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 = """