diff --git a/comictaggerlib/cli.py b/comictaggerlib/cli.py index 688907d..25acae1 100644 --- a/comictaggerlib/cli.py +++ b/comictaggerlib/cli.py @@ -507,19 +507,31 @@ def process_file_cli(filename, opts, settings, match_results): renamer.setTemplate(settings.rename_template) renamer.setIssueZeroPadding(settings.rename_issue_number_padding) renamer.setSmartCleanup(settings.rename_use_smart_string_cleanup) + renamer.move = settings.rename_move_dir - new_name = renamer.determineName(filename, ext=new_ext) - - if new_name == os.path.basename(filename): - print(msg_hdr + "Filename is already good!", file=sys.stderr) + try: + new_name = renamer.determineName(filename, 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", file=sys.stderr) return 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.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 0f18151..150c01b 100644 --- a/comictaggerlib/filerenamer.py +++ b/comictaggerlib/filerenamer.py @@ -17,19 +17,96 @@ import os import re import datetime +import sys +import string + +from pathvalidate import sanitize_filepath from . import utils from .issuestring import IssueString +class MetadataFormatter(string.Formatter): + def __init__(self, smart_cleanup=False): + super().__init__() + self.smart_cleanup = smart_cleanup + + 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: + result.append(literal_text.lstrip("-_)}]#")) + else: + result.append(literal_text) + lstrip = False + # if there's a field, output it + if field_name is not None: + # 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 + fmtObj = self.format_field(obj, format_spec) + if fmtObj == "" and len(result) > 0 and self.smart_cleanup: + lstrip = True + result.pop() + result.append(fmtObj) + + return ''.join(result), auto_arg_index + + class FileRenamer: def __init__(self, metadata): self.setMetadata(metadata) self.setTemplate( - "%series% v%volume% #%issue% (of %issuecount%) (%year%)") + "{publisher}/{series}/{series} v{volume} #{issue} (of {issueCount}) ({year})") self.smart_cleanup = True self.issue_zero_padding = 3 + self.move = False def setMetadata(self, metadata): self.metdata = metadata @@ -43,114 +120,37 @@ class FileRenamer: def setTemplate(self, template): self.template = template - def replaceToken(self, text, value, token): - # helper func - def isToken(word): - return (word[0] == "%" and word[-1:] == "%") - - if value is not None: - return text.replace(token, str(value)) - else: - if self.smart_cleanup: - # smart cleanup means we want to remove anything appended to token if it's empty - # (e.g "#%issue%" or "v%volume%") - # (TODO: This could fail if there is more than one token appended together, I guess) - text_list = text.split() - - # special case for issuecount, remove preceding non-token word, - # as in "...(of %issuecount%)..." - if token == '%issuecount%': - for idx, word in enumerate(text_list): - if token in word and not isToken(text_list[idx - 1]): - text_list[idx - 1] = "" - - text_list = [x for x in text_list if token not in x] - return " ".join(text_list) - else: - return text.replace(token, "") - def determineName(self, filename, ext=None): - + class Default(dict): + def __missing__(self, key): + return "{" + key + "}" md = self.metdata - new_name = self.template - preferred_encoding = utils.get_actual_preferred_encoding() - # print(u"{0}".format(md)) - new_name = self.replaceToken(new_name, md.series, '%series%') - new_name = self.replaceToken(new_name, md.volume, '%volume%') + # padding for issue + md.issue = IssueString(md.issue).asString(pad=self.issue_zero_padding) - if md.issue is not None: - issue_str = "{0}".format( - IssueString(md.issue).asString(pad=self.issue_zero_padding)) - else: - issue_str = None - new_name = self.replaceToken(new_name, issue_str, '%issue%') + template = self.template - new_name = self.replaceToken(new_name, md.issueCount, '%issuecount%') - new_name = self.replaceToken(new_name, md.year, '%year%') - new_name = self.replaceToken(new_name, md.publisher, '%publisher%') - new_name = self.replaceToken(new_name, md.title, '%title%') - new_name = self.replaceToken(new_name, md.month, '%month%') - month_name = None - if md.month is not None: - if (isinstance(md.month, str) and md.month.isdigit()) or isinstance( - md.month, int): - if int(md.month) in range(1, 13): - dt = datetime.datetime(1970, int(md.month), 1, 0, 0) - #month_name = dt.strftime("%B".encode(preferred_encoding)).decode(preferred_encoding) - month_name = dt.strftime("%B") - new_name = self.replaceToken(new_name, month_name, '%month_name%') + pathComponents = template.split(os.sep) + new_name = "" - new_name = self.replaceToken(new_name, md.genre, '%genre%') - new_name = self.replaceToken(new_name, md.language, '%language_code%') - new_name = self.replaceToken( - new_name, md.criticalRating, '%criticalrating%') - new_name = self.replaceToken( - new_name, md.alternateSeries, '%alternateseries%') - new_name = self.replaceToken( - new_name, md.alternateNumber, '%alternatenumber%') - new_name = self.replaceToken( - new_name, md.alternateCount, '%alternatecount%') - new_name = self.replaceToken(new_name, md.imprint, '%imprint%') - new_name = self.replaceToken(new_name, md.format, '%format%') - new_name = self.replaceToken( - new_name, md.maturityRating, '%maturityrating%') - new_name = self.replaceToken(new_name, md.storyArc, '%storyarc%') - new_name = self.replaceToken(new_name, md.seriesGroup, '%seriesgroup%') - new_name = self.replaceToken(new_name, md.scanInfo, '%scaninfo%') + fmt = MetadataFormatter(self.smart_cleanup) + for Component in pathComponents: + new_name = os.path.join(new_name, fmt.vformat(Component, args=[], kwargs=Default(vars(md))).replace("/", "-")) - if self.smart_cleanup: - - # remove empty braces,brackets, parentheses - new_name = re.sub("\(\s*[-:]*\s*\)", "", new_name) - new_name = re.sub("\[\s*[-:]*\s*\]", "", new_name) - new_name = re.sub("\{\s*[-:]*\s*\}", "", new_name) - - # remove duplicate spaces - new_name = " ".join(new_name.split()) - - # remove remove duplicate -, _, - new_name = re.sub("[-_]{2,}\s+", "-- ", new_name) - new_name = re.sub("(\s--)+", " --", new_name) - new_name = re.sub("(\s-)+", " -", new_name) - - # remove dash or double dash at end of line - new_name = re.sub("[-]{1,2}\s*$", "", new_name) - - # remove duplicate spaces (again!) - new_name = " ".join(new_name.split()) - - if ext is None: + if ext is None or ext == "": ext = os.path.splitext(filename)[1] new_name += ext # some tweaks to keep various filesystems happy - new_name = new_name.replace("/", "-") - new_name = new_name.replace(" :", " -") new_name = new_name.replace(": ", " - ") new_name = new_name.replace(":", "-") - new_name = new_name.replace("?", "") - return new_name + # remove padding + md.issue = IssueString(md.issue).asString() + if self.move: + return sanitize_filepath(new_name.strip()) + else: + return os.path.basename(sanitize_filepath(new_name.strip())) diff --git a/comictaggerlib/renamewindow.py b/comictaggerlib/renamewindow.py index 9d1e2e1..6ff51c1 100644 --- a/comictaggerlib/renamewindow.py +++ b/comictaggerlib/renamewindow.py @@ -76,7 +76,20 @@ class RenameWindow(QtWidgets.QDialog): if md.isEmpty: md = ca.metadataFromFilename(self.settings.parse_scan_info) self.renamer.setMetadata(md) - new_name = self.renamer.determineName(ca.path, ext=new_ext) + self.renamer.move = self.settings.rename_move_dir + + try: + new_name = self.renamer.determineName(ca.path, ext=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) @@ -149,17 +162,20 @@ class RenameWindow(QtWidgets.QDialog): centerWindowOnParent(progdialog) 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!") continue if not item['archive'].isWritable(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 8808788..3442527 100644 --- a/comictaggerlib/settings.py +++ b/comictaggerlib/settings.py @@ -108,10 +108,12 @@ class ComicTaggerSettings: self.apply_cbl_transform_on_bulk_operation = False # Rename settings - self.rename_template = "%series% #%issue% (%year%)" + self.rename_template = "{publisher}/{series}/{series} #{issue} - {title} ({year})" 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 # Auto-tag stickies self.save_on_low_confidence = False @@ -301,6 +303,10 @@ class ComicTaggerSettings: 'rename', 'rename_extension_based_on_archive'): 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('autotag', 'save_on_low_confidence'): self.save_on_low_confidence = self.config.getboolean( @@ -462,6 +468,8 @@ class ComicTaggerSettings: 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) if not self.config.has_section('autotag'): self.config.add_section('autotag') diff --git a/comictaggerlib/settingswindow.py b/comictaggerlib/settingswindow.py index 0149a12..79fb11d 100644 --- a/comictaggerlib/settingswindow.py +++ b/comictaggerlib/settingswindow.py @@ -24,6 +24,8 @@ from .settings import ComicTaggerSettings from .comicvinecacher import ComicVineCacher from .comicvinetalker import ComicVineTalker from .imagefetcher import ImageFetcher +from .filerenamer import FileRenamer +from .genericmetadata import GenericMetadata from . import utils @@ -113,6 +115,66 @@ class SettingsWindow(QtWidgets.QDialog): self.btnClearCache.clicked.connect(self.clearCache) self.btnResetSettings.clicked.connect(self.resetSettings) self.btnTestKey.clicked.connect(self.testAPIKey) + self.btnTemplateHelp.clicked.connect(self.showTemplateHelp) + + def configRenamer(self): + md = GenericMetadata() + md.isEmpty = False + md.tagOrigin = "testing" + + md.series = "series name" + md.issue = "1" + md.title = "issue title" + md.publisher = "publisher" + md.year = 1998 + md.month = 4 + md.day = 4 + md.issueCount = 1 + md.volume = 256 + md.genre = "test" + md.language = "en" # 2 letter iso code + md.comments = "This is definitly a comic." # use same way as Summary in CIX + + md.volumeCount = 4096 + md.criticalRating = "Worst Comic Ever" + md.country = "US" + + md.alternateSeries = "None" + md.alternateNumber = 4.4 + md.alternateCount = 4444 + md.imprint = 'imprint' + md.notes = "This doesn't actually exist" + md.webLink = "https://example.com/series name/1" + md.format = "Box Set" + md.manga = "Yes" + md.blackAndWhite = False + md.pageCount = 4 + md.maturityRating = "Everyone" + + md.storyArc = "story" + md.seriesGroup = "seriesGroup" + md.scanInfo = "(lordwelch)" + + md.characters = "character 1, character 2" + md.teams = "None" + md.locations = "Earth, 444 B.C." + + md.credits = [dict({'role': 'Everything', 'person': 'author', 'primary': True})] + md.tags = ["testing", "not real"] + md.pages = [dict({'Image': '0', 'Type': 'Front Cover'}), dict({'Image': '1', 'Type': 'Story'})] + + # Some CoMet-only items + md.price = 0.00 + md.isVersionOf = "SERIES #1" + md.rights = "None" + md.identifier = "LW4444-Comic" + md.lastMark = "0" + md.coverImage = "https://example.com/series name/1/cover" + + self.renamer = FileRenamer(md) + self.renamer.setTemplate(str(self.leRenameTemplate.text())) + self.renamer.setIssueZeroPadding(self.settings.rename_issue_number_padding) + self.renamer.setSmartCleanup(self.settings.rename_use_smart_string_cleanup) def settingsToForm(self): @@ -165,9 +227,27 @@ class SettingsWindow(QtWidgets.QDialog): self.cbxSmartCleanup.setCheckState(QtCore.Qt.Checked) if self.settings.rename_extension_based_on_archive: self.cbxChangeExtension.setCheckState(QtCore.Qt.Checked) + if self.settings.rename_move_dir: + self.cbxMoveFiles.setCheckState(QtCore.Qt.Checked) + self.leDirectory.setText(self.settings.rename_dir) def accept(self): + self.configRenamer() + + + try: + new_name = self.renamer.determineName('test.cbz') + 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 + # Copy values from form to settings and save self.settings.rar_exe_path = str(self.leRarExePath.text()) @@ -210,6 +290,8 @@ class SettingsWindow(QtWidgets.QDialog): 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.save() QtWidgets.QDialog.accept(self) @@ -268,3 +350,17 @@ class SettingsWindow(QtWidgets.QDialog): def showRenameTab(self): self.tabWidget.setCurrentIndex(5) + + def showTemplateHelp(self): + TemplateHelpWin = TemplateHelpWindow(self) + TemplateHelpWin.setModal(False) + TemplateHelpWin.show() + +class TemplateHelpWindow(QtWidgets.QDialog): + + def __init__(self, parent): + super(TemplateHelpWindow, self).__init__(parent) + + uic.loadUi(ComicTaggerSettings.getUIFile('TemplateHelp.ui'), self) + + diff --git a/comictaggerlib/ui/TemplateHelp.ui b/comictaggerlib/ui/TemplateHelp.ui new file mode 100644 index 0000000..2b581ed --- /dev/null +++ b/comictaggerlib/ui/TemplateHelp.ui @@ -0,0 +1,107 @@ + + + Dialog + + + + 0 + 0 + 702 + 452 + + + + Template Help + + + true + + + + 0 + + + 2 + + + 2 + + + + + <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 c64f5f5..82805c5 100644 --- a/comictaggerlib/ui/settingswindow.ui +++ b/comictaggerlib/ui/settingswindow.ui @@ -503,7 +503,7 @@ QFormLayout::AllNonFixedFieldsGrow - + Template: @@ -512,31 +512,80 @@ - <html><head/><body><p>The template for the new filename. Accepts the following variables:</p><p>%series%<br/>%issue%<br/>%volume%<br/>%issuecount%<br/>%year%<br/>%month%<br/>%month_name%<br/>%publisher%<br/>%title%<br/> -%genre%<br/> -%language_code%<br/> -%criticalrating%<br/> -%alternateseries%<br/> -%alternatenumber%<br/> -%alternatecount%<br/> -%imprint%<br/> -%format%<br/> -%maturityrating%<br/> -%storyarc%<br/> -%seriesgroup%<br/> -%scaninfo% -</p><p>Examples:</p><p><span style=" font-style:italic;">%series% %issue% (%year%)</span><br/><span style=" font-style:italic;">%series% #%issue% - %title%</span></p></body></html> + <pre>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({&apos;role&apos;: &apos;str&apos;, &apos;person&apos;: &apos;str&apos;, &apos;primary&apos;: boolean})) +{tags} (list of str) +{pages} (list of dict({&apos;Image&apos;: &apos;str(int)&apos;, &apos;Type&apos;: &apos;str&apos;})) + +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> - + + + Template Help + + + + + Issue # Zero Padding - + @@ -555,7 +604,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> @@ -565,13 +614,33 @@ - + Change Extension Based On Archive Type + + + + If checked moves files to specified folder + + + Move files when renaming + + + + + + + Destination Directory: + + + + + + diff --git a/requirements.txt b/requirements.txt index 792c868..e5a7087 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ configparser natsort pillow>=4.3.0 requests +pathvalidate