From 7037877a77b2cc3f7f07699be4e1f580b2372406 Mon Sep 17 00:00:00 2001 From: Timmy Welch Date: Mon, 18 Apr 2022 22:46:46 -0700 Subject: [PATCH] Add a strict mode to file renaming Strict renaming removes all reserved names and characters regardless of operating system, with out strict mode only for the current Operating System Add more edge cases to smart cleanup Add more tests for file renaming --- comicapi/genericmetadata.py | 2 +- comictaggerlib/cli.py | 2 +- comictaggerlib/filerenamer.py | 68 ++++++++++++++++++----------- comictaggerlib/renamewindow.py | 2 +- comictaggerlib/settings.py | 5 +++ comictaggerlib/settingswindow.py | 23 +++++++--- comictaggerlib/ui/settingswindow.ui | 47 ++++++++++++-------- tests/filenames.py | 44 +++++++++++++++++++ tests/test_rename.py | 17 +++++--- 9 files changed, 149 insertions(+), 61 deletions(-) diff --git a/comicapi/genericmetadata.py b/comicapi/genericmetadata.py index 280dc84..a8d03b3 100644 --- a/comicapi/genericmetadata.py +++ b/comicapi/genericmetadata.py @@ -382,7 +382,7 @@ md_test.series_group = "Futuristic Tales" md_test.scan_info = "(CC BY-NC-SA 3.0)" md_test.characters = "Anda" md_test.teams = "Fahrenheit" -md_test.locations = "lonely cottage" +md_test.locations = "lonely cottage " md_test.credits = [ {"person": "Dara Naraghi", "role": "Writer"}, {"person": "Esteve Polls", "role": "Penciller"}, diff --git a/comictaggerlib/cli.py b/comictaggerlib/cli.py index 25f90f8..c9a29b8 100644 --- a/comictaggerlib/cli.py +++ b/comictaggerlib/cli.py @@ -453,7 +453,7 @@ def process_file_cli(filename, opts, settings, match_results: OnlineMatchResults new_ext = ".cbr" if settings.rename_new_renamer: - renamer = FileRenamer2(md) + renamer = FileRenamer2(md, platform="universal" if settings.rename_strict else "auto") else: renamer = FileRenamer(md) renamer.set_template(settings.rename_template) diff --git a/comictaggerlib/filerenamer.py b/comictaggerlib/filerenamer.py index 742a321..6570f64 100644 --- a/comictaggerlib/filerenamer.py +++ b/comictaggerlib/filerenamer.py @@ -20,8 +20,9 @@ import logging import os import re import string +import sys -from pathvalidate import sanitize_filepath +from pathvalidate import sanitize_filename from comicapi.genericmetadata import GenericMetadata from comicapi.issuestring import IssueString @@ -146,9 +147,10 @@ class FileRenamer: class MetadataFormatter(string.Formatter): - def __init__(self, smart_cleanup=False): + def __init__(self, smart_cleanup=False, platform="auto"): super().__init__() self.smart_cleanup = smart_cleanup + self.platform = platform def format_field(self, value, format_spec): if value is None or value == "": @@ -165,31 +167,36 @@ class MetadataFormatter(string.Formatter): # output the literal text if literal_text: if lstrip: - result.append(literal_text.lstrip("-_)}]#")) - else: - result.append(literal_text) + literal_text = literal_text.lstrip("-_)}]#") + if self.smart_cleanup: + lspace = literal_text[0].isspace() + rspace = literal_text[-1].isspace() + literal_text = " ".join(literal_text.split()) + if literal_text == "": + literal_text = " " + else: + if lspace: + literal_text = " " + literal_text + if rspace: + literal_text += " " + result.append(literal_text) + lstrip = False # if there's a field, output it if field_name is not None: field_name = field_name.lower() - # this is some markup, find the object and do - # the formatting + # this is some markup, find the object and do the formatting # handle arg indexing when empty field_names are given. if field_name == "": if auto_arg_index is False: - raise ValueError( - "cannot switch from manual field " "specification to automatic field " "numbering" - ) + raise ValueError("cannot switch from manual field specification to automatic field numbering") field_name = str(auto_arg_index) auto_arg_index += 1 elif field_name.isdigit(): if auto_arg_index: - raise ValueError( - "cannot switch from manual field " "specification to automatic field " "numbering" - ) - # disable auto arg incrementing, if it gets - # used later on, then an exception will be raised + raise ValueError("cannot switch from manual field specification to automatic field numbering") + # disable auto arg incrementing, if it gets used later on, then an exception will be raised auto_arg_index = False # given the field_name, find the object it references @@ -210,18 +217,22 @@ class MetadataFormatter(string.Formatter): if fmt_obj == "" and len(result) > 0 and self.smart_cleanup: lstrip = True result.pop() + if self.smart_cleanup: + fmt_obj = " ".join(fmt_obj.split()) + fmt_obj = sanitize_filename(fmt_obj, platform=self.platform) result.append(fmt_obj) return "".join(result), auto_arg_index class FileRenamer2: - def __init__(self, metadata): + def __init__(self, metadata, platform="auto"): self.template = "{publisher}/{series}/{series} v{volume} #{issue} (of {issue_count}) ({year})" self.smart_cleanup = True self.issue_zero_padding = 3 self.metadata = metadata self.move = False + self.platform = platform def set_metadata(self, metadata: GenericMetadata): self.metadata = metadata @@ -250,7 +261,7 @@ class FileRenamer2: path_components = template.split(os.sep) new_name = "" - fmt = MetadataFormatter(self.smart_cleanup) + fmt = MetadataFormatter(self.smart_cleanup, platform=self.platform) md_dict = vars(md) for role in ["writer", "penciller", "inker", "colorist", "letterer", "cover artist", "editor"]: md_dict[role] = md.get_primary_credit(role) @@ -264,19 +275,24 @@ class FileRenamer2: md_dict["month_abbr"] = "" for Component in path_components: - new_name = os.path.join( - new_name, fmt.vformat(Component, args=[], kwargs=Default(md_dict)).replace("/", "-") - ) + if ( + self.platform.lower() in ["universal", "windows"] or sys.platform.lower() in ["windows"] + ) and self.smart_cleanup: + # colons get special treatment + Component = Component.replace(": ", " - ") + Component = Component.replace(":", "-") + + new_basename = sanitize_filename( + fmt.vformat(Component, args=[], kwargs=Default(md_dict)), platform=self.platform + ).strip() + new_name = os.path.join(new_name, new_basename) new_name += ext - - # # some tweaks to keep various filesystems happy - # new_name = new_name.replace(": ", " - ") - # new_name = new_name.replace(":", "-") + new_basename += ext # remove padding md.issue = IssueString(md.issue).as_string() if self.move: - return sanitize_filepath(new_name.strip()) + return new_name.strip() else: - return os.path.basename(sanitize_filepath(new_name.strip())) + return new_basename.strip() diff --git a/comictaggerlib/renamewindow.py b/comictaggerlib/renamewindow.py index a7102f5..cd4e9e9 100644 --- a/comictaggerlib/renamewindow.py +++ b/comictaggerlib/renamewindow.py @@ -53,7 +53,7 @@ class RenameWindow(QtWidgets.QDialog): self.btnSettings.clicked.connect(self.modify_settings) if self.settings.rename_new_renamer: - self.renamer = FileRenamer2(None) + self.renamer = FileRenamer2(None, platform="universal" if self.settings.rename_strict else "auto") else: self.renamer = FileRenamer(None) diff --git a/comictaggerlib/settings.py b/comictaggerlib/settings.py index 12f159e..c3aac19 100644 --- a/comictaggerlib/settings.py +++ b/comictaggerlib/settings.py @@ -118,6 +118,7 @@ class ComicTaggerSettings: self.rename_dir = "" self.rename_move_dir = False self.rename_new_renamer = False + self.rename_strict = False # Auto-tag stickies self.save_on_low_confidence = False @@ -195,6 +196,7 @@ class ComicTaggerSettings: self.rename_dir = "" self.rename_move_dir = False self.rename_new_renamer = False + self.rename_strict = False # Auto-tag stickies self.save_on_low_confidence = False @@ -360,6 +362,8 @@ class ComicTaggerSettings: self.rename_move_dir = self.config.getboolean("rename", "rename_move_dir") if self.config.has_option("rename", "rename_new_renamer"): self.rename_new_renamer = self.config.getboolean("rename", "rename_new_renamer") + if self.config.has_option("rename", "rename_strict"): + self.rename_strict = self.config.getboolean("rename", "rename_strict") if self.config.has_option("autotag", "save_on_low_confidence"): self.save_on_low_confidence = self.config.getboolean("autotag", "save_on_low_confidence") @@ -461,6 +465,7 @@ class ComicTaggerSettings: self.config.set("rename", "rename_dir", self.rename_dir) self.config.set("rename", "rename_move_dir", self.rename_move_dir) self.config.set("rename", "rename_new_renamer", self.rename_new_renamer) + self.config.set("rename", "rename_strict", self.rename_strict) if not self.config.has_section("autotag"): self.config.add_section("autotag") diff --git a/comictaggerlib/settingswindow.py b/comictaggerlib/settingswindow.py index 9115f12..1e35ca6 100644 --- a/comictaggerlib/settingswindow.py +++ b/comictaggerlib/settingswindow.py @@ -194,7 +194,7 @@ class SettingsWindow(QtWidgets.QDialog): self.settings_to_form() self.rename_error = None - self.rename_test(self.leRenameTemplate.text()) + self.rename_test() self.cbxNewRenamer.clicked.connect(self.new_rename_toggle) self.btnBrowseRar.clicked.connect(self.select_rar) @@ -202,7 +202,10 @@ class SettingsWindow(QtWidgets.QDialog): self.btnResetSettings.clicked.connect(self.reset_settings) self.btnTestKey.clicked.connect(self.test_api_key) self.btnTemplateHelp.clicked.connect(self.show_template_help) - self.leRenameTemplate.textEdited.connect(self.rename_test) + self.leRenameTemplate.textEdited.connect(self.rename__test) + self.cbxMoveFiles.clicked.connect(self.rename_test) + self.cbxRenameStrict.clicked.connect(self.rename_test) + self.leDirectory.textEdited.connect(self.rename_test) def new_rename_toggle(self): new_rename = self.cbxNewRenamer.isChecked() @@ -215,16 +218,19 @@ class SettingsWindow(QtWidgets.QDialog): self.cbxMoveFiles.setEnabled(new_rename) self.leDirectory.setEnabled(new_rename) self.lblDirectory.setEnabled(new_rename) - self.rename_test(self.leRenameTemplate.text()) + self.rename_test() - def rename_test(self, template): + def rename_test(self): + self.rename__test(self.leRenameTemplate.text()) + + def rename__test(self, template): fr = FileRenamer(md_test) if self.cbxNewRenamer.isChecked(): - fr = FileRenamer2(md_test) + fr = FileRenamer2(md_test, platform="universal" if self.cbxRenameStrict.isChecked() else "auto") + fr.move = self.cbxMoveFiles.isChecked() fr.set_template(template) fr.set_issue_zero_padding(int(self.leIssueNumPadding.text())) fr.set_smart_cleanup(self.cbxSmartCleanup.isChecked()) - fr.move = self.cbxMoveFiles.isChecked() try: self.lblRenameTest.setText(fr.determine_name(".cbz")) self.rename_error = None @@ -288,9 +294,11 @@ class SettingsWindow(QtWidgets.QDialog): if self.settings.rename_move_dir: self.cbxMoveFiles.setCheckState(QtCore.Qt.CheckState.Checked) self.leDirectory.setText(self.settings.rename_dir) + if self.settings.rename_strict: + self.cbxRenameStrict.setCheckState(QtCore.Qt.CheckState.Checked) def accept(self): - self.rename_test(self.leRenameTemplate.text()) + self.rename_test() if self.rename_error is not None: QtWidgets.QMessageBox.critical( self, @@ -352,6 +360,7 @@ class SettingsWindow(QtWidgets.QDialog): self.settings.rename_dir = self.leDirectory.text() self.settings.rename_new_renamer = self.cbxNewRenamer.isChecked() + self.settings.rename_strict = self.cbxRenameStrict.isChecked() self.settings.save() QtWidgets.QDialog.accept(self) diff --git a/comictaggerlib/ui/settingswindow.ui b/comictaggerlib/ui/settingswindow.ui index d0a8fc7..bfc112d 100644 --- a/comictaggerlib/ui/settingswindow.ui +++ b/comictaggerlib/ui/settingswindow.ui @@ -580,6 +580,19 @@ + + + + + + + Qt::PlainText + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse + + + @@ -623,6 +636,13 @@ + + + + Enable the new renamer + + + @@ -633,33 +653,24 @@ - + Destination Directory: - + - - + + + + If checked will ensure reserved characters and filenames are removed for all Operating Systems. +By default only removes restricted characters and filenames for the current Operating System. + - Enable the new renamer - - - - - - - - - - Qt::PlainText - - - Qt::LinksAccessibleByMouse|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse + Strict renaming diff --git a/tests/filenames.py b/tests/filenames.py index 46c0b25..0595d9a 100644 --- a/tests/filenames.py +++ b/tests/filenames.py @@ -423,10 +423,54 @@ fnames = [ rnames = [ ( "{series} #{issue} - {title} ({year})", + False, + "universal", "Cory Doctorow's Futuristic Tales of the Here and Now #001 - Anda's Game (2007).cbz", ), + ( + "{series}: {title} #{issue} ({year})", + False, + "universal", + "Cory Doctorow's Futuristic Tales of the Here and Now - Anda's Game #001 (2007).cbz", + ), + pytest.param( + "{series}: {title} #{issue} ({year})", + False, + "Linux", + "Cory Doctorow's Futuristic Tales of the Here and Now: Anda's Game #001 (2007).cbz", + marks=pytest.mark.xfail, + ), + pytest.param( + "{publisher}/ {series} #{issue} - {title} ({year})", + True, + "universal", + "IDW Publishing/Cory Doctorow's Futuristic Tales of the Here and Now #001 - Anda's Game (2007).cbz", + marks=pytest.mark.xfail, + ), + pytest.param( + "{publisher}/ {series} #{issue} - {title} ({year})", + False, + "universal", + "Cory Doctorow's Futuristic Tales of the Here and Now #001 - Anda's Game (2007).cbz", + marks=pytest.mark.xfail, + ), + ( + "{series} # {issue} - {title} ({year})", + False, + "universal", + "Cory Doctorow's Futuristic Tales of the Here and Now # 001 - Anda's Game (2007).cbz", + ), + pytest.param( + "{series} # {issue} - {locations} ({year})", + False, + "universal", + "Cory Doctorow's Futuristic Tales of the Here and Now # 001 - lonely cottage (2007).cbz", + marks=pytest.mark.xfail, + ), pytest.param( "{series} #{issue} - {title} - {WriteR}, {EDITOR} ({year})", + False, + "universal", "Cory Doctorow's Futuristic Tales of the Here and Now #001 - Anda's Game - Dara Naraghi, Ted Adams (2007).cbz", marks=pytest.mark.xfail, ), diff --git a/tests/test_rename.py b/tests/test_rename.py index bcd57db..9f01aef 100644 --- a/tests/test_rename.py +++ b/tests/test_rename.py @@ -7,15 +7,18 @@ from comicapi.genericmetadata import md_test from comictaggerlib.filerenamer import FileRenamer, FileRenamer2 -@pytest.mark.parametrize("template,result", rnames) -def test_rename_old(template, result): +@pytest.mark.parametrize("template, move, platform, expected", rnames) +def test_rename_old(template, platform, move, expected): + _ = platform + _ = move fr = FileRenamer(md_test) fr.set_template(re.sub(r"{(\w+)}", r"%\1%", template)) - assert fr.determine_name(".cbz") == result + assert fr.determine_name(".cbz") == expected -@pytest.mark.parametrize("template,result", rnames) -def test_rename_new(template, result): - fr = FileRenamer2(md_test) +@pytest.mark.parametrize("template, move, platform, expected", rnames) +def test_rename_new(template, platform, move, expected): + fr = FileRenamer2(md_test, platform=platform) + fr.move = move fr.set_template(template) - assert fr.determine_name(".cbz") == result + assert fr.determine_name(".cbz") == expected