From ed1df400d8ce0c37f0b62906c0e79cdc262c9e21 Mon Sep 17 00:00:00 2001 From: Timmy Welch Date: Wed, 23 Nov 2022 22:16:46 -0800 Subject: [PATCH] Add replacement settings --- comictaggerlib/cli.py | 6 +- comictaggerlib/defaults.py | 29 ++++++ comictaggerlib/filerenamer.py | 40 +++------ comictaggerlib/renamewindow.py | 3 +- comictaggerlib/settings/file.py | 83 +++-------------- comictaggerlib/settings/types.py | 68 +++++++++++++- comictaggerlib/settingswindow.py | 93 +++++++++++++++++-- comictaggerlib/ui/settingswindow.ui | 133 +++++++++++++++++++++++++++- 8 files changed, 340 insertions(+), 115 deletions(-) create mode 100644 comictaggerlib/defaults.py diff --git a/comictaggerlib/cli.py b/comictaggerlib/cli.py index f2424fe..c9bbce9 100644 --- a/comictaggerlib/cli.py +++ b/comictaggerlib/cli.py @@ -502,7 +502,11 @@ def process_file_cli( elif ca.is_rar(): new_ext = ".cbr" - renamer = FileRenamer(md, platform="universal" if options[filename]["rename_strict"] else "auto") + renamer = FileRenamer( + md, + platform="universal" if options[filename]["rename_strict"] else "auto", + replacements=options["rename"]["replacements"], + ) renamer.set_template(options[filename]["rename_template"]) renamer.set_issue_zero_padding(options[filename]["rename_issue_number_padding"]) renamer.set_smart_cleanup(options[filename]["rename_use_smart_string_cleanup"]) diff --git a/comictaggerlib/defaults.py b/comictaggerlib/defaults.py new file mode 100644 index 0000000..8717d12 --- /dev/null +++ b/comictaggerlib/defaults.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import NamedTuple + + +class Replacement(NamedTuple): + find: str + replce: str + strict_only: bool + + +class Replacements(NamedTuple): + literal_text: list[Replacement] + format_value: list[Replacement] + + +DEFAULT_REPLACEMENTS = Replacements( + literal_text=[ + Replacement(": ", " - ", True), + Replacement(":", "-", True), + ], + format_value=[ + Replacement(": ", " - ", True), + Replacement(":", "-", True), + Replacement("/", "-", False), + Replacement("//", "--", False), + Replacement("\\", "-", True), + ], +) diff --git a/comictaggerlib/filerenamer.py b/comictaggerlib/filerenamer.py index d52214f..828f8f2 100644 --- a/comictaggerlib/filerenamer.py +++ b/comictaggerlib/filerenamer.py @@ -20,42 +20,18 @@ import logging import os import pathlib import string -from typing import Any, NamedTuple, cast +from typing import Any, cast from pathvalidate import Platform, normalize_platform, sanitize_filename from comicapi.comicarchive import ComicArchive from comicapi.genericmetadata import GenericMetadata from comicapi.issuestring import IssueString +from comictaggerlib.defaults import DEFAULT_REPLACEMENTS, Replacement, Replacements logger = logging.getLogger(__name__) -class Replacement(NamedTuple): - find: str - replce: str - strict_only: bool - - -class Replacements(NamedTuple): - literal_text: list[Replacement] - format_value: list[Replacement] - - -REPLACEMENTS = Replacements( - literal_text=[ - Replacement(": ", " - ", True), - Replacement(":", "-", True), - ], - format_value=[ - Replacement(": ", " - ", True), - Replacement(":", "-", True), - Replacement("/", "-", False), - Replacement("\\", "-", True), - ], -) - - def get_rename_dir(ca: ComicArchive, rename_dir: str | pathlib.Path | None) -> pathlib.Path: folder = ca.path.parent.absolute() if rename_dir is not None: @@ -67,7 +43,7 @@ def get_rename_dir(ca: ComicArchive, rename_dir: str | pathlib.Path | None) -> p class MetadataFormatter(string.Formatter): def __init__( - self, smart_cleanup: bool = False, platform: str = "auto", replacements: Replacements = REPLACEMENTS + self, smart_cleanup: bool = False, platform: str = "auto", replacements: Replacements = DEFAULT_REPLACEMENTS ) -> None: super().__init__() self.smart_cleanup = smart_cleanup @@ -200,13 +176,19 @@ class MetadataFormatter(string.Formatter): class FileRenamer: - def __init__(self, metadata: GenericMetadata | None, platform: str = "auto") -> None: + def __init__( + self, + metadata: GenericMetadata | None, + platform: str = "auto", + replacements: Replacements = DEFAULT_REPLACEMENTS, + ) -> None: self.template = "{publisher}/{series}/{series} v{volume} #{issue} (of {issue_count}) ({year})" self.smart_cleanup = True self.issue_zero_padding = 3 self.metadata = metadata or GenericMetadata() self.move = False self.platform = platform + self.replacements = replacements def set_metadata(self, metadata: GenericMetadata) -> None: self.metadata = metadata @@ -234,7 +216,7 @@ class FileRenamer: new_name = "" - fmt = MetadataFormatter(self.smart_cleanup, platform=self.platform) + fmt = MetadataFormatter(self.smart_cleanup, platform=self.platform, replacements=self.replacements) md_dict = vars(md) for role in ["writer", "penciller", "inker", "colorist", "letterer", "cover artist", "editor"]: md_dict[role] = md.get_primary_credit(role) diff --git a/comictaggerlib/renamewindow.py b/comictaggerlib/renamewindow.py index f231618..a3f3d78 100644 --- a/comictaggerlib/renamewindow.py +++ b/comictaggerlib/renamewindow.py @@ -62,7 +62,7 @@ class RenameWindow(QtWidgets.QDialog): self.btnSettings.clicked.connect(self.modify_settings) platform = "universal" if self.options["filename"]["rename_strict"] else "auto" - self.renamer = FileRenamer(None, platform=platform) + self.renamer = FileRenamer(None, platform=platform, replacements=self.options["rename"]["replacements"]) self.do_preview() @@ -70,6 +70,7 @@ class RenameWindow(QtWidgets.QDialog): self.renamer.set_template(self.options["filename"]["rename_template"]) self.renamer.set_issue_zero_padding(self.options["filename"]["rename_issue_number_padding"]) self.renamer.set_smart_cleanup(self.options["filename"]["rename_use_smart_string_cleanup"]) + self.renamer.replacements = self.options["rename"]["replacements"] new_ext = ca.path.suffix # default if self.options["filename"]["rename_set_extension_based_on_archive"]: diff --git a/comictaggerlib/settings/file.py b/comictaggerlib/settings/file.py index 36931f5..bafa90c 100644 --- a/comictaggerlib/settings/file.py +++ b/comictaggerlib/settings/file.py @@ -2,10 +2,11 @@ from __future__ import annotations import argparse import uuid -from collections.abc import Sequence -from typing import Any, Callable +from typing import Any +from comictaggerlib.defaults import DEFAULT_REPLACEMENTS, Replacement, Replacements from comictaggerlib.settings.manager import Manager +from comictaggerlib.settings.types import AppendAction def general(parser: Manager) -> None: @@ -38,75 +39,10 @@ def identifier(parser: Manager) -> None: parser.add_setting( "--publisher-filter", default=["Panini Comics", "Abril", "Planeta DeAgostini", "Editorial Televisa", "Dino Comics"], - action=_AppendAction, + action=AppendAction, ) -def _copy_items(items: Sequence[Any] | None) -> Sequence[Any]: - if items is None: - return [] - # The copy module is used only in the 'append' and 'append_const' - # actions, and it is needed only when the default value isn't a list. - # Delay its import for speeding up the common case. - if type(items) is list: - return items[:] - import copy - - return copy.copy(items) - - -class _AppendAction(argparse.Action): - def __init__( - self, - option_strings: list[str], - dest: str, - nargs: str | None = None, - const: Any = None, - default: Any = None, - type: Callable[[str], Any] | None = None, # noqa: A002 - choices: list[Any] | None = None, - required: bool = False, - help: str | None = None, # noqa: A002 - metavar: str | None = None, - ): - self.called = False - if nargs == 0: - raise ValueError( - "nargs for append actions must be != 0; if arg " - "strings are not supplying the value to append, " - "the append const action may be more appropriate" - ) - if const is not None and nargs != argparse.OPTIONAL: - raise ValueError("nargs must be %r to supply const" % argparse.OPTIONAL) - super().__init__( - option_strings=option_strings, - dest=dest, - nargs=nargs, - const=const, - default=default, - type=type, - choices=choices, - required=required, - help=help, - metavar=metavar, - ) - - def __call__( - self, - parser: argparse.ArgumentParser, - namespace: argparse.Namespace, - values: str | Sequence[Any] | None, - option_string: str | None = None, - ) -> None: - if values: - if not self.called: - setattr(namespace, self.dest, []) - items = getattr(namespace, self.dest, None) - items = _copy_items(items) - items.append(values) # type: ignore - setattr(namespace, self.dest, items) - - def dialog(parser: Manager) -> None: # Show/ask dialog flags parser.add_setting("ask_about_cbi_in_rar", default=True, cmdline=False) @@ -140,12 +76,10 @@ def comicvine(parser: Manager) -> None: parser.add_setting("--remove-html-tables", default=False, action=argparse.BooleanOptionalAction) parser.add_setting( "--cv-api-key", - default="", help="Use the given Comic Vine API Key (persisted in settings).", ) parser.add_setting( "--cv-url", - default="", help="Use the given Comic Vine URL (persisted in settings).", ) parser.add_setting( @@ -184,6 +118,11 @@ def rename(parser: Manager) -> None: parser.add_setting("--dir", default="") parser.add_setting("--move-to-dir", default=False, action=argparse.BooleanOptionalAction) parser.add_setting("--strict", default=False, action=argparse.BooleanOptionalAction) + parser.add_setting( + "replacements", + default=DEFAULT_REPLACEMENTS, + cmdline=False, + ) def autotag(parser: Manager) -> None: @@ -214,6 +153,10 @@ def validate_settings(options: dict[str, dict[str, Any]], parser: Manager) -> di options["identifier"]["publisher_filter"] = [ x.strip() for x in options["identifier"]["publisher_filter"] if x.strip() ] + options["rename"]["replacements"] = Replacements( + [Replacement(x[0], x[1], x[2]) for x in options["rename"]["replacements"][0]], + [Replacement(x[0], x[1], x[2]) for x in options["rename"]["replacements"][1]], + ) return options diff --git a/comictaggerlib/settings/types.py b/comictaggerlib/settings/types.py index 774fbdb..7028e89 100644 --- a/comictaggerlib/settings/types.py +++ b/comictaggerlib/settings/types.py @@ -2,7 +2,8 @@ from __future__ import annotations import argparse import pathlib -from typing import Any +from collections.abc import Sequence +from typing import Any, Callable from appdirs import AppDirs @@ -72,6 +73,71 @@ def metadata_type(types: str) -> list[int]: return result +def _copy_items(items: Sequence[Any] | None) -> Sequence[Any]: + if items is None: + return [] + # The copy module is used only in the 'append' and 'append_const' + # actions, and it is needed only when the default value isn't a list. + # Delay its import for speeding up the common case. + if type(items) is list: + return items[:] + import copy + + return copy.copy(items) + + +class AppendAction(argparse.Action): + def __init__( + self, + option_strings: list[str], + dest: str, + nargs: str | None = None, + const: Any = None, + default: Any = None, + type: Callable[[str], Any] | None = None, # noqa: A002 + choices: list[Any] | None = None, + required: bool = False, + help: str | None = None, # noqa: A002 + metavar: str | None = None, + ): + self.called = False + if nargs == 0: + raise ValueError( + "nargs for append actions must be != 0; if arg " + "strings are not supplying the value to append, " + "the append const action may be more appropriate" + ) + if const is not None and nargs != argparse.OPTIONAL: + raise ValueError("nargs must be %r to supply const" % argparse.OPTIONAL) + super().__init__( + option_strings=option_strings, + dest=dest, + nargs=nargs, + const=const, + default=default, + type=type, + choices=choices, + required=required, + help=help, + metavar=metavar, + ) + + def __call__( + self, + parser: argparse.ArgumentParser, + namespace: argparse.Namespace, + values: str | Sequence[Any] | None, + option_string: str | None = None, + ) -> None: + if values: + if not self.called: + setattr(namespace, self.dest, []) + items = getattr(namespace, self.dest, None) + items = _copy_items(items) + items.append(values) # type: ignore + setattr(namespace, self.dest, items) + + def parse_metadata_from_string(mdstr: str) -> GenericMetadata: """The metadata string is a comma separated list of name-value pairs The names match the attributes of the internal metadata struct (for now) diff --git a/comictaggerlib/settingswindow.py b/comictaggerlib/settingswindow.py index b786869..daf585e 100644 --- a/comictaggerlib/settingswindow.py +++ b/comictaggerlib/settingswindow.py @@ -20,6 +20,7 @@ import logging import os import pathlib import platform +from typing import Any from PyQt5 import QtCore, QtGui, QtWidgets, uic @@ -27,7 +28,7 @@ from comicapi import utils from comicapi.genericmetadata import md_test from comictaggerlib import settings from comictaggerlib.ctversion import version -from comictaggerlib.filerenamer import FileRenamer +from comictaggerlib.filerenamer import FileRenamer, Replacement, Replacements from comictaggerlib.imagefetcher import ImageFetcher from comictaggerlib.ui import ui_path from comictalker.comiccacher import ComicCacher @@ -190,23 +191,64 @@ class SettingsWindow(QtWidgets.QDialog): self.btnResetSettings.clicked.connect(self.reset_settings) self.btnTestKey.clicked.connect(self.test_api_key) self.btnTemplateHelp.clicked.connect(self.show_template_help) - self.leRenameTemplate.textEdited.connect(self._rename_test) - self.cbxMoveFiles.clicked.connect(self.rename_test) self.cbxMoveFiles.clicked.connect(self.dir_test) - self.cbxRenameStrict.clicked.connect(self.rename_test) self.leDirectory.textEdited.connect(self.dir_test) self.cbxComplicatedParser.clicked.connect(self.switch_parser) - def rename_test(self) -> None: + self.btnAddLiteralReplacement.clicked.connect(self.addLiteralReplacement) + self.btnAddValueReplacement.clicked.connect(self.addValueReplacement) + self.btnRemoveLiteralReplacement.clicked.connect(self.removeLiteralReplacement) + self.btnRemoveValueReplacement.clicked.connect(self.removeValueReplacement) + + self.leRenameTemplate.textEdited.connect(self.rename_test) + self.cbxMoveFiles.clicked.connect(self.rename_test) + self.cbxRenameStrict.clicked.connect(self.rename_test) + self.cbxSmartCleanup.clicked.connect(self.rename_test) + self.cbxChangeExtension.clicked.connect(self.rename_test) + self.leIssueNumPadding.textEdited.connect(self.rename_test) + self.twLiteralReplacements.cellChanged.connect(self.rename_test) + self.twValueReplacements.cellChanged.connect(self.rename_test) + + def addLiteralReplacement(self) -> None: + self.insertRow(self.twLiteralReplacements, self.twLiteralReplacements.rowCount(), Replacement("", "", False)) + + def addValueReplacement(self) -> None: + self.insertRow(self.twValueReplacements, self.twValueReplacements.rowCount(), Replacement("", "", False)) + + def removeLiteralReplacement(self) -> None: + if self.twLiteralReplacements.currentRow() >= 0: + self.twLiteralReplacements.removeRow(self.twLiteralReplacements.currentRow()) + + def removeValueReplacement(self) -> None: + if self.twValueReplacements.currentRow() >= 0: + self.twValueReplacements.removeRow(self.twValueReplacements.currentRow()) + + def insertRow(self, table: QtWidgets.QTableWidget, row: int, replacement: Replacement) -> None: + find, replace, strict_only = replacement + table.insertRow(row) + table.setItem(row, 0, QtWidgets.QTableWidgetItem(find)) + table.setItem(row, 1, QtWidgets.QTableWidgetItem(replace)) + tmp = QtWidgets.QTableWidgetItem() + if strict_only: + tmp.setCheckState(QtCore.Qt.Checked) + else: + tmp.setCheckState(QtCore.Qt.Unchecked) + table.setItem(row, 2, tmp) + + def rename_test(self, *args: Any, **kwargs: Any) -> None: self._rename_test(self.leRenameTemplate.text()) def dir_test(self) -> None: self.lblDir.setText( - str(pathlib.Path(self.leDirectory.text().strip()).absolute()) if self.cbxMoveFiles.isChecked() else "" + str(pathlib.Path(self.leDirectory.text().strip()).resolve()) if self.cbxMoveFiles.isChecked() else "" ) def _rename_test(self, template: str) -> None: - fr = FileRenamer(md_test, platform="universal" if self.cbxRenameStrict.isChecked() else "auto") + fr = FileRenamer( + md_test, + platform="universal" if self.cbxRenameStrict.isChecked() else "auto", + replacements=self.get_replacemnts(), + ) fr.move = self.cbxMoveFiles.isChecked() fr.set_template(template) fr.set_issue_zero_padding(int(self.leIssueNumPadding.text())) @@ -271,6 +313,38 @@ class SettingsWindow(QtWidgets.QDialog): self.leDirectory.setText(self.options["rename"]["dir"]) self.cbxRenameStrict.setChecked(self.options["rename"]["strict"]) + for table, replacments in zip( + (self.twLiteralReplacements, self.twValueReplacements), self.options["rename"]["replacements"] + ): + table.clearContents() + for i in reversed(range(table.rowCount())): + table.removeRow(i) + for row, replacement in enumerate(replacments): + self.insertRow(table, row, replacement) + + def get_replacemnts(self) -> Replacements: + literal_replacements = [] + value_replacements = [] + for row in range(self.twLiteralReplacements.rowCount()): + if self.twLiteralReplacements.item(row, 0).text(): + literal_replacements.append( + Replacement( + self.twLiteralReplacements.item(row, 0).text(), + self.twLiteralReplacements.item(row, 1).text(), + self.twLiteralReplacements.item(row, 2).checkState() == QtCore.Qt.Checked, + ) + ) + for row in range(self.twValueReplacements.rowCount()): + if self.twValueReplacements.item(row, 0).text(): + value_replacements.append( + Replacement( + self.twValueReplacements.item(row, 0).text(), + self.twValueReplacements.item(row, 1).text(), + self.twValueReplacements.item(row, 2).checkState() == QtCore.Qt.Checked, + ) + ) + return Replacements(literal_replacements, value_replacements) + def accept(self) -> None: self.rename_test() if self.rename_error is not None: @@ -362,6 +436,7 @@ class SettingsWindow(QtWidgets.QDialog): self.options["rename"]["dir"] = self.leDirectory.text() self.options["rename"]["strict"] = self.cbxRenameStrict.isChecked() + self.options["rename"]["replacements"] = self.get_replacemnts() settings.Manager().save_file(self.options, self.options["runtime"]["config"].user_config_dir / "settings.json") self.parent().options = self.options @@ -402,9 +477,9 @@ class SettingsWindow(QtWidgets.QDialog): dialog.setDirectory(os.path.dirname(str(control.text()))) if name == "RAR": - dialog.setWindowTitle("Find " + name + " program") + dialog.setWindowTitle(f"Find {name} program") else: - dialog.setWindowTitle("Find " + name + " library") + dialog.setWindowTitle(f"Find {name} library") if dialog.exec(): file_list = dialog.selectedFiles() diff --git a/comictaggerlib/ui/settingswindow.ui b/comictaggerlib/ui/settingswindow.ui index 418bf4c..813c7a8 100644 --- a/comictaggerlib/ui/settingswindow.ui +++ b/comictaggerlib/ui/settingswindow.ui @@ -7,7 +7,7 @@ 0 0 702 - 488 + 513 @@ -616,7 +616,21 @@ Rename - + + + + Add Replacement + + + + + + + Remove Replacement + + + + QFormLayout::AllNonFixedFieldsGrow @@ -717,8 +731,7 @@ - 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. + If checked will ensure reserved characters and filenames are removed for all Operating Systems.<br/>By default only removes restricted characters and filenames for the current Operating System. Strict renaming @@ -730,6 +743,118 @@ By default only removes restricted characters and filenames for the current Oper + + + + Add Replacement + + + + + + + Remove Replacement + + + + + + + + Monaco + + + + QAbstractItemView::SingleSelection + + + true + + + + Find + + + AlignCenter + + + + + Replacement + + + AlignCenter + + + + + Strict Only + + + AlignCenter + + + + + + + + + Monaco + + + + Qt::ActionsContextMenu + + + QAbstractItemView::SingleSelection + + + 3 + + + true + + + + Find + + + AlignCenter + + + + + Replacement + + + AlignCenter + + + + + Strict Only + + + AlignCenter + + + + + + + + Value Text Replacements + + + + + + + Literal Text Replacements + + +