diff --git a/src/tchmaterial_parser/ui/download_panel.py b/src/tchmaterial_parser/ui/download_panel.py index c5f54b9..12eccd2 100644 --- a/src/tchmaterial_parser/ui/download_panel.py +++ b/src/tchmaterial_parser/ui/download_panel.py @@ -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") # 恢复下载按钮为启用状态 diff --git a/tests/test_download_paths.py b/tests/test_download_paths.py index b130d73..639d648 100644 --- a/tests/test_download_paths.py +++ b/tests/test_download_paths.py @@ -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()