From 8b0683f67c9d0d0453f77139865ef44fcaf7c851 Mon Sep 17 00:00:00 2001 From: Mizaki Date: Sun, 4 Feb 2024 20:33:10 +0000 Subject: [PATCH] Add OverlayMode options for read style and data source --- comicapi/genericmetadata.py | 279 +++++--- comicapi/utils.py | 5 + comictaggerlib/autotagmatchwindow.py | 6 +- comictaggerlib/cbltransformer.py | 14 +- comictaggerlib/cli.py | 9 +- comictaggerlib/ctsettings/__init__.py | 7 +- comictaggerlib/ctsettings/file.py | 51 +- .../ctsettings/settngs_namespace.py | 52 +- comictaggerlib/main.py | 2 +- comictaggerlib/settingswindow.py | 63 +- comictaggerlib/taggerwindow.py | 4 +- comictaggerlib/ui/settingswindow.ui | 597 ++++++++++++++---- tests/genericmetadata_test.py | 102 +++ 13 files changed, 905 insertions(+), 286 deletions(-) diff --git a/comicapi/genericmetadata.py b/comicapi/genericmetadata.py index cc6760b..060c461 100644 --- a/comicapi/genericmetadata.py +++ b/comicapi/genericmetadata.py @@ -24,6 +24,7 @@ from __future__ import annotations import copy import dataclasses import logging +import sys from collections.abc import Sequence from enum import Enum, auto from typing import TYPE_CHECKING, Any, TypedDict, Union @@ -32,6 +33,7 @@ from typing_extensions import NamedTuple, Required from comicapi import utils from comicapi._url import Url, parse_url +from comicapi.utils import norm_fold if TYPE_CHECKING: Union @@ -46,6 +48,45 @@ class __remove(Enum): REMOVE = __remove.REMOVE +if sys.version_info < (3, 11): + + class StrEnum(str, Enum): + """ + Enum where members are also (and must be) strings + """ + + def __new__(cls, *values: Any) -> Any: + "values must already be of type `str`" + if len(values) > 3: + raise TypeError(f"too many arguments for str(): {values!r}") + if len(values) == 1: + # it must be a string + if not isinstance(values[0], str): + raise TypeError(f"{values[0]!r} is not a string") + if len(values) >= 2: + # check that encoding argument is a string + if not isinstance(values[1], str): + raise TypeError(f"encoding must be a string, not {values[1]!r}") + if len(values) == 3: + # check that errors argument is a string + if not isinstance(values[2], str): + raise TypeError("errors must be a string, not %r" % (values[2])) + value = str(*values) + member = str.__new__(cls, value) + member._value_ = value + return member + + @staticmethod + def _generate_next_value_(name: str, start: int, count: int, last_values: Any) -> str: + """ + Return the lower-cased version of the member name. + """ + return name.lower() + +else: + from enum import StrEnum + + class PageType: """ These page info classes are exactly the same as the CIX scheme, since @@ -104,6 +145,12 @@ class TagOrigin(NamedTuple): name: str +class OverlayMode(StrEnum): + overlay = auto() + add_missing = auto() + combine = auto() + + @dataclasses.dataclass class GenericMetadata: writer_synonyms = ("writer", "plotter", "scripter", "script") @@ -214,101 +261,172 @@ class GenericMetadata: new_md.__post_init__() return new_md - def overlay(self, new_md: GenericMetadata) -> None: - """Overlay a metadata object on this one + def credit_dedupe(self, cur: list[Credit], new: list[Credit]) -> list[Credit]: + if len(new) == 0: + return cur + if len(cur) == 0: + return new - That is, when the new object has non-None values, over-write them - to this one. - """ + # Create dict for deduplication + new_dict: dict[str, Credit] = {f"{norm_fold(n['person'])}_{n['role'].casefold()}": n for n in new} + cur_dict: dict[str, Credit] = {f"{norm_fold(c['person'])}_{c['role'].casefold()}": c for c in cur} - def assign(cur: str, new: Any) -> None: - if new is not None: - if new is REMOVE: - if isinstance(getattr(self, cur), (list, set)): - getattr(self, cur).clear() - else: - setattr(self, cur, None) - return + # Any duplicates use the 'new' value + cur_dict.update(new_dict) + return list(cur_dict.values()) - if isinstance(new, str) and len(new) == 0: - setattr(self, cur, None) - elif isinstance(new, (list, set)) and len(new) == 0: - pass + def assign_dedupe(self, new: list[str] | set[str], cur: list[str] | set[str]) -> list[str] | set[str]: + """Dedupes normalised (NFKD), casefolded values using 'new' values on collisions""" + if len(new) == 0: + return cur + if len(cur) == 0: + return new + + # Create dict values for deduplication + new_dict: dict[str, str] = {norm_fold(n): n for n in new} + cur_dict: dict[str, str] = {norm_fold(c): c for c in cur} + + if isinstance(cur, list): + cur_dict.update(new_dict) + return list(cur_dict.values()) + + if isinstance(cur, set): + cur_dict.update(new_dict) + return set(cur_dict.values()) + + # All else fails + return cur + + def assign_overlay(self, cur: Any, new: Any) -> Any: + """Overlay - When the 'new' object has non-None values, overwrite 'cur'(rent) with 'new'.""" + if new is None: + return cur + if isinstance(new, (list, set)) and len(new) == 0: + return cur + else: + return new + + def assign_add_missing(self, cur: Any, new: Any) -> Any: + """Add Missing - Any 'cur(rent)' values that are None or an empty list/set, add 'new' non-None values""" + if new is None: + return cur + if cur is None: + return new + elif isinstance(cur, (list, set)) and len(cur) == 0: + return new + else: + return cur + + def assign_combine(self, cur: Any, new: Any) -> Any: + """Combine - Combine lists and sets (checking for dupes). All non-None values from new replace cur(rent)""" + if new is None: + return cur + if isinstance(new, (list, set)) and isinstance(cur, (list, set)): + # Check for weblinks (Url is a tuple) + if len(new) > 0 and isinstance(new, list) and isinstance(new[0], Url): + return list(set(new).union(cur)) + return self.assign_dedupe(new, cur) + else: + return new + + def overlay(self, new_md: GenericMetadata, mode: OverlayMode = OverlayMode.overlay) -> None: + """Overlay a new metadata object on this one""" + + assign_funcs = { + OverlayMode.overlay: self.assign_overlay, + OverlayMode.add_missing: self.assign_add_missing, + OverlayMode.combine: self.assign_combine, + } + + def assign(cur: Any, new: Any) -> Any: + if new is REMOVE: + if isinstance(cur, (list, set)): + cur.clear() + return cur else: - setattr(self, cur, new) + return None + + assign_func = assign_funcs.get(mode, self.assign_overlay) + return assign_func(cur, new) if not new_md.is_empty: self.is_empty = False - assign("tag_origin", new_md.tag_origin) - assign("issue_id", new_md.issue_id) - assign("series_id", new_md.series_id) + self.tag_origin = assign(self.tag_origin, new_md.tag_origin) # TODO use and purpose now? + self.issue_id = assign(self.issue_id, new_md.issue_id) + self.series_id = assign(self.series_id, new_md.series_id) - assign("series", new_md.series) - assign("series_aliases", new_md.series_aliases) - assign("issue", new_md.issue) - assign("issue_count", new_md.issue_count) - assign("title", new_md.title) - assign("title_aliases", new_md.title_aliases) - assign("volume", new_md.volume) - assign("volume_count", new_md.volume_count) - assign("genres", new_md.genres) - assign("description", new_md.description) - assign("notes", new_md.notes) + self.series = assign(self.series, new_md.series) + self.series_aliases = assign(self.series_aliases, new_md.series_aliases) + self.issue = assign(self.issue, new_md.issue) + self.issue_count = assign(self.issue_count, new_md.issue_count) + self.title = assign(self.title, new_md.title) + self.title_aliases = assign(self.title_aliases, new_md.title_aliases) + self.volume = assign(self.volume, new_md.volume) + self.volume_count = assign(self.volume_count, new_md.volume_count) + self.genres = assign(self.genres, new_md.genres) + self.description = assign(self.description, new_md.description) + self.notes = assign(self.notes, new_md.notes) - assign("alternate_series", new_md.alternate_series) - assign("alternate_number", new_md.alternate_number) - assign("alternate_count", new_md.alternate_count) - assign("story_arcs", new_md.story_arcs) - assign("series_groups", new_md.series_groups) + self.alternate_series = assign(self.alternate_series, new_md.alternate_series) + self.alternate_number = assign(self.alternate_number, new_md.alternate_number) + self.alternate_count = assign(self.alternate_count, new_md.alternate_count) + self.story_arcs = assign(self.story_arcs, new_md.story_arcs) + self.series_groups = assign(self.series_groups, new_md.series_groups) - assign("publisher", new_md.publisher) - assign("imprint", new_md.imprint) - assign("day", new_md.day) - assign("month", new_md.month) - assign("year", new_md.year) - assign("language", new_md.language) - assign("country", new_md.country) - assign("web_links", new_md.web_links) - assign("format", new_md.format) - assign("manga", new_md.manga) - assign("black_and_white", new_md.black_and_white) - assign("maturity_rating", new_md.maturity_rating) - assign("critical_rating", new_md.critical_rating) - assign("scan_info", new_md.scan_info) + self.publisher = assign(self.publisher, new_md.publisher) + self.imprint = assign(self.imprint, new_md.imprint) + self.day = assign(self.day, new_md.day) + self.month = assign(self.month, new_md.month) + self.year = assign(self.year, new_md.year) + self.language = assign(self.language, new_md.language) + self.country = assign(self.country, new_md.country) + self.web_links = assign(self.web_links, new_md.web_links) + self.format = assign(self.format, new_md.format) + self.manga = assign(self.manga, new_md.manga) + self.black_and_white = assign(self.black_and_white, new_md.black_and_white) + self.maturity_rating = assign(self.maturity_rating, new_md.maturity_rating) + self.critical_rating = assign(self.critical_rating, new_md.critical_rating) + self.scan_info = assign(self.scan_info, new_md.scan_info) - assign("tags", new_md.tags) - assign("pages", new_md.pages) - assign("page_count", new_md.page_count) + self.tags = assign(self.tags, new_md.tags) + self.pages = assign(self.pages, new_md.pages) + self.page_count = assign(self.page_count, new_md.page_count) - assign("characters", new_md.characters) - assign("teams", new_md.teams) - assign("locations", new_md.locations) - self.overlay_credits(new_md.credits) + self.characters = assign(self.characters, new_md.characters) + self.teams = assign(self.teams, new_md.teams) + self.locations = assign(self.locations, new_md.locations) + self.credits = self.assign_credits(self.credits, new_md.credits) - assign("price", new_md.price) - assign("is_version_of", new_md.is_version_of) - assign("rights", new_md.rights) - assign("identifier", new_md.identifier) - assign("last_mark", new_md.last_mark) - assign("_cover_image", new_md._cover_image) - assign("_alternate_images", new_md._alternate_images) + self.price = assign(self.price, new_md.price) + self.is_version_of = assign(self.is_version_of, new_md.is_version_of) + self.rights = assign(self.rights, new_md.rights) + self.identifier = assign(self.identifier, new_md.identifier) + self.last_mark = assign(self.last_mark, new_md.last_mark) + self._cover_image = assign(self._cover_image, new_md._cover_image) + self._alternate_images = assign(self._alternate_images, new_md._alternate_images) - def overlay_credits(self, new_credits: list[Credit]) -> None: + def assign_credits_overlay(self, cur_credits: list[Credit], new_credits: list[Credit]) -> list[Credit]: + return self.credit_dedupe(cur_credits, new_credits) + + def assign_credits_add_missing(self, cur_credits: list[Credit], new_credits: list[Credit]) -> list[Credit]: + # Send new_credits as cur_credits and vis-versa to keep cur_credit on duplication + return self.credit_dedupe(new_credits, cur_credits) + + def assign_credits( + self, cur_credits: list[Credit], new_credits: list[Credit], mode: OverlayMode = OverlayMode.overlay + ) -> list[Credit]: if new_credits is REMOVE: - self.credits = [] - return - for c in new_credits: - primary = bool("primary" in c and c["primary"]) + return [] - # Remove credit role if person is blank - if c["person"] == "": - for r in reversed(self.credits): - if r["role"].casefold() == c["role"].casefold(): - self.credits.remove(r) - # otherwise, add it! - else: - self.add_credit(c["person"], c["role"], primary) + assign_credit_funcs = { + OverlayMode.overlay: self.assign_credits_overlay, + OverlayMode.add_missing: self.assign_credits_add_missing, + OverlayMode.combine: self.assign_credits_overlay, + } + + assign_credit_func = assign_credit_funcs.get(mode, self.assign_credits_overlay) + return assign_credit_func(cur_credits, new_credits) def apply_default_page_list(self, page_list: Sequence[str]) -> None: # generate a default page list, with the first page marked as the cover @@ -354,12 +472,15 @@ class GenericMetadata: return coverlist def add_credit(self, person: str, role: str, primary: bool = False) -> None: + if person == "": + return + credit = Credit(person=person, role=role, primary=primary) # look to see if it's not already there... found = False for c in self.credits: - if c["person"].casefold() == person.casefold() and c["role"].casefold() == role.casefold(): + if norm_fold(c["person"]) == norm_fold(person) and norm_fold(c["role"]) == norm_fold(role): # no need to add it. just adjust the "primary" flag as needed c["primary"] = primary found = True diff --git a/comicapi/utils.py b/comicapi/utils.py index 91239a5..ccfdac7 100644 --- a/comicapi/utils.py +++ b/comicapi/utils.py @@ -223,6 +223,11 @@ def parse_filename( return fni +def norm_fold(string: str) -> str: + """Normalise and casefold string""" + return unicodedata.normalize("NFKD", string).casefold() + + def combine_notes(existing_notes: str | None, new_notes: str | None, split: str) -> str: split_notes, split_str, untouched_notes = (existing_notes or "").rpartition(split) if split_notes or split_str: diff --git a/comictaggerlib/autotagmatchwindow.py b/comictaggerlib/autotagmatchwindow.py index 0b0acb7..163f597 100644 --- a/comictaggerlib/autotagmatchwindow.py +++ b/comictaggerlib/autotagmatchwindow.py @@ -81,7 +81,7 @@ class AutoTagMatchWindow(QtWidgets.QDialog): self.buttonBox.button(QtWidgets.QDialogButtonBox.StandardButton.Ok).setText("Accept and Write Tags") self.match_set_list = match_set_list - self.load_data_styles = load_styles + self._styles = styles self.fetch_func = fetch_func self.current_match_set_idx = 0 @@ -262,8 +262,8 @@ class AutoTagMatchWindow(QtWidgets.QDialog): return QtWidgets.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.CursorShape.WaitCursor)) - md.overlay(ct_md) - for style in self.load_data_styles: + md.overlay(ct_md, self.config.Metadata_Options__source_overlay) + for style in self._styles: success = ca.write_metadata(md, style) QtWidgets.QApplication.restoreOverrideCursor() if not success: diff --git a/comictaggerlib/cbltransformer.py b/comictaggerlib/cbltransformer.py index 79cbe4e..d195273 100644 --- a/comictaggerlib/cbltransformer.py +++ b/comictaggerlib/cbltransformer.py @@ -30,7 +30,7 @@ class CBLTransformer: self.config = config def apply(self) -> GenericMetadata: - if self.config.Comic_Book_Lover__assume_lone_credit_is_primary: + if self.config.Metadata_Options__cbl_assume_lone_credit_is_primary: # helper def set_lone_primary(role_list: list[str]) -> tuple[Credit | None, int]: lone_credit: Credit | None = None @@ -56,19 +56,19 @@ class CBLTransformer: c["primary"] = False self.metadata.add_credit(c["person"], "Artist", True) - if self.config.Comic_Book_Lover__copy_characters_to_tags: + if self.config.Metadata_Options__cbl_copy_characters_to_tags: self.metadata.tags.update(x for x in self.metadata.characters) - if self.config.Comic_Book_Lover__copy_teams_to_tags: + if self.config.Metadata_Options__cbl_copy_teams_to_tags: self.metadata.tags.update(x for x in self.metadata.teams) - if self.config.Comic_Book_Lover__copy_locations_to_tags: + if self.config.Metadata_Options__cbl_copy_locations_to_tags: self.metadata.tags.update(x for x in self.metadata.locations) - if self.config.Comic_Book_Lover__copy_storyarcs_to_tags: + if self.config.Metadata_Options__cbl_copy_storyarcs_to_tags: self.metadata.tags.update(x for x in self.metadata.story_arcs) - if self.config.Comic_Book_Lover__copy_notes_to_comments: + if self.config.Metadata_Options__cbl_copy_notes_to_comments: if self.metadata.notes is not None: if self.metadata.description is None: self.metadata.description = "" @@ -77,7 +77,7 @@ class CBLTransformer: if self.metadata.notes not in self.metadata.description: self.metadata.description += self.metadata.notes - if self.config.Comic_Book_Lover__copy_weblink_to_comments: + if self.config.Metadata_Options__cbl_copy_weblink_to_comments: for web_link in self.metadata.web_links: temp_desc = self.metadata.description if temp_desc is None: diff --git a/comictaggerlib/cli.py b/comictaggerlib/cli.py index 7b7dee4..13dc94a 100644 --- a/comictaggerlib/cli.py +++ b/comictaggerlib/cli.py @@ -127,7 +127,7 @@ class CLI: logger.exception(f"Error retrieving issue details. Save aborted.\n{e}") return GenericMetadata() - if self.config.Comic_Book_Lover__apply_transform_on_import: + if self.config.Metadata_Options__cbl_apply_transform_on_import: ct_md = CBLTransformer(ct_md, self.config).apply() return ct_md @@ -253,11 +253,12 @@ class CLI: if ca.has_metadata(style): try: t_md = ca.read_metadata(style) - md.overlay(t_md) + md.overlay(t_md, self.config.Metadata_Options__read_style_overlay) + break except Exception as e: logger.error("Failed to load metadata for %s: %s", ca.path, e) - # finally, use explicit stuff + # finally, use explicit stuff (always 'overlay' mode) md.overlay(self.config.Runtime_Options__metadata) return md @@ -340,7 +341,7 @@ class CLI: src_style_name = md_styles[self.config.Commands__copy].name() if not self.config.Runtime_Options__dryrun: - if self.config.Comic_Book_Lover__apply_transform_on_bulk_operation == "cbi": + if self.config.Metadata_Options__cbl_apply_transform_on_bulk_operation == "cbi": md = CBLTransformer(md, self.config).apply() if ca.write_metadata(md, style): diff --git a/comictaggerlib/ctsettings/__init__.py b/comictaggerlib/ctsettings/__init__.py index 4029bd4..5e68f71 100644 --- a/comictaggerlib/ctsettings/__init__.py +++ b/comictaggerlib/ctsettings/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import json import logging import pathlib +from enum import Enum from typing import Any import settngs @@ -55,7 +56,11 @@ def validate_types(config: settngs.Config[settngs.Values]) -> settngs.Config[set if setting.type is not None: # If it is not the default and the type attribute is not None # use it to convert the loaded string into the expected value - if isinstance(value, str): + if ( + isinstance(value, str) + or isinstance(default, Enum) + or (isinstance(setting.type, type) and issubclass(setting.type, Enum)) + ): config.values[setting.group][setting.dest] = setting.type(value) return config diff --git a/comictaggerlib/ctsettings/file.py b/comictaggerlib/ctsettings/file.py index a6066e6..6f67c98 100644 --- a/comictaggerlib/ctsettings/file.py +++ b/comictaggerlib/ctsettings/file.py @@ -6,6 +6,7 @@ import uuid import settngs from comicapi import utils +from comicapi.genericmetadata import OverlayMode from comictaggerlib.ctsettings.settngs_namespace import SettngsNS as ct_ns from comictaggerlib.defaults import DEFAULT_REPLACEMENTS, Replacement, Replacements @@ -13,13 +14,6 @@ from comictaggerlib.defaults import DEFAULT_REPLACEMENTS, Replacement, Replaceme def general(parser: settngs.Manager) -> None: # General Settings parser.add_setting("check_for_new_version", default=False, cmdline=False) - parser.add_setting( - "--disable-cr", - default=False, - action=argparse.BooleanOptionalAction, - help="Disable the ComicRack metadata type", - ) - parser.add_setting("use_short_metadata_names", default=False, action=argparse.BooleanOptionalAction, cmdline=False) parser.add_setting( "--prompt-on-save", default=True, @@ -164,17 +158,38 @@ def talker(parser: settngs.Manager) -> None: ) -def cbl(parser: settngs.Manager) -> None: +def md_options(parser: settngs.Manager) -> None: # CBL Transform settings - parser.add_setting("--assume-lone-credit-is-primary", default=False, action=argparse.BooleanOptionalAction) - parser.add_setting("--copy-characters-to-tags", default=False, action=argparse.BooleanOptionalAction) - parser.add_setting("--copy-teams-to-tags", default=False, action=argparse.BooleanOptionalAction) - parser.add_setting("--copy-locations-to-tags", default=False, action=argparse.BooleanOptionalAction) - parser.add_setting("--copy-storyarcs-to-tags", default=False, action=argparse.BooleanOptionalAction) - parser.add_setting("--copy-notes-to-comments", default=False, action=argparse.BooleanOptionalAction) - parser.add_setting("--copy-weblink-to-comments", default=False, action=argparse.BooleanOptionalAction) - parser.add_setting("--apply-transform-on-import", default=False, action=argparse.BooleanOptionalAction) - parser.add_setting("--apply-transform-on-bulk-operation", default=False, action=argparse.BooleanOptionalAction) + parser.add_setting("--cbl-assume-lone-credit-is-primary", default=False, action=argparse.BooleanOptionalAction) + parser.add_setting("--cbl-copy-characters-to-tags", default=False, action=argparse.BooleanOptionalAction) + parser.add_setting("--cbl-copy-teams-to-tags", default=False, action=argparse.BooleanOptionalAction) + parser.add_setting("--cbl-copy-locations-to-tags", default=False, action=argparse.BooleanOptionalAction) + parser.add_setting("--cbl-copy-storyarcs-to-tags", default=False, action=argparse.BooleanOptionalAction) + parser.add_setting("--cbl-copy-notes-to-comments", default=False, action=argparse.BooleanOptionalAction) + parser.add_setting("--cbl-copy-weblink-to-comments", default=False, action=argparse.BooleanOptionalAction) + parser.add_setting("--cbl-apply-transform-on-import", default=False, action=argparse.BooleanOptionalAction) + parser.add_setting("--cbl-apply-transform-on-bulk-operation", default=False, action=argparse.BooleanOptionalAction) + + parser.add_setting( + "--read-style-overlay", + default=OverlayMode.overlay, + type=OverlayMode, + help="How to overlay new metadata on the current for enabled read styles (CR, CBL, etc.)", + ) + parser.add_setting( + "--source-overlay", + default=OverlayMode.overlay, + type=OverlayMode, + help="How to overlay the new metadata from a source (CV, Metron, GCD, etc.) on to the current", + ) + + parser.add_setting("use_short_metadata_names", default=False, action=argparse.BooleanOptionalAction, cmdline=False) + parser.add_setting( + "--disable-cr", + default=False, + action=argparse.BooleanOptionalAction, + help="Disable the ComicRack metadata type", + ) def rename(parser: settngs.Manager) -> None: @@ -311,7 +326,7 @@ def register_file_settings(parser: settngs.Manager) -> None: parser.add_group("Issue Identifier", identifier, False) parser.add_group("Filename Parsing", filename, False) parser.add_group("Sources", talker, False) - parser.add_group("Comic Book Lover", cbl, False) + parser.add_group("Metadata Options", md_options, False) parser.add_group("File Rename", rename, False) parser.add_group("Auto-Tag", autotag, False) parser.add_group("General", general, False) diff --git a/comictaggerlib/ctsettings/settngs_namespace.py b/comictaggerlib/ctsettings/settngs_namespace.py index a0677fb..089ff84 100644 --- a/comictaggerlib/ctsettings/settngs_namespace.py +++ b/comictaggerlib/ctsettings/settngs_namespace.py @@ -9,6 +9,7 @@ import comicapi.utils import comictaggerlib.ctsettings.types import comictaggerlib.defaults import comictaggerlib.resulttypes +from comicapi.genericmetadata import OverlayMode class SettngsNS(settngs.TypedNS): @@ -74,15 +75,19 @@ class SettngsNS(settngs.TypedNS): Sources__source: str Sources__remove_html_tables: bool - Comic_Book_Lover__assume_lone_credit_is_primary: bool - Comic_Book_Lover__copy_characters_to_tags: bool - Comic_Book_Lover__copy_teams_to_tags: bool - Comic_Book_Lover__copy_locations_to_tags: bool - Comic_Book_Lover__copy_storyarcs_to_tags: bool - Comic_Book_Lover__copy_notes_to_comments: bool - Comic_Book_Lover__copy_weblink_to_comments: bool - Comic_Book_Lover__apply_transform_on_import: bool - Comic_Book_Lover__apply_transform_on_bulk_operation: bool + Metadata_Options__cbl_assume_lone_credit_is_primary: bool + Metadata_Options__cbl_copy_characters_to_tags: bool + Metadata_Options__cbl_copy_teams_to_tags: bool + Metadata_Options__cbl_copy_locations_to_tags: bool + Metadata_Options__cbl_copy_storyarcs_to_tags: bool + Metadata_Options__cbl_copy_notes_to_comments: bool + Metadata_Options__cbl_copy_weblink_to_comments: bool + Metadata_Options__cbl_apply_transform_on_import: bool + Metadata_Options__cbl_apply_transform_on_bulk_operation: bool + Metadata_Options__read_style_overlay: OverlayMode + Metadata_Options__source_overlay: OverlayMode + Metadata_Options__use_short_metadata_names: bool + Metadata_Options__disable_cr: bool File_Rename__template: str File_Rename__issue_number_padding: int @@ -101,8 +106,6 @@ class SettngsNS(settngs.TypedNS): Auto_Tag__remove_archive_after_successful_match: bool General__check_for_new_version: bool - General__disable_cr: bool - General__use_short_metadata_names: bool General__prompt_on_save: bool Dialog_Flags__show_disclaimer: bool @@ -189,16 +192,19 @@ class Sources(typing.TypedDict): remove_html_tables: bool -class Comic_Book_Lover(typing.TypedDict): - assume_lone_credit_is_primary: bool - copy_characters_to_tags: bool - copy_teams_to_tags: bool - copy_locations_to_tags: bool - copy_storyarcs_to_tags: bool - copy_notes_to_comments: bool - copy_weblink_to_comments: bool - apply_transform_on_import: bool - apply_transform_on_bulk_operation: bool +class Metadata_Options(typing.TypedDict): + cbl_assume_lone_credit_is_primary: bool + cbl_copy_characters_to_tags: bool + cbl_copy_teams_to_tags: bool + cbl_copy_locations_to_tags: bool + cbl_copy_storyarcs_to_tags: bool + cbl_copy_notes_to_comments: bool + cbl_copy_weblink_to_comments: bool + cbl_apply_transform_on_import: bool + cbl_apply_transform_on_bulk_operation: bool + metadata_overlay: OverlayMode + use_short_metadata_names: bool + disable_cr: bool class File_Rename(typing.TypedDict): @@ -223,8 +229,6 @@ class Auto_Tag(typing.TypedDict): class General(typing.TypedDict): check_for_new_version: bool - disable_cr: bool - use_short_metadata_names: bool prompt_on_save: bool @@ -253,7 +257,7 @@ SettngsDict = typing.TypedDict( "Issue Identifier": Issue_Identifier, "Filename Parsing": Filename_Parsing, "Sources": Sources, - "Comic Book Lover": Comic_Book_Lover, + "Metadata Options": Metadata_Options, "File Rename": File_Rename, "Auto-Tag": Auto_Tag, "General": General, diff --git a/comictaggerlib/main.py b/comictaggerlib/main.py index ffc7101..3534384 100644 --- a/comictaggerlib/main.py +++ b/comictaggerlib/main.py @@ -252,7 +252,7 @@ class App: # config already loaded error = None - if self.config[0].General__disable_cr: + if self.config[0].Metadata_Options__disable_cr: if "cr" in comicapi.comicarchive.metadata_styles: del comicapi.comicarchive.metadata_styles["cr"] diff --git a/comictaggerlib/settingswindow.py b/comictaggerlib/settingswindow.py index 7c8d136..5cd9bf7 100644 --- a/comictaggerlib/settingswindow.py +++ b/comictaggerlib/settingswindow.py @@ -30,7 +30,8 @@ from PyQt5 import QtCore, QtGui, QtWidgets, uic import comictaggerlib.ui.talkeruigenerator from comicapi import utils from comicapi.archivers.archiver import Archiver -from comicapi.genericmetadata import md_test +# TODO OverlayMode changed to merge +from comicapi.genericmetadata import OverlayMode, md_test from comictaggerlib import ctsettings from comictaggerlib.ctsettings import ct_ns from comictaggerlib.ctsettings.plugin import group_for_plugin @@ -194,6 +195,10 @@ class SettingsWindow(QtWidgets.QDialog): ) self.cbFilenameParser.clear() self.cbFilenameParser.addItems(utils.Parser) + # TODO check + for mode in OverlayMode: + self.cbxOverlayReadStyle.addItem(mode.name.capitalize().replace("_", " "), mode.value) + self.cbxOverlaySource.addItem(mode.name.capitalize().replace("_", " "), mode.value) self.connect_signals() self.settings_to_form() self.rename_test() @@ -399,7 +404,6 @@ class SettingsWindow(QtWidgets.QDialog): self.tePublisherFilter.setPlainText("\n".join(self.config[0].Issue_Identifier__publisher_filter)) self.cbxCheckForNewVersion.setChecked(self.config[0].General__check_for_new_version) - self.cbxShortMetadataNames.setChecked(self.config[0].General__use_short_metadata_names) self.cbFilenameParser.setCurrentText(self.config[0].Filename_Parsing__filename_parser) self.cbxRemoveC2C.setChecked(self.config[0].Filename_Parsing__remove_c2c) @@ -417,17 +421,25 @@ class SettingsWindow(QtWidgets.QDialog): self.cbxExactMatches.setChecked(self.config[0].Issue_Identifier__exact_series_matches_first) self.cbxClearFormBeforePopulating.setChecked(self.config[0].Issue_Identifier__clear_metadata) - self.cbxAssumeLoneCreditIsPrimary.setChecked(self.config[0].Comic_Book_Lover__assume_lone_credit_is_primary) - self.cbxCopyCharactersToTags.setChecked(self.config[0].Comic_Book_Lover__copy_characters_to_tags) - self.cbxCopyTeamsToTags.setChecked(self.config[0].Comic_Book_Lover__copy_teams_to_tags) - self.cbxCopyLocationsToTags.setChecked(self.config[0].Comic_Book_Lover__copy_locations_to_tags) - self.cbxCopyStoryArcsToTags.setChecked(self.config[0].Comic_Book_Lover__copy_storyarcs_to_tags) - self.cbxCopyNotesToComments.setChecked(self.config[0].Comic_Book_Lover__copy_notes_to_comments) - self.cbxCopyWebLinkToComments.setChecked(self.config[0].Comic_Book_Lover__copy_weblink_to_comments) - self.cbxApplyCBLTransformOnCVIMport.setChecked(self.config[0].Comic_Book_Lover__apply_transform_on_import) + self.cbxAssumeLoneCreditIsPrimary.setChecked(self.config[0].Metadata_Options__cbl_assume_lone_credit_is_primary) + self.cbxCopyCharactersToTags.setChecked(self.config[0].Metadata_Options__cbl_copy_characters_to_tags) + self.cbxCopyTeamsToTags.setChecked(self.config[0].Metadata_Options__cbl_copy_teams_to_tags) + self.cbxCopyLocationsToTags.setChecked(self.config[0].Metadata_Options__cbl_copy_locations_to_tags) + self.cbxCopyStoryArcsToTags.setChecked(self.config[0].Metadata_Options__cbl_copy_storyarcs_to_tags) + self.cbxCopyNotesToComments.setChecked(self.config[0].Metadata_Options__cbl_copy_notes_to_comments) + self.cbxCopyWebLinkToComments.setChecked(self.config[0].Metadata_Options__cbl_copy_weblink_to_comments) + self.cbxApplyCBLTransformOnCVIMport.setChecked(self.config[0].Metadata_Options__cbl_apply_transform_on_import) self.cbxApplyCBLTransformOnBatchOperation.setChecked( - self.config[0].Comic_Book_Lover__apply_transform_on_bulk_operation + self.config[0].Metadata_Options__cbl_apply_transform_on_bulk_operation ) + self.cbxOverlayReadStyle.setCurrentIndex( + self.cbxOverlayReadStyle.findData(self.config[0].Metadata_Options__read_style_overlay.value) + ) + self.cbxOverlaySource.setCurrentIndex( + self.cbxOverlaySource.findData(self.config[0].Metadata_Options__source_overlay.value) + ) + self.cbxShortMetadataNames.setChecked(self.config[0].Metadata_Options__use_short_metadata_names) + self.cbxDisableCR.setChecked(self.config[0].Metadata_Options__disable_cr) self.leRenameTemplate.setText(self.config[0].File_Rename__template) self.leIssueNumPadding.setText(str(self.config[0].File_Rename__issue_number_padding)) @@ -544,18 +556,29 @@ class SettingsWindow(QtWidgets.QDialog): self.config[0].Issue_Identifier__exact_series_matches_first = self.cbxExactMatches.isChecked() self.config[0].Issue_Identifier__clear_metadata = self.cbxClearFormBeforePopulating.isChecked() - self.config[0].Comic_Book_Lover__assume_lone_credit_is_primary = self.cbxAssumeLoneCreditIsPrimary.isChecked() - self.config[0].Comic_Book_Lover__copy_characters_to_tags = self.cbxCopyCharactersToTags.isChecked() - self.config[0].Comic_Book_Lover__copy_teams_to_tags = self.cbxCopyTeamsToTags.isChecked() - self.config[0].Comic_Book_Lover__copy_locations_to_tags = self.cbxCopyLocationsToTags.isChecked() - self.config[0].Comic_Book_Lover__copy_storyarcs_to_tags = self.cbxCopyStoryArcsToTags.isChecked() - self.config[0].Comic_Book_Lover__copy_notes_to_comments = self.cbxCopyNotesToComments.isChecked() - self.config[0].Comic_Book_Lover__copy_weblink_to_comments = self.cbxCopyWebLinkToComments.isChecked() - self.config[0].Comic_Book_Lover__apply_transform_on_import = self.cbxApplyCBLTransformOnCVIMport.isChecked() - self.config.values.Comic_Book_Lover__apply_transform_on_bulk_operation = ( + self.config[0].Metadata_Options__cbl_assume_lone_credit_is_primary = ( + self.cbxAssumeLoneCreditIsPrimary.isChecked() + ) + self.config[0].Metadata_Options__cbl_copy_characters_to_tags = self.cbxCopyCharactersToTags.isChecked() + self.config[0].Metadata_Options__cbl_copy_teams_to_tags = self.cbxCopyTeamsToTags.isChecked() + self.config[0].Metadata_Options__cbl_copy_locations_to_tags = self.cbxCopyLocationsToTags.isChecked() + self.config[0].Metadata_Options__cbl_copy_storyarcs_to_tags = self.cbxCopyStoryArcsToTags.isChecked() + self.config[0].Metadata_Options__cbl_copy_notes_to_comments = self.cbxCopyNotesToComments.isChecked() + self.config[0].Metadata_Options__cbl_copy_weblink_to_comments = self.cbxCopyWebLinkToComments.isChecked() + self.config[0].Metadata_Options__cbl_apply_transform_on_import = self.cbxApplyCBLTransformOnCVIMport.isChecked() + self.config.values.Metadata_Options__cbl_apply_transform_on_bulk_operation = ( self.cbxApplyCBLTransformOnBatchOperation.isChecked() ) + self.config[0].Metadata_Options__read_style_overlay = OverlayMode[self.cbxOverlayReadStyle.currentData()] + self.config[0].Metadata_Options__source_overlay = OverlayMode[self.cbxOverlaySource.currentData()] + self.config[0].Metadata_Options__disable_cr = self.cbxDisableCR.isChecked() + # Update metadata style names if required + if self.config[0].Metadata_Options__use_short_metadata_names != self.cbxShortMetadataNames.isChecked(): + self.config[0].Metadata_Options__use_short_metadata_names = self.cbxShortMetadataNames.isChecked() + self.parent().populate_style_names() + self.parent().adjust_save_style_combo() + self.config[0].File_Rename__template = str(self.leRenameTemplate.text()) self.config[0].File_Rename__issue_number_padding = int(self.leIssueNumPadding.text()) self.config[0].File_Rename__use_smart_string_cleanup = self.cbxSmartCleanup.isChecked() diff --git a/comictaggerlib/taggerwindow.py b/comictaggerlib/taggerwindow.py index 790eba4..7651552 100644 --- a/comictaggerlib/taggerwindow.py +++ b/comictaggerlib/taggerwindow.py @@ -1431,7 +1431,7 @@ class TaggerWindow(QtWidgets.QMainWindow): self.cbLoadDataStyle.clear() # Add the entries to the tag style combobox for style in metadata_styles.values(): - if self.config[0].General__use_short_metadata_names: + if self.config[0].Metadata_Options__use_short_metadata_names: self.cbSaveDataStyle.addItem(style.short_name.upper(), style.short_name) self.cbLoadDataStyle.addItem(style.short_name.upper(), style.short_name) else: @@ -1701,7 +1701,7 @@ class TaggerWindow(QtWidgets.QMainWindow): center_window_on_parent(prog_dialog) QtCore.QCoreApplication.processEvents() - if style == "cbi" and self.config[0].Comic_Book_Lover__apply_transform_on_bulk_operation: + if style == "cbi" and self.config[0].Metadata_Options__cbl_apply_transform_on_bulk_operation: md = CBLTransformer(md, self.config[0]).apply() if ca.write_metadata(md, style): diff --git a/comictaggerlib/ui/settingswindow.ui b/comictaggerlib/ui/settingswindow.ui index d5a4af6..7423156 100644 --- a/comictaggerlib/ui/settingswindow.ui +++ b/comictaggerlib/ui/settingswindow.ui @@ -7,7 +7,7 @@ 0 0 703 - 574 + 597 @@ -28,7 +28,7 @@ - 0 + 4 @@ -48,43 +48,7 @@ - - - - Use the short name for the metadata styles (CBI, CR, etc.) - - - Use "short" names for metadata styles - - - - - - - - 0 - 0 - - - - Default Settings - - - - - - - - 0 - 0 - - - - Clear Cache - - - - + @@ -103,7 +67,7 @@ - + @@ -122,13 +86,39 @@ - + Prompts the user to confirm saving tags + + + + + 0 + 0 + + + + Default Settings + + + + + + + + 0 + 0 + + + + Clear Cache + + + @@ -427,19 +417,124 @@ - + - Metadata Types + Metadata Options - - - - Apply CBL Transforms on Batch Copy Operations to CBL Tags + + + + + 0 + 0 + + + + 0 + 0 + + + + General + + + + 6 + + + 6 + + + 6 + + + 6 + + + 6 + + + + + + 0 + 0 + + + + Use the short name for the metadata styles (CBI, CR, etc.) + + + Use "short" names for metadata styles + + + + + + + Useful if you use the CIX metadata type + + + Disable ComicRack Metadata Type + + + + - + + + + + 0 + 0 + + + + + 0 + 80 + + + + CBL Options + + + + 6 + + + 6 + + + 6 + + + 6 + + + 6 + + + + + Apply CBL Transforms on ComicVine Import + + + + + + + Apply CBL Transforms on Batch Copy Operations to CBL Tags + + + + + + + @@ -450,99 +545,347 @@ CBL Transforms - - - - 11 - 21 - 251 - 206 - + + + 6 - - - - - Copy Locations to Generic Tags - - - - - - - Assume Lone Credit Is Primary - - - - - - - Copy Characters to Generic Tags - - - - - - - Copy Teams to Generic Tags - - - - - - - Copy Notes to Comments - - - - - - - Copy Web Link to Comments - - - - - - - Copy Story Arcs to Generic Tags - - - - - + + 6 + + + 6 + + + 6 + + + 6 + + + + + Copy Notes to Comments + + + + + + + Copy Web Link to Comments + + + + + + + Copy Locations to Generic Tags + + + + + + + Assume Lone Credit Is Primary + + + + + + + Copy Story Arcs to Generic Tags + + + + + + + Copy Characters to Generic Tags + + + + + + + Copy Teams to Generic Tags + + + + - + + + + + 0 + 165 + + + + Overlay + + + + 6 + + + 6 + + + 6 + + + 6 + + + 6 + + + + + The operation to perform when overlaying source data (Comic Vine, Metron, GCD, etc.) + + + Data Source + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + 6 + + + + + + + The operation to perform when overlaying read styles (ComicRack, ComicBookInfo, etc.) + + + Read Style + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + 6 + + + + + + + + 0 + 0 + + + + + 16777215 + 16777215 + + + + The operation to perform when overlaying source data (Comic Vine, Metron, GCD, etc.) + + + + + + + + 0 + 0 + + + + + 16777215 + 200 + + + + QTabWidget::North + + + 0 + + + true + + + false + + + + + 16777215 + 16777215 + + + + Overlay + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 1 + 1 + + + + false + + + Read Style: Overlays all the none empty values of the read style metadata on top of the current metadata (i.e. file name parsing, other read style(s)). +Data Source: Overlays all the none empty values from the data source (e.g. Comic Vine) on top of the current metadata (i.e. file name parsing, read style(s)). + +Example: +(Series=batman, Issue=1, Tags=[batman,joker,robin]) ++ (Series=Batman, Publisher=DC Comics, Tags=[mystery,action]) += (Series=Batman, Issue=1, Publisher=DC Comics, Tags=[mystery,action]) + + + Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse + + + + + + + + Add Missing + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Read Style: Adds any metadata that is present in the read style but is missing in the current metadata (i.e. file name parsing, other read style(s)). +Data Source: Add any metadata that is present in the data source (e.g. Comic Vine) but is missing in the current metadata (i.e. file name parsing, read style(s)). + +Example: +(Series=batman, Issue=1, Tags=[batman,joker,robin]) ++ (Series=Superman, Issue=10, Publisher=DC Comics, Tags=[superman,lois lane,lex luthor]) += (Series=batman, Issue=1, Publisher=DC Comics, Tags=[batman,joker,robin]) + + + Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse + + + + + + + + Combine + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Read Style: Same as Overlay except lists (tags, characters, genres, credits, etc.) are combined with the current metadata (i.e. file name parsing, other read style(s)). +Data Source: Same as Overlay except lists (tags, characters, genres, credits, etc.) are combined with the current metadata (i.e. file name parsing, read style(s)). + +Example: +(Series=batman, Issue=1, Tags=[batman,joker,robin]) ++ (Series=Batman, Publisher=DC Comics, Tags=[mystery,action]) += (Series=Batman, Issue=1, Publisher=DC Comics, Tags=[batman,joker,robin,mystery,action]) + + + Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse + + + + + + + + + + + true + + + + 0 + 0 + + + + + 16777215 + 16777215 + + + + The operation to perform when overlaying read styles (ComicRack, ComicBookInfo, etc.) + + + + + + + Qt::Vertical - - QSizePolicy::Fixed - 20 - 10 + 40 - - - - Apply CBL Transforms on ComicVine Import - - - - - - - Disable ComicRack Metadata Type (useful if you use the CIX metadata type) - - - diff --git a/tests/genericmetadata_test.py b/tests/genericmetadata_test.py index 38602ad..cbf32ff 100644 --- a/tests/genericmetadata_test.py +++ b/tests/genericmetadata_test.py @@ -5,6 +5,7 @@ import textwrap import pytest import comicapi.genericmetadata +from comicapi.genericmetadata import OverlayMode, parse_url from testing.comicdata import credits, metadata @@ -24,6 +25,107 @@ def test_metadata_overlay(md: comicapi.genericmetadata.GenericMetadata, replaced assert md == expected +def test_metadata_overlay_add_missing(): + md = comicapi.genericmetadata.GenericMetadata(series="test", issue="1", title="test", genres={"test", "test2"}) + add = comicapi.genericmetadata.GenericMetadata( + series="test2", issue="2", title="test2", genres={"test3", "test4"}, issue_count=5 + ) + expected = comicapi.genericmetadata.GenericMetadata( + series="test", issue="1", title="test", genres={"test", "test2"}, issue_count=5 + ) + md.overlay(add, OverlayMode.add_missing) + + assert md == expected + + +def test_metadata_overlay_combine(): + md = comicapi.genericmetadata.GenericMetadata( + series="test", + issue="1", + title="test", + genres={"test", "test2"}, + story_arcs=["arc1"], + characters={"Bob", "fred"}, + web_links=[parse_url("https://my.comics.here.com")], + ) + combine_new = comicapi.genericmetadata.GenericMetadata( + series="test2", + title="test2", + genres={"test2", "test3", "test4"}, + story_arcs=["arc1", "arc2"], + characters={"bob", "fred"}, + ) + expected = comicapi.genericmetadata.GenericMetadata( + series="test2", + issue="1", + title="test2", + genres={"test", "test2", "test3", "test4"}, + story_arcs=["arc1", "arc2"], + characters={"bob", "fred"}, + web_links=[parse_url("https://my.comics.here.com")], + ) + md.overlay(combine_new, OverlayMode.combine) + + assert md == expected + + +def test_assign_dedupe_set(): + md_cur = comicapi.genericmetadata.GenericMetadata(characters={"Macintosh", "Søren Kierkegaard", "Barry"}) + md_new = comicapi.genericmetadata.GenericMetadata(characters={"MacIntosh", "Soren Kierkegaard"}) + # Expect a failure to normalise with NFKD and 'ø' + expected = comicapi.genericmetadata.GenericMetadata( + characters={"MacIntosh", "Soren Kierkegaard", "Søren Kierkegaard", "Barry"} + ) + + md_cur.overlay(md_new, OverlayMode.combine) + + assert md_cur == expected + + +def test_assign_dedupe_list(): + md_cur = comicapi.genericmetadata.GenericMetadata(story_arcs=["arc 1", "arc2", "arc 3"]) + md_new = comicapi.genericmetadata.GenericMetadata(story_arcs=["Arc 1", "Arc2"]) + expected = comicapi.genericmetadata.GenericMetadata(story_arcs=["Arc 1", "Arc2", "arc 3"]) + + md_cur.overlay(md_new, OverlayMode.combine) + + assert md_cur == expected + + +def test_assign_credits_overlay(): + md = comicapi.genericmetadata.GenericMetadata() + md.add_credit(person="test", role="writer", primary=False) + md.add_credit(person="test", role="artist", primary=True) + + md_new = comicapi.genericmetadata.GenericMetadata() + md_new.add_credit(person="", role="writer") + md_new.add_credit(person="test2", role="inker") + + expected = comicapi.genericmetadata.GenericMetadata() + expected.add_credit(person="test", role="writer", primary=False) + expected.add_credit(person="test", role="artist", primary=True) + expected.add_credit(person="test2", role="inker") + + assert md.assign_credits_overlay(md.credits, md_new.credits) == expected.credits + + +def test_assign_credits_add_missing(): + md = comicapi.genericmetadata.GenericMetadata() + md.add_credit(person="test", role="writer", primary=False) + md.add_credit(person="test", role="artist", primary=True) + + md_new = comicapi.genericmetadata.GenericMetadata() + md_new.add_credit(person="Bob", role="writer") + md_new.add_credit(person="test", role="artist", primary=True) + + expected = comicapi.genericmetadata.GenericMetadata() + expected.add_credit(person="Bob", role="writer") + expected.add_credit(person="test", role="artist", primary=True) + expected.add_credit(person="test", role="writer", primary=False) + + assert md.assign_credits_add_missing(md.credits, md_new.credits) == expected.credits + + def test_add_credit(): md = comicapi.genericmetadata.GenericMetadata()