From 51132c061bdacc79e1570f20fc5f3813804434f9 Mon Sep 17 00:00:00 2001 From: lordwelch Date: Thu, 5 Sep 2019 14:40:14 -0700 Subject: [PATCH 1/2] Cleanup metadata handling Mainly corrects for consistency in most situations CoMet is not touched as there is no support in the gui and has an odd requirements on attributes --- comicapi/comicbookinfo.py | 86 +++++++++++-------------- comicapi/comicinfoxml.py | 85 ++++++++++++------------- comicapi/utils.py | 17 +++++ comictaggerlib/comicvinetalker.py | 20 +++--- comictaggerlib/taggerwindow.py | 101 +++++++++++++++--------------- 5 files changed, 152 insertions(+), 157 deletions(-) diff --git a/comicapi/comicbookinfo.py b/comicapi/comicbookinfo.py index cb2d9e2..e55fac9 100644 --- a/comicapi/comicbookinfo.py +++ b/comicapi/comicbookinfo.py @@ -24,39 +24,33 @@ from . import utils class ComicBookInfo: - def metadataFromString(self, string): - + class Default(dict): + def __missing__(self, key): + return None cbi_container = json.loads(str(string, 'utf-8')) metadata = GenericMetadata() - cbi = cbi_container['ComicBookInfo/1.0'] + cbi = Default(cbi_container['ComicBookInfo/1.0']) - # helper func - # If item is not in CBI, return None - def xlate(cbi_entry): - if cbi_entry in cbi: - return cbi[cbi_entry] - else: - return None + metadata.series = utils.xlate(cbi['series']) + metadata.title = utils.xlate(cbi['title']) + metadata.issue = utils.xlate(cbi['issue']) + metadata.publisher = utils.xlate(cbi['publisher']) + metadata.month = utils.xlate(cbi['publicationMonth'], True) + metadata.year = utils.xlate(cbi['publicationYear'], True) + metadata.issueCount = utils.xlate(cbi['numberOfIssues'], True) + metadata.comments = utils.xlate(cbi['comments']) + metadata.genre = utils.xlate(cbi['genre']) + metadata.volume = utils.xlate(cbi['volume'], True) + metadata.volumeCount = utils.xlate(cbi['numberOfVolumes'], True) + metadata.language = utils.xlate(cbi['language']) + metadata.country = utils.xlate(cbi['country']) + metadata.criticalRating = utils.xlate(cbi['rating']) - metadata.series = xlate('series') - metadata.title = xlate('title') - metadata.issue = xlate('issue') - metadata.publisher = xlate('publisher') - metadata.month = xlate('publicationMonth') - metadata.year = xlate('publicationYear') - metadata.issueCount = xlate('numberOfIssues') - metadata.comments = xlate('comments') - metadata.credits = xlate('credits') - metadata.genre = xlate('genre') - metadata.volume = xlate('volume') - metadata.volumeCount = xlate('numberOfVolumes') - metadata.language = xlate('language') - metadata.country = xlate('country') - metadata.criticalRating = xlate('rating') - metadata.tags = xlate('tags') + metadata.credits = cbi['credits'] + metadata.tags = cbi['tags'] # make sure credits and tags are at least empty lists and not None if metadata.credits is None: @@ -103,33 +97,23 @@ class ComicBookInfo: # helper func def assign(cbi_entry, md_entry): - if md_entry is not None: + if md_entry is not None or isinstance(md_entry, str) and md_entry != "": cbi[cbi_entry] = md_entry - # helper func - def toInt(s): - i = None - if type(s) in [str, str, int]: - try: - i = int(s) - except ValueError: - pass - return i - - assign('series', metadata.series) - assign('title', metadata.title) - assign('issue', metadata.issue) - assign('publisher', metadata.publisher) - assign('publicationMonth', toInt(metadata.month)) - assign('publicationYear', toInt(metadata.year)) - assign('numberOfIssues', toInt(metadata.issueCount)) - assign('comments', metadata.comments) - assign('genre', metadata.genre) - assign('volume', toInt(metadata.volume)) - assign('numberOfVolumes', toInt(metadata.volumeCount)) - assign('language', utils.getLanguageFromISO(metadata.language)) - assign('country', metadata.country) - assign('rating', metadata.criticalRating) + assign('series', utils.xlate(metadata.series)) + assign('title', utils.xlate(metadata.title)) + assign('issue', utils.xlate(metadata.issue)) + assign('publisher', utils.xlate(metadata.publisher)) + assign('publicationMonth', utils.xlate(metadata.month, True)) + assign('publicationYear', utils.xlate(metadata.year, True)) + assign('numberOfIssues', utils.xlate(metadata.issueCount, True)) + assign('comments', utils.xlate(metadata.comments)) + assign('genre', utils.xlate(metadata.genre)) + assign('volume', utils.xlate(metadata.volume, True)) + assign('numberOfVolumes', utils.xlate(metadata.volumeCount, True)) + assign('language', utils.xlate(utils.getLanguageFromISO(metadata.language))) + assign('country', utils.xlate(metadata.country)) + assign('rating', utils.xlate(metadata.criticalRating)) assign('credits', metadata.credits) assign('tags', metadata.tags) diff --git a/comicapi/comicinfoxml.py b/comicapi/comicinfoxml.py index 757fa46..5c38902 100644 --- a/comicapi/comicinfoxml.py +++ b/comicapi/comicinfoxml.py @@ -20,6 +20,7 @@ import xml.etree.ElementTree as ET #import zipfile from .genericmetadata import GenericMetadata +from .issuestring import IssueString from . import utils @@ -206,48 +207,44 @@ class ComicInfoXml: raise 1 return None - metadata = GenericMetadata() - md = metadata - - # Helper function - def xlate(tag): - node = root.find(tag) - if node is not None: - return node.text - else: + def get(name): + tag = root.find(name) + if tag is None: return None + return tag.text - md.series = xlate('Series') - md.title = xlate('Title') - md.issue = xlate('Number') - md.issueCount = xlate('Count') - md.volume = xlate('Volume') - md.alternateSeries = xlate('AlternateSeries') - md.alternateNumber = xlate('AlternateNumber') - md.alternateCount = xlate('AlternateCount') - md.comments = xlate('Summary') - md.notes = xlate('Notes') - md.year = xlate('Year') - md.month = xlate('Month') - md.day = xlate('Day') - md.publisher = xlate('Publisher') - md.imprint = xlate('Imprint') - md.genre = xlate('Genre') - md.webLink = xlate('Web') - md.language = xlate('LanguageISO') - md.format = xlate('Format') - md.manga = xlate('Manga') - md.characters = xlate('Characters') - md.teams = xlate('Teams') - md.locations = xlate('Locations') - md.pageCount = xlate('PageCount') - md.scanInfo = xlate('ScanInformation') - md.storyArc = xlate('StoryArc') - md.seriesGroup = xlate('SeriesGroup') - md.maturityRating = xlate('AgeRating') + md = GenericMetadata() - tmp = xlate('BlackAndWhite') - md.blackAndWhite = False + md.series = utils.xlate(get('Series')) + md.title = utils.xlate(get('Title')) + md.issue = IssueString(utils.xlate(get('Number'))).asString() + md.issueCount = utils.xlate(get('Count'), True) + md.volume = utils.xlate(get('Volume'), True) + md.alternateSeries = utils.xlate(get('AlternateSeries')) + md.alternateNumber = IssueString(utils.xlate(get('AlternateNumber'))).asString() + md.alternateCount = utils.xlate(get('AlternateCount'), True) + md.comments = utils.xlate(get('Summary')) + md.notes = utils.xlate(get('Notes')) + md.year = utils.xlate(get('Year'), True) + md.month = utils.xlate(get('Month'), True) + md.day = utils.xlate(get('Day'), True) + md.publisher = utils.xlate(get('Publisher')) + md.imprint = utils.xlate(get('Imprint')) + md.genre = utils.xlate(get('Genre')) + md.webLink = utils.xlate(get('Web')) + md.language = utils.xlate(get('LanguageISO')) + md.format = utils.xlate(get('Format')) + md.manga = utils.xlate(get('Manga')) + md.characters = utils.xlate(get('Characters')) + md.teams = utils.xlate(get('Teams')) + md.locations = utils.xlate(get('Locations')) + md.pageCount = utils.xlate(get('PageCount'), True) + md.scanInfo = utils.xlate(get('ScanInformation')) + md.storyArc = utils.xlate(get('StoryArc')) + md.seriesGroup = utils.xlate(get('SeriesGroup')) + md.maturityRating = utils.xlate(get('AgeRating')) + + tmp = utils.xlate(get('BlackAndWhite')) if tmp is not None and tmp.lower() in ["yes", "true", "1"]: md.blackAndWhite = True # Now extract the credit info @@ -261,23 +258,23 @@ class ComicInfoXml: ): if n.text is not None: for name in n.text.split(','): - metadata.addCredit(name.strip(), n.tag) + md.addCredit(name.strip(), n.tag) if n.tag == 'CoverArtist': if n.text is not None: for name in n.text.split(','): - metadata.addCredit(name.strip(), "Cover") + md.addCredit(name.strip(), "Cover") # parse page data now pages_node = root.find("Pages") if pages_node is not None: for page in pages_node: - metadata.pages.append(page.attrib) + md.pages.append(page.attrib) # print page.attrib - metadata.isEmpty = False + md.isEmpty = False - return metadata + return md def writeToExternalFile(self, filename, metadata): diff --git a/comicapi/utils.py b/comicapi/utils.py index 1303689..aeec166 100644 --- a/comicapi/utils.py +++ b/comicapi/utils.py @@ -121,6 +121,23 @@ def which(program): return None +def xlate(data, isInt=False): + class Default(dict): + def __missing__(self, key): + return None + if data is None or data == "": + return None + if isInt: + i = str(data).translate(Default(zip((ord(c) for c in "1234567890"),"1234567890"))) + if i == "0": + return "0" + if i is "": + return None + return int(i) + else: + return str(data) + + def removearticles(text): text = text.lower() articles = ['and', 'a', '&', 'issue', 'the'] diff --git a/comictaggerlib/comicvinetalker.py b/comictaggerlib/comicvinetalker.py index cc0d2bc..66f8468 100644 --- a/comictaggerlib/comicvinetalker.py +++ b/comictaggerlib/comicvinetalker.py @@ -124,11 +124,11 @@ class ComicVineTalker(QObject): year = None if date_str is not None: parts = date_str.split('-') - year = parts[0] + year = utils.xlate(parts[0], True) if len(parts) > 1: - month = parts[1] + month = utils.xlate(parts[1], True) if len(parts) > 2: - day = parts[2] + day = utils.xlate(parts[2], True) return day, month, year def testKey(self, key): @@ -497,15 +497,13 @@ class ComicVineTalker(QObject): # Now, map the Comic Vine data to generic metadata metadata = GenericMetadata() - metadata.series = issue_results['volume']['name'] + metadata.series = utils.xlate(issue_results['volume']['name']) + metadata.issue = IssueString(issue_results['issue_number']).asString() + metadata.title = utils.xlate(issue_results['name']) - num_s = IssueString(issue_results['issue_number']).asString() - metadata.issue = num_s - metadata.title = issue_results['name'] - - metadata.publisher = volume_results['publisher']['name'] - metadata.day, metadata.month, metadata.year = self.parseDateStr( - issue_results['cover_date']) + if volume_results['publisher'] is not None: + metadata.publisher = utils.xlate(volume_results['publisher']['name']) + metadata.day, metadata.month, metadata.year = self.parseDateStr(issue_results['cover_date']) #metadata.issueCount = volume_results['count_of_issues'] metadata.comments = self.cleanup_html( diff --git a/comictaggerlib/taggerwindow.py b/comictaggerlib/taggerwindow.py index 5b2bfab..35c9ae5 100644 --- a/comictaggerlib/taggerwindow.py +++ b/comictaggerlib/taggerwindow.py @@ -51,6 +51,7 @@ from .cbltransformer import CBLTransformer from .renamewindow import RenameWindow from .exportwindow import ExportWindow, ExportConflictOpts from .issueidentifier import IssueIdentifier +from .issuestring import IssueString from .autotagstartwindow import AutoTagStartWindow from .autotagprogresswindow import AutoTagProgressWindow from .autotagmatchwindow import AutoTagMatchWindow @@ -788,14 +789,12 @@ class TaggerWindow(QtWidgets.QMainWindow): for child in widget.children(): self.clearChildren(child) + # Copy all of the metadata object into to the form. + # Merging of metadata should be done via the overlay function def metadataToForm(self): - # copy the the metadata object into to the form - - # helper func def assignText(field, value): if value is not None: field.setText(str(value)) - md = self.metadata assignText(self.leSeries, md.series) @@ -837,23 +836,33 @@ class TaggerWindow(QtWidgets.QMainWindow): self.cbMaturityRating.setEditText(md.maturityRating) else: self.cbMaturityRating.setCurrentIndex(i) + else: + self.cbMaturityRating.setCurrentIndex(0) if md.language is not None: i = self.cbLanguage.findData(md.language) self.cbLanguage.setCurrentIndex(i) + else: + self.cbLanguage.setCurrentIndex(0) if md.country is not None: i = self.cbCountry.findText(md.country) self.cbCountry.setCurrentIndex(i) + else: + self.cbCountry.setCurrentIndex(0) if md.manga is not None: i = self.cbManga.findData(md.manga) self.cbManga.setCurrentIndex(i) + else: + self.cbManga.setCurrentIndex(0) - if md.blackAndWhite is not None and md.blackAndWhite: + if md.blackAndWhite != None and md.blackAndWhite: self.cbBW.setChecked(True) + else: + self.cbBW.setChecked(False) - assignText(self.teTags, utils.listToString(md.tags)) + self.teTags.setText(utils.listToString(md.tags)) # !!! Should we clear the credits table or just avoid duplicates? while self.twCredits.rowCount() > 0: @@ -912,58 +921,47 @@ class TaggerWindow(QtWidgets.QMainWindow): return False def formToMetadata(self): - - # helper func - def xlate(data, type_str): - s = "{0}".format(data).strip() - if s == "": - return None - elif type_str == "str": - return s - else: - return int(s) - # copy the data from the form into the metadata - md = self.metadata - md.series = xlate(self.leSeries.text(), "str") - md.issue = xlate(self.leIssueNum.text(), "str") - md.issueCount = xlate(self.leIssueCount.text(), "int") - md.volume = xlate(self.leVolumeNum.text(), "int") - md.volumeCount = xlate(self.leVolumeCount.text(), "int") - md.title = xlate(self.leTitle.text(), "str") - md.publisher = xlate(self.lePublisher.text(), "str") - md.month = xlate(self.lePubMonth.text(), "int") - md.year = xlate(self.lePubYear.text(), "int") - md.day = xlate(self.lePubDay.text(), "int") - md.genre = xlate(self.leGenre.text(), "str") - md.imprint = xlate(self.leImprint.text(), "str") - md.comments = xlate(self.teComments.toPlainText(), "str") - md.notes = xlate(self.teNotes.toPlainText(), "str") - md.criticalRating = xlate(self.leCriticalRating.text(), "int") - md.maturityRating = xlate(self.cbMaturityRating.currentText(), "str") + md = GenericMetadata() + md.isEmpty = False + md.alternateNumber = IssueString(self.leAltIssueNum.text()).asString() + md.issue = IssueString(self.leIssueNum.text()).asString() + md.issueCount = utils.xlate(self.leIssueCount.text(), True) + md.volume = utils.xlate(self.leVolumeNum.text(), True) + md.volumeCount = utils.xlate(self.leVolumeCount.text(), True) + md.month = utils.xlate(self.lePubMonth.text(), True) + md.year = utils.xlate(self.lePubYear.text(), True) + md.day = utils.xlate(self.lePubDay.text(), True) + md.criticalRating = utils.xlate(self.leCriticalRating.text(), True) + md.alternateCount = utils.xlate(self.leAltIssueCount.text(), True) - md.storyArc = xlate(self.leStoryArc.text(), "str") - md.scanInfo = xlate(self.leScanInfo.text(), "str") - md.seriesGroup = xlate(self.leSeriesGroup.text(), "str") - md.alternateSeries = xlate(self.leAltSeries.text(), "str") - md.alternateNumber = xlate(self.leAltIssueNum.text(), "int") - md.alternateCount = xlate(self.leAltIssueCount.text(), "int") - md.webLink = xlate(self.leWebLink.text(), "str") - md.characters = xlate(self.teCharacters.toPlainText(), "str") - md.teams = xlate(self.teTeams.toPlainText(), "str") - md.locations = xlate(self.teLocations.toPlainText(), "str") + md.series = self.leSeries.text() + md.title = self.leTitle.text() + md.publisher = self.lePublisher.text() + md.genre = self.leGenre.text() + md.imprint = self.leImprint.text() + md.comments = self.teComments.toPlainText() + md.notes = self.teNotes.toPlainText() + md.maturityRating = self.cbMaturityRating.currentText() - md.format = xlate(self.cbFormat.currentText(), "str") - md.country = xlate(self.cbCountry.currentText(), "str") + md.storyArc = self.leStoryArc.text() + md.scanInfo = self.leScanInfo.text() + md.seriesGroup = self.leSeriesGroup.text() + md.alternateSeries = self.leAltSeries.text() + md.webLink = self.leWebLink.text() + md.characters = self.teCharacters.toPlainText() + md.teams = self.teTeams.toPlainText() + md.locations = self.teLocations.toPlainText() - langiso = self.cbLanguage.itemData(self.cbLanguage.currentIndex()) - md.language = xlate(langiso, "str") + md.format = self.cbFormat.currentText() + md.country = self.cbCountry.currentText() - manga_code = self.cbManga.itemData(self.cbManga.currentIndex()) - md.manga = xlate(manga_code, "str") + md.language = utils.xlate(self.cbLanguage.itemData(self.cbLanguage.currentIndex())) + + md.manga = utils.xlate(self.cbManga.itemData(self.cbManga.currentIndex())) # Make a list from the coma delimited tags string - tmp = xlate(self.teTags.toPlainText(), "str") + tmp = self.teTags.toPlainText() if tmp is not None: def striplist(l): return([x.strip() for x in l]) @@ -987,6 +985,7 @@ class TaggerWindow(QtWidgets.QMainWindow): row += 1 md.pages = self.pageListEditor.getPageList() + self.metadata = md def useFilename(self): if self.comic_archive is not None: From 0747a6b0ef6805010e40268f2e4b88949ae5f465 Mon Sep 17 00:00:00 2001 From: lordwelch Date: Tue, 3 Sep 2019 00:36:36 -0700 Subject: [PATCH 2/2] Improve file renaming Moves to Python format strings for renaming, handles directory structures, moving of files to a destination directory, sanitizes file paths with pathvalidate and takes a different approach to smart filename cleanup using the Python string.Formatter class Moving to Python format strings means we can point to python documentation for syntax and all we have to do is document the properties and types that are attached to the GenericMetadata class. Switching to pathvalidate allows comictagger to more simply handle both directories and symbols in filenames. The only changes to the string.Formatter class is: 1. format_field returns an empty string if the value is none or an empty string regardless of the format specifier. 2. _vformat drops the previous literal text if the field value is an empty string and lstrips the following literal text of closing special characters. --- comictaggerlib/cli.py | 13 +- comictaggerlib/filerenamer.py | 192 ++++++++++++++-------------- comictaggerlib/renamewindow.py | 14 +- comictaggerlib/settings.py | 30 +++-- comictaggerlib/settingswindow.py | 39 ++++-- comictaggerlib/taggerwindow.py | 13 +- comictaggerlib/ui/TemplateHelp.ui | 105 +++++++++++++++ comictaggerlib/ui/settingswindow.ui | 106 ++++++++++++--- requirements.txt | 3 +- 9 files changed, 363 insertions(+), 152 deletions(-) create mode 100644 comictaggerlib/ui/TemplateHelp.ui diff --git a/comictaggerlib/cli.py b/comictaggerlib/cli.py index 688907d..91e10fc 100644 --- a/comictaggerlib/cli.py +++ b/comictaggerlib/cli.py @@ -507,19 +507,24 @@ 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): + 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 - folder = os.path.dirname(os.path.abspath(filename)) - new_abs_path = utils.unique_file(os.path.join(folder, new_name)) - 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..abe856d 100644 --- a/comictaggerlib/renamewindow.py +++ b/comictaggerlib/renamewindow.py @@ -76,6 +76,7 @@ class RenameWindow(QtWidgets.QDialog): if md.isEmpty: md = ca.metadataFromFilename(self.settings.parse_scan_info) self.renamer.setMetadata(md) + self.renamer.move = self.settings.rename_move_dir new_name = self.renamer.determineName(ca.path, ext=new_ext) row = self.twList.rowCount() @@ -149,17 +150,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 3879445..a82b6c3 100644 --- a/comictaggerlib/settings.py +++ b/comictaggerlib/settings.py @@ -44,7 +44,7 @@ class ComicTaggerSettings: @staticmethod def haveOwnUnrarLib(): return os.path.exists(ComicTaggerSettings.defaultLibunrarPath()) - + @staticmethod def baseDir(): if getattr(sys, 'frozen', None): @@ -118,10 +118,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 @@ -166,15 +168,15 @@ class ComicTaggerSettings: if self.rar_exe_path != "": self.save() if self.rar_exe_path != "": - # make sure rar program is now in the path for the rar class + # make sure rar program is now in the path for the rar class utils.addtopath(os.path.dirname(self.rar_exe_path)) - + if self.haveOwnUnrarLib(): # We have a 'personal' copy of the unrar lib in the basedir, so # don't search and change the setting # NOTE: a manual edit of the settings file overrides this below os.environ["UNRAR_LIB_PATH"] = self.defaultLibunrarPath() - + elif self.unrar_lib_path == "": # Priority is for unrar lib search is: # 1. explicit setting in settings file @@ -186,11 +188,11 @@ class ComicTaggerSettings: # look in some platform specific places: if platform.system() == "Windows": # Default location for the RARLab DLL installer - if (platform.architecture()[0] == '64bit' and + if (platform.architecture()[0] == '64bit' and os.path.exists("C:\\Program Files (x86)\\UnrarDLL\\x64\\UnRAR64.dll") ): self.unrar_lib_path = "C:\\Program Files (x86)\\UnrarDLL\\x64\\UnRAR64.dll" - elif (platform.architecture()[0] == '32bit' and + elif (platform.architecture()[0] == '32bit' and os.path.exists("C:\\Program Files\\UnrarDLL\\UnRAR.dll") ): self.unrar_lib_path = "C:\\Program Files\\UnrarDLL\\UnRAR.dll" @@ -202,14 +204,14 @@ class ComicTaggerSettings: if os.path.exists("/usr/local/lib/libunrar.so"): self.unrar_lib_path = "/usr/local/lib/libunrar.so" elif os.path.exists("/usr/lib/libunrar.so"): - self.unrar_lib_path = "/usr/lib/libunrar.so" - + self.unrar_lib_path = "/usr/lib/libunrar.so" + if self.unrar_lib_path != "": self.save() - + if self.unrar_lib_path != "": # This needs to occur before the unrar module is loaded for the first time - os.environ["UNRAR_LIB_PATH"] = self.unrar_lib_path + os.environ["UNRAR_LIB_PATH"] = self.unrar_lib_path def reset(self): os.unlink(self.settings_file) @@ -358,6 +360,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( @@ -522,6 +528,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 857b885..28978b9 100644 --- a/comictaggerlib/settingswindow.py +++ b/comictaggerlib/settingswindow.py @@ -44,13 +44,13 @@ linuxRarHelp = """ here, and install in your path.

""" - + macRarHelp = """

To write to CBR/RAR archives, you will need the rar tool. The easiest way to get this is to install homebrew. -

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

Once homebrew is installed, run: brew install caskroom/cask/rar """ windowsUnrarHelp = """ @@ -70,7 +70,7 @@ linuxUnrarHelp = """ here for the UnRAR source (which is easy to compile on Linux).

""" - + macUnrarHelp = """

To read CBR/RAR archives, you will need the unrar library. The easiest way to get this is @@ -92,13 +92,13 @@ class SettingsWindow(QtWidgets.QDialog): self.settings = settings self.name = "Settings" - + self.priorUnrarLibPath = self.settings.unrar_lib_path if self.settings.haveOwnUnrarLib(): # We have our own unrarlib, so no need for this GUI self.grpBoxUnrar.hide() - + if platform.system() == "Windows": self.lblRarHelp.setText(windowsRarHelp) self.lblUnrarHelp.setText(windowsUnrarHelp) @@ -111,7 +111,7 @@ class SettingsWindow(QtWidgets.QDialog): # Mac file dialog hides "/usr" and others, so allow user to type self.leUnrarLibPath.setReadOnly(False) self.leRarExePath.setReadOnly(False) - + self.lblRarHelp.setText(macRarHelp) self.lblUnrarHelp.setText(macUnrarHelp) self.name = "Preferences" @@ -152,6 +152,7 @@ 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 settingsToForm(self): @@ -205,12 +206,15 @@ 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): # Copy values from form to settings and save self.settings.rar_exe_path = str(self.leRarExePath.text()) - + # Don't accept the form info if we have our own unrar lib if not self.settings.haveOwnUnrarLib(): self.settings.unrar_lib_path = str(self.leUnrarLibPath.text()) @@ -222,7 +226,7 @@ class SettingsWindow(QtWidgets.QDialog): if self.settings.unrar_lib_path: os.environ["UNRAR_LIB_PATH"] = self.settings.unrar_lib_path # This doesn't do anything... we need to restart! - + if not str(self.leNameLengthDeltaThresh.text()).isdigit(): self.leNameLengthDeltaThresh.setText("0") @@ -258,6 +262,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) @@ -266,7 +272,7 @@ class SettingsWindow(QtWidgets.QDialog): QtWidgets.QMessageBox.information( self, "UnRar Library Change", "ComicTagger will need to be restarted for changes to take effect.") - + def selectRar(self): self.selectFile(self.leRarExePath, "RAR") @@ -317,10 +323,23 @@ class SettingsWindow(QtWidgets.QDialog): dialog.setWindowTitle("Find " + name + " program") else: dialog.setWindowTitle("Find " + name + " library") - + if (dialog.exec_()): fileList = dialog.selectedFiles() control.setText(str(fileList[0])) def showRenameTab(self): self.tabWidget.setCurrentIndex(5) + + def showTemplateHelp(self): + TemplateHelpWin = TemplateHelpWindow(self) + TemplateHelpWin.exec_() + +class TemplateHelpWindow(QtWidgets.QDialog): + + def __init__(self, parent): + super(TemplateHelpWindow, self).__init__(parent) + + uic.loadUi(ComicTaggerSettings.getUIFile('TemplateHelp.ui'), self) + + diff --git a/comictaggerlib/taggerwindow.py b/comictaggerlib/taggerwindow.py index 35c9ae5..9d799dd 100644 --- a/comictaggerlib/taggerwindow.py +++ b/comictaggerlib/taggerwindow.py @@ -25,6 +25,7 @@ import json import webbrowser import re import pickle +import datetime #import signal from PyQt5 import QtCore, QtGui, QtWidgets, uic @@ -87,7 +88,7 @@ class TaggerWindow(QtWidgets.QMainWindow): def __init__(self, file_list, settings, parent=None, opts=None): super(TaggerWindow, self).__init__(parent) - + uic.loadUi(ComicTaggerSettings.getUIFile('taggerwindow.ui'), self) self.settings = settings @@ -184,7 +185,7 @@ class TaggerWindow(QtWidgets.QMainWindow): self.resetApp() # set up some basic field validators - validator = QtGui.QIntValidator(1900, 2099, self) + validator = QtGui.QIntValidator(1900, datetime.date.today().year + 15, self) self.lePubYear.setValidator(validator) validator = QtGui.QIntValidator(1, 12, self) @@ -294,7 +295,7 @@ class TaggerWindow(QtWidgets.QMainWindow): self.setWindowIcon( QtGui.QIcon(ComicTaggerSettings.getGraphic('app.png'))) - + if self.comic_archive is None: self.setWindowTitle(self.appName) else: @@ -610,7 +611,7 @@ class TaggerWindow(QtWidgets.QMainWindow): ) local = QUrl(str(absCFURL[0])).toLocalFile() - + return local def dropEvent(self, event): @@ -1195,11 +1196,11 @@ class TaggerWindow(QtWidgets.QMainWindow): def updateCreditColors(self): #!!!ATB qt5 porting TODO - #return + #return inactive_color = QtGui.QColor(255, 170, 150) active_palette = self.leSeries.palette() active_color = active_palette.color(QtGui.QPalette.Base) - + inactive_brush = QtGui.QBrush(inactive_color) active_brush = QtGui.QBrush(active_color) diff --git a/comictaggerlib/ui/TemplateHelp.ui b/comictaggerlib/ui/TemplateHelp.ui new file mode 100644 index 0000000..4540fc1 --- /dev/null +++ b/comictaggerlib/ui/TemplateHelp.ui @@ -0,0 +1,105 @@ + + + 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) +{alternateinteger} (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} (integer) +{isVersionOf} (boolean) +{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 d7f1457..9b97e76 100644 --- a/comictaggerlib/ui/settingswindow.ui +++ b/comictaggerlib/ui/settingswindow.ui @@ -503,7 +503,7 @@ QFormLayout::AllNonFixedFieldsGrow - + Template: @@ -512,31 +512,79 @@ - <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} (number) +{year} (number) +{day} (number) +{date} (Date) +{issueCount} (number) +{volume} (number) +{genre} (string) +{language} (string) +{comments} (string) +{volumeCount} (number) +{criticalRating} (string) +{country} (string) +{alternateSeries} (string) +{alternateNumber} (string) +{alternateCount} (number) +{imprint} (string) +{notes} (string) +{webLink} (string) +{format} (string) +{manga} (string) +{blackAndWhite} (boolean) +{pageCount} (number) +{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} (number) +{isVersionOf} (boolean) +{rights} (string) +{identifier} (string) +{lastMark} (string) +{coverImage} (string) + +Examples: + +{series} {issue} ({year}) + +{series} #{issue} - {title} +</pre> - + + + Template Help + + + + + Issue # Zero Padding - + @@ -555,7 +603,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 +613,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 a20fb10..511943f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,5 @@ natsort==3.5.2 PyPDF2==1.24 pillow>=4.3.0 PyQt5>=5.10.1 -git+https://github.com/pyinstaller/pyinstaller@develop \ No newline at end of file +git+https://github.com/pyinstaller/pyinstaller@develop +pathvalidate