diff --git a/comicapi/genericmetadata.py b/comicapi/genericmetadata.py index 8d2d3db..db67741 100644 --- a/comicapi/genericmetadata.py +++ b/comicapi/genericmetadata.py @@ -255,6 +255,15 @@ class GenericMetadata: if not found: self.credits.append(credit) + def get_primary_credit(self, role): + primary = "" + for credit in self.credits: + if (primary == "" and credit["role"].lower() == role.lower()) or ( + credit["role"].lower() == role.lower() and credit["primary"] + ): + primary = credit["person"] + return primary + def __str__(self): vals = [] if self.is_empty: @@ -328,3 +337,93 @@ class GenericMetadata: outstr += fmt_str.format(i[0] + ":", i[1]) return outstr + + +md_test = GenericMetadata() + +md_test.is_empty = False +md_test.tag_origin = None +md_test.series = "Cory Doctorow's Futuristic Tales of the Here and Now" +md_test.issue = "1" +md_test.title = "Anda's Game" +md_test.publisher = "IDW Publishing" +md_test.month = 10 +md_test.year = 2007 +md_test.day = 1 +md_test.issue_count = 6 +md_test.volume = 1 +md_test.genre = "Sci-Fi" +md_test.language = "en" +md_test.comments = ( + "For 12-year-old Anda, getting paid real money to kill the characters of players who were cheating in her favorite online " + "computer game was a win-win situation. Until she found out who was paying her, and what those characters meant to the " + "livelihood of children around the world." +) +md_test.volume_count = None +md_test.critical_rating = None +md_test.country = None +md_test.alternate_series = "Tales" +md_test.alternate_number = "2" +md_test.alternate_count = 7 +md_test.imprint = "craphound.com" +md_test.notes = "Tagged with ComicTagger 1.3.2a5 using info from Comic Vine on 2022-04-16 15:52:26. [Issue ID 140529]" +md_test.web_link = "https://comicvine.gamespot.com/cory-doctorows-futuristic-tales-of-the-here-and-no/4000-140529/" +md_test.format = "Series" +md_test.manga = "No" +md_test.black_and_white = None +md_test.page_count = 24 +md_test.maturity_rating = "Everyone 10+" +md_test.story_arc = "Here and Now" +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.credits = [ + {"person": "Dara Naraghi", "role": "Writer"}, + {"person": "Esteve Polls", "role": "Penciller"}, + {"person": "Esteve Polls", "role": "Inker"}, + {"person": "Neil Uyetake", "role": "Letterer"}, + {"person": "Sam Kieth", "role": "Cover"}, + {"person": "Ted Adams", "role": "Editor"}, +] +md_test.tags = [] +md_test.pages = [ + {"Image": "0", "ImageHeight": "1280", "ImageSize": "195977", "ImageWidth": "800", "Type": "FrontCover"}, + {"Image": "1", "ImageHeight": "2039", "ImageSize": "611993", "ImageWidth": "1327"}, + {"Image": "2", "ImageHeight": "2039", "ImageSize": "783726", "ImageWidth": "1327"}, + {"Image": "3", "ImageHeight": "2039", "ImageSize": "679584", "ImageWidth": "1327"}, + {"Image": "4", "ImageHeight": "2039", "ImageSize": "788179", "ImageWidth": "1327"}, + {"Image": "5", "ImageHeight": "2039", "ImageSize": "864433", "ImageWidth": "1327"}, + {"Image": "6", "ImageHeight": "2039", "ImageSize": "765606", "ImageWidth": "1327"}, + {"Image": "7", "ImageHeight": "2039", "ImageSize": "876427", "ImageWidth": "1327"}, + {"Image": "8", "ImageHeight": "2039", "ImageSize": "852622", "ImageWidth": "1327"}, + {"Image": "9", "ImageHeight": "2039", "ImageSize": "800205", "ImageWidth": "1327"}, + {"Image": "10", "ImageHeight": "2039", "ImageSize": "746243", "ImageWidth": "1326"}, + {"Image": "11", "ImageHeight": "2039", "ImageSize": "718062", "ImageWidth": "1327"}, + {"Image": "12", "ImageHeight": "2039", "ImageSize": "532179", "ImageWidth": "1326"}, + {"Image": "13", "ImageHeight": "2039", "ImageSize": "686708", "ImageWidth": "1327"}, + {"Image": "14", "ImageHeight": "2039", "ImageSize": "641907", "ImageWidth": "1327"}, + {"Image": "15", "ImageHeight": "2039", "ImageSize": "805388", "ImageWidth": "1327"}, + {"Image": "16", "ImageHeight": "2039", "ImageSize": "668927", "ImageWidth": "1326"}, + {"Image": "17", "ImageHeight": "2039", "ImageSize": "710605", "ImageWidth": "1327"}, + {"Image": "18", "ImageHeight": "2039", "ImageSize": "761398", "ImageWidth": "1326"}, + {"Image": "19", "ImageHeight": "2039", "ImageSize": "743807", "ImageWidth": "1327"}, + {"Image": "20", "ImageHeight": "2039", "ImageSize": "552911", "ImageWidth": "1326"}, + {"Image": "21", "ImageHeight": "2039", "ImageSize": "556827", "ImageWidth": "1327"}, + {"Image": "22", "ImageHeight": "2039", "ImageSize": "675078", "ImageWidth": "1326"}, + { + "Bookmark": "Interview", + "Image": "23", + "ImageHeight": "2032", + "ImageSize": "800965", + "ImageWidth": "1338", + "Type": "Letters", + }, +] +md_test.price = None +md_test.is_version_of = None +md_test.rights = None +md_test.identifier = None +md_test.last_mark = None +md_test.cover_image = None diff --git a/comictaggerlib/cli.py b/comictaggerlib/cli.py index a386095..c9a29b8 100644 --- a/comictaggerlib/cli.py +++ b/comictaggerlib/cli.py @@ -27,7 +27,7 @@ from comicapi.comicarchive import ComicArchive, MetaDataStyle from comicapi.genericmetadata import GenericMetadata from comictaggerlib.cbltransformer import CBLTransformer from comictaggerlib.comicvinetalker import ComicVineTalker, ComicVineTalkerException -from comictaggerlib.filerenamer import FileRenamer +from comictaggerlib.filerenamer import FileRenamer, FileRenamer2 from comictaggerlib.issueidentifier import IssueIdentifier from comictaggerlib.resulttypes import MultipleMatch, OnlineMatchResults from comictaggerlib.settings import ComicTaggerSettings @@ -155,6 +155,13 @@ def cli_mode(opts, settings): logger.error("You must specify at least one filename. Use the -h option for more info") return + if not settings.hide_rename_message: + print( + "There is a new rename template format available. " + "Please use the settings window to enable and test if you use this feature.\n\n" + "The old rename template format will be removed in the next release, " + "please reference the template help button in the settings or https://github.com/comictagger/comictagger/wiki/UserGuide#rename", + ) match_results = OnlineMatchResults() for f in opts.file_list: @@ -445,24 +452,42 @@ def process_file_cli(filename, opts, settings, match_results: OnlineMatchResults elif ca.is_rar(): new_ext = ".cbr" - renamer = FileRenamer(md) + if settings.rename_new_renamer: + renamer = FileRenamer2(md, platform="universal" if settings.rename_strict else "auto") + else: + renamer = FileRenamer(md) renamer.set_template(settings.rename_template) renamer.set_issue_zero_padding(settings.rename_issue_number_padding) renamer.set_smart_cleanup(settings.rename_use_smart_string_cleanup) + renamer.move = settings.rename_move_dir - new_name = renamer.determine_name(ca.path, ext=new_ext) - - if new_name == os.path.basename(ca.path): - logger.error(msg_hdr + "Filename is already good!") + try: + new_name = renamer.determine_name(ext=new_ext) + except Exception as e: + print( + msg_hdr + "Invalid format string!\nYour rename template is invalid!\n\n" + "{}\n\nPlease consult the template help in the settings " + "and the documentation on the format at " + "https://docs.python.org/3/library/string.html#format-string-syntax".format(e), + file=sys.stderr, + ) return - folder = os.path.dirname(os.path.abspath(ca.path)) + folder = os.path.dirname(os.path.abspath(filename)) + if settings.rename_move_dir and len(settings.rename_dir.strip()) > 3: + folder = settings.rename_dir.strip() + new_abs_path = utils.unique_file(os.path.join(folder, new_name)) + if os.path.join(folder, new_name) == os.path.abspath(filename): + print(msg_hdr + "Filename is already good!", file=sys.stderr) + return + suffix = "" if not opts.dryrun: # rename the file - os.rename(ca.path, new_abs_path) + os.makedirs(os.path.dirname(new_abs_path), 0o777, True) + os.rename(filename, new_abs_path) else: suffix = " (dry-run, no change)" diff --git a/comictaggerlib/filerenamer.py b/comictaggerlib/filerenamer.py index e35aa63..6570f64 100644 --- a/comictaggerlib/filerenamer.py +++ b/comictaggerlib/filerenamer.py @@ -14,10 +14,15 @@ # See the License for the specific language governing permissions and # limitations under the License. +import calendar import datetime import logging import os import re +import string +import sys + +from pathvalidate import sanitize_filename from comicapi.genericmetadata import GenericMetadata from comicapi.issuestring import IssueString @@ -69,7 +74,7 @@ class FileRenamer: return text.replace(token, "") - def determine_name(self, filename, ext=None): + def determine_name(self, ext): md = self.metadata new_name = self.template @@ -129,9 +134,6 @@ class FileRenamer: # remove duplicate spaces (again!) new_name = " ".join(new_name.split()) - if ext is None: - ext = os.path.splitext(filename)[1] - new_name += ext # some tweaks to keep various filesystems happy @@ -142,3 +144,155 @@ class FileRenamer: new_name = new_name.replace("?", "") return new_name + + +class MetadataFormatter(string.Formatter): + 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 == "": + return "" + return super().format_field(value, format_spec) + + def _vformat(self, format_string, args, kwargs, used_args, recursion_depth, auto_arg_index=0): + if recursion_depth < 0: + raise ValueError("Max string recursion exceeded") + result = [] + lstrip = False + for literal_text, field_name, format_spec, conversion in self.parse(format_string): + + # output the literal text + if literal_text: + if lstrip: + 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 + + # 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") + 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 + auto_arg_index = False + + # given the field_name, find the object it references + # and the argument it came from + obj, arg_used = self.get_field(field_name, args, kwargs) + used_args.add(arg_used) + + # do any conversion on the resulting object + obj = self.convert_field(obj, conversion) + + # expand the format spec, if needed + format_spec, auto_arg_index = self._vformat( + format_spec, args, kwargs, used_args, recursion_depth - 1, auto_arg_index=auto_arg_index + ) + + # format the object and append to the result + fmt_obj = self.format_field(obj, format_spec) + 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, 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 + + def set_issue_zero_padding(self, count): + self.issue_zero_padding = count + + def set_smart_cleanup(self, on): + self.smart_cleanup = on + + def set_template(self, template: str): + self.template = template + + def determine_name(self, ext): + class Default(dict): + def __missing__(self, key): + return "{" + key + "}" + + md = self.metadata + + # padding for issue + md.issue = IssueString(md.issue).as_string(pad=self.issue_zero_padding) + + template = self.template + + path_components = template.split(os.sep) + new_name = "" + + 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) + + if (isinstance(md.month, int) or isinstance(md.month, str) and md.month.isdigit()) and 0 < int(md.month) < 13: + md_dict["month_name"] = calendar.month_name[int(md.month)] + md_dict["month_abbr"] = calendar.month_abbr[int(md.month)] + else: + print(md.month) + md_dict["month_name"] = "" + md_dict["month_abbr"] = "" + + for Component in path_components: + 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 + new_basename += ext + + # remove padding + md.issue = IssueString(md.issue).as_string() + if self.move: + return new_name.strip() + else: + return new_basename.strip() diff --git a/comictaggerlib/renamewindow.py b/comictaggerlib/renamewindow.py index bb02a84..cd4e9e9 100644 --- a/comictaggerlib/renamewindow.py +++ b/comictaggerlib/renamewindow.py @@ -23,7 +23,7 @@ from PyQt5 import QtCore, QtWidgets, uic import comicapi.comicarchive from comicapi import utils from comicapi.comicarchive import MetaDataStyle -from comictaggerlib.filerenamer import FileRenamer +from comictaggerlib.filerenamer import FileRenamer, FileRenamer2 from comictaggerlib.settings import ComicTaggerSettings from comictaggerlib.settingswindow import SettingsWindow from comictaggerlib.ui.qtutils import center_window_on_parent @@ -52,7 +52,11 @@ class RenameWindow(QtWidgets.QDialog): self.rename_list = [] self.btnSettings.clicked.connect(self.modify_settings) - self.renamer = FileRenamer(None) + if self.settings.rename_new_renamer: + self.renamer = FileRenamer2(None, platform="universal" if self.settings.rename_strict else "auto") + else: + self.renamer = FileRenamer(None) + self.config_renamer() self.do_preview() @@ -82,7 +86,22 @@ class RenameWindow(QtWidgets.QDialog): if md.is_empty: md = ca.metadata_from_filename(self.settings.parse_scan_info) self.renamer.set_metadata(md) - new_name = self.renamer.determine_name(ca.path, ext=new_ext) + self.renamer.move = self.settings.rename_move_dir + + try: + new_name = self.renamer.determine_name(new_ext) + except Exception as e: + QtWidgets.QMessageBox.critical( + self, + "Invalid format string!", + "Your rename template is invalid!" + "

{}

" + "Please consult the template help in the " + "settings and the documentation on the format at " + "" + "https://docs.python.org/3/library/string.html#format-string-syntax".format(e), + ) + return row = self.twList.rowCount() self.twList.insertRow(row) @@ -150,7 +169,13 @@ class RenameWindow(QtWidgets.QDialog): center_window_on_parent(prog_dialog) QtCore.QCoreApplication.processEvents() - if item["new_name"] == os.path.basename(item["archive"].path): + folder = os.path.dirname(os.path.abspath(item["archive"].path)) + if self.settings.rename_move_dir and len(self.settings.rename_dir.strip()) > 3: + folder = self.settings.rename_dir.strip() + + new_abs_path = utils.unique_file(os.path.join(folder, item["new_name"])) + + if os.path.join(folder, item["new_name"]) == item["archive"].path: print(item["new_name"], "Filename is already good!") logger.info(item["new_name"], "Filename is already good!") continue @@ -158,9 +183,7 @@ class RenameWindow(QtWidgets.QDialog): if not item["archive"].is_writable(check_rar_status=False): continue - folder = os.path.dirname(os.path.abspath(item["archive"].path)) - new_abs_path = utils.unique_file(os.path.join(folder, item["new_name"])) - + os.makedirs(os.path.dirname(new_abs_path), 0o777, True) os.rename(item["archive"].path, new_abs_path) item["archive"].rename(new_abs_path) diff --git a/comictaggerlib/settings.py b/comictaggerlib/settings.py index 12d0b6f..1ab5929 100644 --- a/comictaggerlib/settings.py +++ b/comictaggerlib/settings.py @@ -86,6 +86,7 @@ class ComicTaggerSettings: self.show_disclaimer = True self.dont_notify_about_this_version = "" self.ask_about_usage_stats = True + self.hide_rename_message = False # filename parsing settings self.parse_scan_info = True @@ -117,6 +118,10 @@ class ComicTaggerSettings: self.rename_issue_number_padding = 3 self.rename_use_smart_string_cleanup = True self.rename_extension_based_on_archive = True + 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 @@ -156,6 +161,7 @@ class ComicTaggerSettings: self.show_disclaimer = True self.dont_notify_about_this_version = "" self.ask_about_usage_stats = True + self.hide_rename_message = False # filename parsing settings self.parse_scan_info = True @@ -187,6 +193,10 @@ class ComicTaggerSettings: self.rename_issue_number_padding = 3 self.rename_use_smart_string_cleanup = True self.rename_extension_based_on_archive = True + 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 @@ -292,6 +302,8 @@ class ComicTaggerSettings: self.dont_notify_about_this_version = self.config.get("dialogflags", "dont_notify_about_this_version") if self.config.has_option("dialogflags", "ask_about_usage_stats"): self.ask_about_usage_stats = self.config.getboolean("dialogflags", "ask_about_usage_stats") + if self.config.has_option("dialogflags", "hide_rename_message"): + self.hide_rename_message = self.config.getboolean("dialogflags", "hide_rename_message") if self.config.has_option("comicvine", "use_series_start_as_volume"): self.use_series_start_as_volume = self.config.getboolean("comicvine", "use_series_start_as_volume") @@ -347,6 +359,14 @@ class ComicTaggerSettings: self.rename_extension_based_on_archive = self.config.getboolean( "rename", "rename_extension_based_on_archive" ) + if self.config.has_option("rename", "rename_dir"): + self.rename_dir = self.config.get("rename", "rename_dir") + if self.config.has_option("rename", "rename_move_dir"): + 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") @@ -403,6 +423,7 @@ class ComicTaggerSettings: self.config.set("dialogflags", "show_disclaimer", self.show_disclaimer) self.config.set("dialogflags", "dont_notify_about_this_version", self.dont_notify_about_this_version) self.config.set("dialogflags", "ask_about_usage_stats", self.ask_about_usage_stats) + self.config.set("dialogflags", "hide_rename_message", self.hide_rename_message) if not self.config.has_section("filenameparser"): self.config.add_section("filenameparser") @@ -444,6 +465,10 @@ class ComicTaggerSettings: self.config.set("rename", "rename_issue_number_padding", self.rename_issue_number_padding) self.config.set("rename", "rename_use_smart_string_cleanup", self.rename_use_smart_string_cleanup) self.config.set("rename", "rename_extension_based_on_archive", self.rename_extension_based_on_archive) + 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 904ee36..1e35ca6 100644 --- a/comictaggerlib/settingswindow.py +++ b/comictaggerlib/settingswindow.py @@ -17,12 +17,15 @@ import logging import os import platform +import re from PyQt5 import QtCore, QtGui, QtWidgets, uic from comicapi import utils +from comicapi.genericmetadata import md_test from comictaggerlib.comicvinecacher import ComicVineCacher from comictaggerlib.comicvinetalker import ComicVineTalker +from comictaggerlib.filerenamer import FileRenamer, FileRenamer2 from comictaggerlib.imagefetcher import ImageFetcher from comictaggerlib.settings import ComicTaggerSettings @@ -54,6 +57,82 @@ macRarHelp = """

Once homebrew is installed, run: brew install caskroom/cask/rar """ +old_template_tooltip = """ +

The template for the new filename. Accepts the following variables:

%series%
%issue%
%volume%
%issuecount%
%year%
%month%
%month_name%
%publisher%
%title%
+%genre%
+%language_code%
+%criticalrating%
+%alternateseries%
+%alternatenumber%
+%alternatecount%
+%imprint%
+%format%
+%maturityrating%
+%storyarc%
+%seriesgroup%
+%scaninfo% +

Examples:

%series% %issue% (%year%)
%series% #%issue% - %title%

+""" + +new_template_tooltip = """ +
The template for the new filename. Uses python format strings https://docs.python.org/3/library/string.html#format-string-syntax
+Accepts the following variables:
+{isEmpty}       (boolean)
+{tagOrigin}     (string)
+{series}        (string)
+{issue}     (string)
+{title}     (string)
+{publisher}     (string)
+{month}     (integer)
+{year}      (integer)
+{day}       (integer)
+{issueCount}    (integer)
+{volume}        (integer)
+{genre}     (string)
+{language}      (string)
+{comments}      (string)
+{volumeCount}   (integer)
+{criticalRating}    (string)
+{country}       (string)
+{alternateSeries}   (string)
+{alternateNumber}   (string)
+{alternateCount}    (integer)
+{imprint}       (string)
+{notes}     (string)
+{webLink}       (string)
+{format}        (string)
+{manga}     (string)
+{blackAndWhite} (boolean)
+{pageCount}     (integer)
+{maturityRating}    (string)
+{storyArc}      (string)
+{seriesGroup}   (string)
+{scanInfo}      (string)
+{characters}    (string)
+{teams}     (string)
+{locations}     (string)
+{credits}       (list of dict({'role': 'str', 'person': 'str', 'primary': boolean}))
+{tags}      (list of str)
+{pages}     (list of dict({'Image': 'str(int)', 'Type': 'str'}))
+
+CoMet-only items:
+{price}     (float)
+{isVersionOf}   (string)
+{rights}        (string)
+{identifier}    (string)
+{lastMark}  (string)
+{coverImage}    (string)
+
+Examples:
+
+{series} {issue} ({year})
+Spider-Geddon 1 (2018)
+
+{series} #{issue} - {title}
+Spider-Geddon #1 - New Players; Check In
+
+""" + class SettingsWindow(QtWidgets.QDialog): def __init__(self, parent, settings): @@ -105,15 +184,61 @@ class SettingsWindow(QtWidgets.QDialog): validator = QtGui.QIntValidator(0, 99, self) self.leNameLengthDeltaThresh.setValidator(validator) - self.settings_to_form() + new_rename = self.settings.rename_new_renamer + self.cbxNewRenamer.setChecked(new_rename) + self.cbxMoveFiles.setEnabled(new_rename) + self.leDirectory.setEnabled(new_rename) + self.lblDirectory.setEnabled(new_rename) + if self.settings.rename_new_renamer: + self.leRenameTemplate.setToolTip(new_template_tooltip) + self.settings_to_form() + self.rename_error = None + self.rename_test() + + self.cbxNewRenamer.clicked.connect(self.new_rename_toggle) self.btnBrowseRar.clicked.connect(self.select_rar) self.btnClearCache.clicked.connect(self.clear_cache) 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.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() + if new_rename: + self.leRenameTemplate.setText(re.sub(r"%(\w+)%", r"{\1}", self.leRenameTemplate.text())) + self.leRenameTemplate.setToolTip(new_template_tooltip) + else: + self.leRenameTemplate.setText(re.sub(r"{(\w+)}", r"%\1%", self.leRenameTemplate.text())) + self.leRenameTemplate.setToolTip(old_template_tooltip) + self.cbxMoveFiles.setEnabled(new_rename) + self.leDirectory.setEnabled(new_rename) + self.lblDirectory.setEnabled(new_rename) + self.rename_test() + + 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, 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()) + try: + self.lblRenameTest.setText(fr.determine_name(".cbz")) + self.rename_error = None + except Exception as e: + self.rename_error = e + self.lblRenameTest.setText(str(e)) def settings_to_form(self): - # Copy values from settings to form self.leRarExePath.setText(self.settings.rar_exe_path) self.leNameLengthDeltaThresh.setText(str(self.settings.id_length_delta_thresh)) @@ -166,8 +291,26 @@ class SettingsWindow(QtWidgets.QDialog): self.cbxSmartCleanup.setCheckState(QtCore.Qt.CheckState.Checked) if self.settings.rename_extension_based_on_archive: self.cbxChangeExtension.setCheckState(QtCore.Qt.CheckState.Checked) + 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() + if self.rename_error is not None: + QtWidgets.QMessageBox.critical( + self, + "Invalid format string!", + "Your rename template is invalid!" + "

{}

" + "Please consult the template help in the " + "settings and the documentation on the format at " + "" + "https://docs.python.org/3/library/string.html#format-string-syntax".format(self.rename_error), + ) + return # Copy values from form to settings and save self.settings.rar_exe_path = str(self.leRarExePath.text()) @@ -213,6 +356,11 @@ class SettingsWindow(QtWidgets.QDialog): self.settings.rename_issue_number_padding = int(self.leIssueNumPadding.text()) self.settings.rename_use_smart_string_cleanup = self.cbxSmartCleanup.isChecked() self.settings.rename_extension_based_on_archive = self.cbxChangeExtension.isChecked() + self.settings.rename_move_dir = self.cbxMoveFiles.isChecked() + 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) @@ -262,3 +410,17 @@ class SettingsWindow(QtWidgets.QDialog): def show_rename_tab(self): self.tabWidget.setCurrentIndex(5) + + def show_template_help(self): + template_help_win = TemplateHelpWindow(self) + template_help_win.setModal(False) + if not self.cbxNewRenamer.isChecked(): + template_help_win.textEdit.setHtml(old_template_tooltip) + template_help_win.show() + + +class TemplateHelpWindow(QtWidgets.QDialog): + def __init__(self, parent): + super(TemplateHelpWindow, self).__init__(parent) + + uic.loadUi(ComicTaggerSettings.get_ui_file("TemplateHelp.ui"), self) diff --git a/comictaggerlib/taggerwindow.py b/comictaggerlib/taggerwindow.py index 323e6f1..6cffdea 100644 --- a/comictaggerlib/taggerwindow.py +++ b/comictaggerlib/taggerwindow.py @@ -246,6 +246,15 @@ Have fun! """, ) self.settings.show_disclaimer = not checked + if not self.settings.hide_rename_message: + self.settings.hide_rename_message = OptionalMessageDialog.msg( + self, + "New rename template!", + "There is a new rename template format available. " + "Please use the settings window to enable and test if you use this feature.

" + "The old rename template format will be removed in the next release, " + "please reference the template help button in the settings or https://github.com/comictagger/comictagger/wiki/UserGuide#rename", + ) if self.settings.check_for_new_version: # self.checkLatestVersionOnline() diff --git a/comictaggerlib/ui/TemplateHelp.ui b/comictaggerlib/ui/TemplateHelp.ui new file mode 100644 index 0000000..0a692f1 --- /dev/null +++ b/comictaggerlib/ui/TemplateHelp.ui @@ -0,0 +1,110 @@ + + + Dialog + + + + 0 + 0 + 702 + 452 + + + + Template Help + + + true + + + + 0 + + + 2 + + + 2 + + + + + true + + + <html> + <head/> + <body> + <h1 style="text-align: center">Template help</h1> + <p>The template uses Python format strings, in the simplest use it replaces the field (e.g. {issue}) with the value for that particular comic (e.g. 1) for advanced formatting please reference the + + <a href="https://docs.python.org/3/library/string.html#format-string-syntax">Python 3 documentation</a></p> + <pre>Accepts the following variables: +{isEmpty} (boolean) +{tagOrigin} (string) +{series} (string) +{issue} (string) +{title} (string) +{publisher} (string) +{month} (integer) +{year} (integer) +{day} (integer) +{issueCount} (integer) +{volume} (integer) +{genre} (string) +{language} (string) +{comments} (string) +{volumeCount} (integer) +{criticalRating} (string) +{country} (string) +{alternateSeries} (string) +{alternateNumber} (string) +{alternateCount} (integer) +{imprint} (string) +{notes} (string) +{webLink} (string) +{format} (string) +{manga} (string) +{blackAndWhite} (boolean) +{pageCount} (integer) +{maturityRating} (string) +{storyArc} (string) +{seriesGroup} (string) +{scanInfo} (string) +{characters} (string) +{teams} (string) +{locations} (string) +{credits} (list of dict({'role': 'str', 'person': 'str', 'primary': boolean})) +{tags} (list of str) +{pages} (list of dict({'Image': 'str(int)', 'Type': 'str'})) + +CoMet-only items: +{price} (float) +{isVersionOf} (string) +{rights} (string) +{identifier} (string) +{lastMark} (string) +{coverImage} (string) + +Examples: + +{series} {issue} ({year}) +Spider-Geddon 1 (2018) + +{series} #{issue} - {title} +Spider-Geddon #1 - New Players; Check In + +</pre> + </body> +</html> + + + true + + + + + + + + diff --git a/comictaggerlib/ui/settingswindow.ui b/comictaggerlib/ui/settingswindow.ui index c64e7ce..bfc112d 100644 --- a/comictaggerlib/ui/settingswindow.ui +++ b/comictaggerlib/ui/settingswindow.ui @@ -28,7 +28,7 @@ - 1 + 0 @@ -547,7 +547,7 @@ QFormLayout::AllNonFixedFieldsGrow - + Template: @@ -574,13 +574,33 @@ - + + + Template Help + + + + + + + + + + Qt::PlainText + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse + + + + + Issue # Zero Padding - + @@ -599,7 +619,7 @@ - + <html><head/><body><p><span style=" font-weight:600;">&quot;Smart Text Cleanup&quot; </span>will attempt to clean up the new filename if there are missing fields from the template. For example, removing empty braces, repeated spaces and dashes, and more. Experimental feature.</p></body></html> @@ -609,13 +629,51 @@ - + Change Extension Based On Archive Type + + + + Enable the new renamer + + + + + + + If checked moves files to specified folder + + + Move files when renaming + + + + + + + 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. + + + Strict renaming + + + diff --git a/requirements.txt b/requirements.txt index e929a15..c6e833d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,5 +2,6 @@ beautifulsoup4 >= 4.1 natsort>=8.1.0 pillow>=4.3.0 requests==2.* +pathvalidate pycountry py7zr diff --git a/tests/filenames.py b/tests/filenames.py index e521d5c..a29c554 100644 --- a/tests/filenames.py +++ b/tests/filenames.py @@ -537,3 +537,59 @@ fnames = [ marks=pytest.mark.xfail, ), ] + +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 new file mode 100644 index 0000000..9f01aef --- /dev/null +++ b/tests/test_rename.py @@ -0,0 +1,24 @@ +import re + +import pytest +from filenames import rnames + +from comicapi.genericmetadata import md_test +from comictaggerlib.filerenamer import FileRenamer, FileRenamer2 + + +@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") == expected + + +@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") == expected