mirror of
https://github.com/happycola233/tchMaterial-parser.git
synced 2026-10-17 05:11:30 +08:00
fix: 用全角标点替换文件名中的非法字符,避免 Windows 下载失败 (#86)
This commit is contained in:
@@ -137,8 +137,39 @@ def download_failure_reason(response, attempted_urls: list[str]) -> str:
|
||||
reason += f",已尝试 {len(attempted_urls)} 个下载镜像"
|
||||
return reason
|
||||
|
||||
# Windows 禁止在文件名中使用半角 ? * : / 等;改为对应全角字符,尽量保留原标题读法(#86)。
|
||||
_INVALID_FILENAME_REPLACEMENTS = str.maketrans({
|
||||
"<": "<",
|
||||
">": ">",
|
||||
":": ":",
|
||||
'"': """,
|
||||
"/": "/",
|
||||
"\\": "\",
|
||||
"|": "|",
|
||||
"?": "?",
|
||||
"*": "*",
|
||||
})
|
||||
_CONTROL_FILENAME_CHARS = re.compile(r"[\x00-\x1f]")
|
||||
_WINDOWS_RESERVED_NAMES = frozenset({
|
||||
"CON", "PRN", "AUX", "NUL",
|
||||
*(f"COM{i}" for i in range(10)),
|
||||
*(f"LPT{i}" for i in range(10)),
|
||||
})
|
||||
|
||||
def sanitize_filename(filename: str) -> str:
|
||||
"""将非法文件名字符换成全角对应字符,并避开 Windows 保留设备名。"""
|
||||
filename = _CONTROL_FILENAME_CHARS.sub("_", filename.translate(_INVALID_FILENAME_REPLACEMENTS))
|
||||
filename = filename.rstrip(" .")
|
||||
if not filename:
|
||||
return "download"
|
||||
|
||||
stem, extension = os.path.splitext(filename)
|
||||
if stem.upper() in _WINDOWS_RESERVED_NAMES:
|
||||
return f"_{stem}{extension}"
|
||||
return filename
|
||||
|
||||
def download_filename(resource: ResourceInfo) -> str:
|
||||
return f"{resource.title or 'download'}.{resource.file_format}"
|
||||
return sanitize_filename(f"{resource.title or 'download'}.{resource.file_format}")
|
||||
|
||||
def filename_key(filename: str) -> str:
|
||||
"""以跨平台保守方式比较文件名,提前避开 Windows/macOS 上的大小写冲突。"""
|
||||
@@ -153,7 +184,7 @@ def allocate_download_paths(resources: list[ResourceInfo], directory: str) -> li
|
||||
for resource, filename in zip(resources, base_filenames):
|
||||
# 例如人教版与北师大版的“普通高中教科书·英语必修 第三册”同名时,优先使用易读的版别前缀区分。
|
||||
if base_counts[filename_key(filename)] > 1 and resource.edition:
|
||||
filename = f"[{resource.edition}] {filename}"
|
||||
filename = sanitize_filename(f"[{resource.edition}] {filename}")
|
||||
edition_filenames.append(filename)
|
||||
|
||||
reserved_paths: set[str] = set()
|
||||
@@ -258,7 +289,7 @@ def download() -> None: # 下载资源文件
|
||||
save_path = filedialog.asksaveasfilename( # 选择保存路径
|
||||
defaultextension=f".{resource.file_format}",
|
||||
filetypes=[(f"{resource.file_format.upper()} 文件", f"*.{resource.file_format}"), ("所有文件", "*.*")],
|
||||
initialfile=resource.title or "download",
|
||||
initialfile=sanitize_filename(resource.title or "download"),
|
||||
)
|
||||
if not save_path: # 用户取消了文件保存操作
|
||||
download_btn.config(state="normal") # 恢复下载按钮为启用状态
|
||||
|
||||
@@ -3,7 +3,11 @@ import tempfile
|
||||
import unittest
|
||||
|
||||
from src.tchmaterial_parser.api import ResourceInfo
|
||||
from src.tchmaterial_parser.ui.download_panel import allocate_download_paths
|
||||
from src.tchmaterial_parser.ui.download_panel import (
|
||||
allocate_download_paths,
|
||||
download_filename,
|
||||
sanitize_filename,
|
||||
)
|
||||
|
||||
|
||||
def resource(
|
||||
@@ -76,6 +80,77 @@ class DownloadPathTest(unittest.TestCase):
|
||||
|
||||
self.assertEqual([os.path.basename(path) for path in paths], ["已有教材 (2).pdf", "未完成教材 (2).pdf"])
|
||||
|
||||
def test_issue_86_replaces_windows_illegal_filename_characters(self) -> None:
|
||||
resources = [
|
||||
resource(
|
||||
"(根据2022年版课程标准修订)义务教育教科书英语九年级上册 - Unit 3 Understanding ideas_2 Read the speech. What does the title mean?",
|
||||
"audio-1",
|
||||
file_format="mp3",
|
||||
),
|
||||
resource(
|
||||
"(根据2022年版课程标准修订)义务教育教科书英语九年级上册 - Unit 4 Understanding ideas_2 Read the passage. What is the writer's relationship with Zhao Yiman?",
|
||||
"audio-2",
|
||||
file_format="mp3",
|
||||
),
|
||||
resource("听力 * 跟读 1/2", "audio-3", file_format="mp3"),
|
||||
]
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
paths = allocate_download_paths(resources, directory)
|
||||
names = [os.path.basename(path) for path in paths]
|
||||
self.assertEqual(names, [
|
||||
"(根据2022年版课程标准修订)义务教育教科书英语九年级上册 - Unit 3 Understanding ideas_2 Read the speech. What does the title mean?.mp3",
|
||||
"(根据2022年版课程标准修订)义务教育教科书英语九年级上册 - Unit 4 Understanding ideas_2 Read the passage. What is the writer's relationship with Zhao Yiman?.mp3",
|
||||
"听力 * 跟读 1/2.mp3",
|
||||
])
|
||||
for path in paths:
|
||||
with open(f"{path}.tmp", "wb") as file:
|
||||
file.write(b"ok")
|
||||
|
||||
def test_fullwidth_replacements_keep_distinct_punctuation_apart(self) -> None:
|
||||
resources = [
|
||||
resource("同名教材?", "book-1"),
|
||||
resource("同名教材*", "book-2"),
|
||||
]
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
paths = allocate_download_paths(resources, directory)
|
||||
|
||||
self.assertEqual([os.path.basename(path) for path in paths], [
|
||||
"同名教材?.pdf",
|
||||
"同名教材*.pdf",
|
||||
])
|
||||
|
||||
def test_sanitized_titles_that_collide_still_get_a_sequence(self) -> None:
|
||||
resources = [
|
||||
resource("同名教材?", "book-1"),
|
||||
resource("同名教材?", "book-2"),
|
||||
]
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
paths = allocate_download_paths(resources, directory)
|
||||
|
||||
self.assertEqual([os.path.basename(path) for path in paths], [
|
||||
"同名教材?.pdf",
|
||||
"同名教材? (2).pdf",
|
||||
])
|
||||
|
||||
|
||||
class SanitizeFilenameTest(unittest.TestCase):
|
||||
def test_uses_fullwidth_punctuation_and_strips_trailing_dots(self) -> None:
|
||||
self.assertEqual(sanitize_filename('a<>:"/\\|?*b'), "a<>:"/\|?*b")
|
||||
self.assertEqual(sanitize_filename("结束."), "结束")
|
||||
self.assertEqual(sanitize_filename("???"), "???")
|
||||
self.assertEqual(sanitize_filename("..."), "download")
|
||||
|
||||
def test_prefixes_windows_reserved_device_names(self) -> None:
|
||||
self.assertEqual(sanitize_filename("CON.mp3"), "_CON.mp3")
|
||||
self.assertEqual(sanitize_filename("nul"), "_nul")
|
||||
|
||||
def test_download_filename_sanitizes_title_but_keeps_extension(self) -> None:
|
||||
info = resource("What does the title mean?", "audio-1", file_format="mp3")
|
||||
self.assertEqual(download_filename(info), "What does the title mean?.mp3")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user