diff --git a/comictaggerlib/autotagmatchwindow.py b/comictaggerlib/autotagmatchwindow.py index fe62cbe..0b0acb7 100644 --- a/comictaggerlib/autotagmatchwindow.py +++ b/comictaggerlib/autotagmatchwindow.py @@ -39,7 +39,7 @@ class AutoTagMatchWindow(QtWidgets.QDialog): self, parent: QtWidgets.QWidget, match_set_list: list[Result], - styles: list[str], + load_styles: list[str], fetch_func: Callable[[IssueResult], GenericMetadata], config: ct_ns, talker: ComicTalker, @@ -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._styles = styles + self.load_data_styles = load_styles self.fetch_func = fetch_func self.current_match_set_idx = 0 @@ -229,8 +229,17 @@ class AutoTagMatchWindow(QtWidgets.QDialog): def save_match(self) -> None: match = self.current_match() ca = ComicArchive(self.current_match_set.original_path) + md, error = self.parent().overlay_ca_read_style(self.load_data_styles, ca) + if error is not None: + logger.error("Failed to load metadata for %s: %s", ca.path, error) + QtWidgets.QApplication.restoreOverrideCursor() + QtWidgets.QMessageBox.critical( + self, + "Read Failed!", + f"One or more of the read styles failed to load for {ca.path}, check log for details", + ) + return - md = ca.read_metadata(self.config.internal__load_data_style) if md.is_empty: md = ca.metadata_from_filename( self.config.Filename_Parsing__filename_parser, @@ -254,7 +263,7 @@ class AutoTagMatchWindow(QtWidgets.QDialog): QtWidgets.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.CursorShape.WaitCursor)) md.overlay(ct_md) - for style in self._styles: + for style in self.load_data_styles: success = ca.write_metadata(md, style) QtWidgets.QApplication.restoreOverrideCursor() if not success: @@ -265,4 +274,4 @@ class AutoTagMatchWindow(QtWidgets.QDialog): ) break - ca.load_cache(list(metadata_styles)) + ca.reset_cache() diff --git a/comictaggerlib/cli.py b/comictaggerlib/cli.py index fe385a6..7b7dee4 100644 --- a/comictaggerlib/cli.py +++ b/comictaggerlib/cli.py @@ -134,7 +134,7 @@ class CLI: def actual_metadata_save(self, ca: ComicArchive, md: GenericMetadata) -> bool: if not self.config.Runtime_Options__dryrun: - for style in self.config.Runtime_Options__type: + for style in self.config.Runtime_Options__type_modify: # write out the new data if not ca.write_metadata(md, style): logger.error("The tag save seemed to fail for style: %s!", md_styles[style].name()) @@ -249,12 +249,11 @@ class CLI: md.overlay(f_md) - for style in self.config.Runtime_Options__type: + for style in self.config.Runtime_Options__type_read: if ca.has_metadata(style): try: t_md = ca.read_metadata(style) md.overlay(t_md) - break except Exception as e: logger.error("Failed to load metadata for %s: %s", ca.path, e) @@ -264,7 +263,7 @@ class CLI: return md def print(self, ca: ComicArchive) -> Result: - if not self.config.Runtime_Options__type: + if not self.config.Runtime_Options__type_read: page_count = ca.get_number_of_pages() brief = "" @@ -290,7 +289,7 @@ class CLI: md = None for style, style_obj in md_styles.items(): - if not self.config.Runtime_Options__type or style in self.config.Runtime_Options__type: + if not self.config.Runtime_Options__type_read or style in self.config.Runtime_Options__type_read: if ca.has_metadata(style): self.output(f"--------- {style_obj.name()} tags ---------") try: @@ -322,7 +321,7 @@ class CLI: def delete(self, ca: ComicArchive) -> Result: res = Result(Action.delete, Status.success, ca.path) - for style in self.config.Runtime_Options__type: + for style in self.config.Runtime_Options__type_modify: status = self.delete_style(ca, style) if status == Status.success: res.tags_deleted.append(style) @@ -367,7 +366,9 @@ class CLI: except Exception as e: logger.error("Failed to load metadata for %s: %s", ca.path, e) return res - for style in self.config.Runtime_Options__type: + for style in self.config.Runtime_Options__type_modify: + if style == src_style_name: + continue status = self.copy_style(ca, res.md, style) if status == Status.success: res.tags_written.append(style) @@ -377,7 +378,7 @@ class CLI: def save(self, ca: ComicArchive, match_results: OnlineMatchResults) -> tuple[Result, OnlineMatchResults]: if not self.config.Runtime_Options__overwrite: - for style in self.config.Runtime_Options__type: + for style in self.config.Runtime_Options__type_modify: if ca.has_metadata(style): self.output(f"{ca.path}: Already has {md_styles[style].name()} tags. Not overwriting.") return ( @@ -385,7 +386,7 @@ class CLI: Action.save, original_path=ca.path, status=Status.existing_tags, - tags_written=self.config.Runtime_Options__type, + tags_written=self.config.Runtime_Options__type_modify, ), match_results, ) @@ -413,7 +414,7 @@ class CLI: Action.save, original_path=ca.path, status=Status.fetch_data_failure, - tags_written=self.config.Runtime_Options__type, + tags_written=self.config.Runtime_Options__type_modify, ) match_results.fetch_data_failures.append(res) return res, match_results @@ -425,7 +426,7 @@ class CLI: status=Status.match_failure, original_path=ca.path, match_status=MatchStatus.no_match, - tags_written=self.config.Runtime_Options__type, + tags_written=self.config.Runtime_Options__type_modify, ) match_results.no_matches.append(res) return res, match_results @@ -438,7 +439,7 @@ class CLI: status=Status.match_failure, original_path=ca.path, match_status=MatchStatus.no_match, - tags_written=self.config.Runtime_Options__type, + tags_written=self.config.Runtime_Options__type_modify, ) match_results.no_matches.append(res) return res, match_results @@ -481,7 +482,7 @@ class CLI: original_path=ca.path, online_results=matches, match_status=MatchStatus.low_confidence_match, - tags_written=self.config.Runtime_Options__type, + tags_written=self.config.Runtime_Options__type_modify, ) match_results.low_confidence_matches.append(res) return res, match_results @@ -493,7 +494,7 @@ class CLI: original_path=ca.path, online_results=matches, match_status=MatchStatus.multiple_match, - tags_written=self.config.Runtime_Options__type, + tags_written=self.config.Runtime_Options__type_modify, ) match_results.multiple_matches.append(res) return res, match_results @@ -505,7 +506,7 @@ class CLI: original_path=ca.path, online_results=matches, match_status=MatchStatus.low_confidence_match, - tags_written=self.config.Runtime_Options__type, + tags_written=self.config.Runtime_Options__type_modify, ) match_results.low_confidence_matches.append(res) return res, match_results @@ -517,7 +518,7 @@ class CLI: original_path=ca.path, online_results=matches, match_status=MatchStatus.no_match, - tags_written=self.config.Runtime_Options__type, + tags_written=self.config.Runtime_Options__type_modify, ) match_results.no_matches.append(res) return res, match_results @@ -533,7 +534,7 @@ class CLI: original_path=ca.path, online_results=matches, match_status=MatchStatus.good_match, - tags_written=self.config.Runtime_Options__type, + tags_written=self.config.Runtime_Options__type_modify, ) match_results.fetch_data_failures.append(res) return res, match_results @@ -545,7 +546,7 @@ class CLI: online_results=matches, match_status=MatchStatus.good_match, md=prepare_metadata(md, ct_md, self.config), - tags_written=self.config.Runtime_Options__type, + tags_written=self.config.Runtime_Options__type_modify, ) assert res.md # ok, done building our metadata. time to save diff --git a/comictaggerlib/ctsettings/commandline.py b/comictaggerlib/ctsettings/commandline.py index 3920f3c..3884718 100644 --- a/comictaggerlib/ctsettings/commandline.py +++ b/comictaggerlib/ctsettings/commandline.py @@ -160,14 +160,21 @@ def register_runtime(parser: settngs.Manager) -> None: parser.add_setting( "--json", "-j", action="store_true", help="Output json on stdout. Ignored in interactive mode.", file=False ) - parser.add_setting( - "-t", - "--type", + "--type-modify", metavar=f"{{{','.join(metadata_styles).upper()}}}", default=[], type=metadata_type, - help="""Specify TYPE as either CR, CBL or COMET\n(as either ComicRack, ComicBookLover,\nor CoMet style tags, respectively).\nUse commas for multiple types.\nFor searching the metadata will use the first listed:\neg '-t cbl,cr' with no CBL tags, CR will be used if they exist\n\n""", + help="""Specify the type of tags to write.\nUse commas for multiple types.\nRead types will be used if unspecified\nSee --list-plugins for the available types.\n\n""", + file=False, + ) + parser.add_setting( + "-t", + "--type-read", + metavar=f"{{{','.join(metadata_styles).upper()}}}", + default=[], + type=metadata_type, + help="""Specify the type of tags to read.\nUse commas for multiple types.\nSee --list-plugins for the available types.\nThe tag use will be 'overlayed' in order:\ne.g. '-t cbl,cr' with no CBL tags, CR will be used if they exist and CR will overwrite any shared CBL tags.\n\n""", file=False, ) parser.add_setting( @@ -190,7 +197,7 @@ def register_commands(parser: settngs.Manager) -> None: dest="command", action="store_const", const=Action.print, - help="""Print out tag info from file. Specify type\n(via -t) to get only info of that tag type.\n\n""", + help="""Print out tag info from file. Specify type\n(via --type-read) to get only info of that tag type.\n\n""", file=False, ) parser.add_setting( @@ -199,7 +206,7 @@ def register_commands(parser: settngs.Manager) -> None: dest="command", action="store_const", const=Action.delete, - help="Deletes the tag block of specified type (via -t).\n", + help="Deletes the tag block of specified type (via --type-modify).\n", file=False, ) parser.add_setting( @@ -207,7 +214,7 @@ def register_commands(parser: settngs.Manager) -> None: "--copy", type=metadata_type_single, metavar=f"{{{','.join(metadata_styles).upper()}}}", - help="Copy the specified source tag block to\ndestination style specified via -t\n(potentially lossy operation).\n\n", + help="Copy the specified source tag block to\ndestination style specified via --type-modify\n(potentially lossy operation).\n\n", file=False, ) parser.add_setting( @@ -216,7 +223,7 @@ def register_commands(parser: settngs.Manager) -> None: dest="command", action="store_const", const=Action.save, - help="Save out tags as specified type (via -t).\nMust specify also at least -o, -f, or -m.\n\n", + help="Save out tags as specified type (via --type-modify).\nMust specify also at least -o, -f, or -m.\n\n", file=False, ) parser.add_setting( @@ -284,6 +291,9 @@ def validate_commandline_settings(config: settngs.Config[ct_ns], parser: settngs if config[0].Runtime_Options__json and config[0].Runtime_Options__interactive: config[0].Runtime_Options__json = False + if config[0].Runtime_Options__type_read and not config[0].Runtime_Options__type_modify: + config[0].Runtime_Options__type_modify = config[0].Runtime_Options__type_read + if ( config[0].Commands__command not in (Action.save_config, Action.list_plugins) and config[0].Runtime_Options__no_gui @@ -291,16 +301,16 @@ def validate_commandline_settings(config: settngs.Config[ct_ns], parser: settngs ): parser.exit(message="Command requires at least one filename!\n", status=1) - if config[0].Commands__command == Action.delete and not config[0].Runtime_Options__type: - parser.exit(message="Please specify the type to delete with -t\n", status=1) + if config[0].Commands__command == Action.delete and not config[0].Runtime_Options__type_modify: + parser.exit(message="Please specify the type to delete with --type-modify\n", status=1) - if config[0].Commands__command == Action.save and not config[0].Runtime_Options__type: - parser.exit(message="Please specify the type to save with -t\n", status=1) + if config[0].Commands__command == Action.save and not config[0].Runtime_Options__type_modify: + parser.exit(message="Please specify the type to save with --type-modify\n", status=1) if config[0].Commands__copy: config[0].Commands__command = Action.copy - if not config[0].Runtime_Options__type: - parser.exit(message="Please specify the type to copy to with -t\n", status=1) + if not config[0].Runtime_Options__type_modify: + parser.exit(message="Please specify the type to copy to with --type-modify\n", status=1) if config[0].Runtime_Options__recursive: config[0].Runtime_Options__files = utils.get_recursive_filelist(config[0].Runtime_Options__files) diff --git a/comictaggerlib/ctsettings/file.py b/comictaggerlib/ctsettings/file.py index d1bb276..a6066e6 100644 --- a/comictaggerlib/ctsettings/file.py +++ b/comictaggerlib/ctsettings/file.py @@ -32,7 +32,7 @@ def internal(parser: settngs.Manager) -> None: # automatic settings parser.add_setting("install_id", default=uuid.uuid4().hex, cmdline=False) parser.add_setting("save_data_style", default=["cbi"], cmdline=False) - parser.add_setting("load_data_style", default="cbi", cmdline=False) + parser.add_setting("load_data_style", default=["cbi"], cmdline=False) parser.add_setting("last_opened_folder", default="", cmdline=False) parser.add_setting("window_width", default=0, cmdline=False) parser.add_setting("window_height", default=0, cmdline=False) @@ -279,6 +279,15 @@ def migrate_settings(config: settngs.Config[ct_ns]) -> settngs.Config[ct_ns]: else: config[0].internal__save_data_style = ["cbi"] + load_style = config[0].internal__load_data_style + if not isinstance(load_style, list): + if isinstance(load_style, int) and load_style in (0, 1, 2): + config[0].internal__load_data_style = [original_types[load_style]] + elif isinstance(load_style, str): + config[0].internal__load_data_style = [load_style] + else: + config[0].internal__load_data_style = ["cbi"] + return config diff --git a/comictaggerlib/ctsettings/settngs_namespace.py b/comictaggerlib/ctsettings/settngs_namespace.py index 5f1aaf8..a0677fb 100644 --- a/comictaggerlib/ctsettings/settngs_namespace.py +++ b/comictaggerlib/ctsettings/settngs_namespace.py @@ -34,14 +34,15 @@ class SettngsNS(settngs.TypedNS): Runtime_Options__glob: bool Runtime_Options__quiet: bool Runtime_Options__json: bool - Runtime_Options__type: list[str] + Runtime_Options__type_modify: list[str] + Runtime_Options__type_read: list[str] Runtime_Options__overwrite: bool Runtime_Options__no_gui: bool Runtime_Options__files: list[str] internal__install_id: str internal__save_data_style: list[str] - internal__load_data_style: str + internal__load_data_style: list[str] internal__last_opened_folder: str internal__window_width: int internal__window_height: int @@ -149,7 +150,7 @@ class Runtime_Options(typing.TypedDict): class internal(typing.TypedDict): install_id: str save_data_style: list[str] - load_data_style: str + load_data_style: list[str] last_opened_folder: str window_width: int window_height: int diff --git a/comictaggerlib/graphics/down.png b/comictaggerlib/graphics/down.png new file mode 100644 index 0000000..bc6ee2d Binary files /dev/null and b/comictaggerlib/graphics/down.png differ diff --git a/comictaggerlib/graphics/up.png b/comictaggerlib/graphics/up.png new file mode 100644 index 0000000..5f4a1e2 Binary files /dev/null and b/comictaggerlib/graphics/up.png differ diff --git a/comictaggerlib/renamewindow.py b/comictaggerlib/renamewindow.py index ae7efdf..62e6be0 100644 --- a/comictaggerlib/renamewindow.py +++ b/comictaggerlib/renamewindow.py @@ -39,7 +39,7 @@ class RenameWindow(QtWidgets.QDialog): self, parent: QtWidgets.QWidget, comic_archive_list: list[ComicArchive], - data_style: str, + load_data_styles: list[str], config: settngs.Config[ct_ns], talkers: dict[str, ComicTalker], ) -> None: @@ -48,7 +48,9 @@ class RenameWindow(QtWidgets.QDialog): with (ui_path / "renamewindow.ui").open(encoding="utf-8") as uifile: uic.loadUi(uifile, self) - self.label.setText(f"Preview (based on {metadata_styles[data_style].name()} tags):") + self.label.setText( + f"Preview (based on {', '.join(metadata_styles[style].name() for style in load_data_styles)} tags):" + ) self.setWindowFlags( QtCore.Qt.WindowType( @@ -61,7 +63,7 @@ class RenameWindow(QtWidgets.QDialog): self.config = config self.talkers = talkers self.comic_archive_list = comic_archive_list - self.data_style = data_style + self.load_data_styles = load_data_styles self.rename_list: list[str] = [] self.btnSettings.clicked.connect(self.modify_settings) @@ -70,7 +72,7 @@ class RenameWindow(QtWidgets.QDialog): self.do_preview() - def config_renamer(self, ca: ComicArchive, md: GenericMetadata | None = None) -> str: + def config_renamer(self, ca: ComicArchive, md: GenericMetadata = GenericMetadata()) -> str: self.renamer.set_template(self.config[0].File_Rename__template) self.renamer.set_issue_zero_padding(self.config[0].File_Rename__issue_number_padding) self.renamer.set_smart_cleanup(self.config[0].File_Rename__use_smart_string_cleanup) @@ -82,7 +84,15 @@ class RenameWindow(QtWidgets.QDialog): new_ext = ca.extension() if md is None or md.is_empty: - md = ca.read_metadata(self.data_style) + md, error = self.parent().overlay_ca_read_style(self.load_data_styles, ca) + if error is not None: + logger.error("Failed to load metadata for %s: %s", ca.path, error) + QtWidgets.QMessageBox.warning( + self, + "Read Failed!", + f"One or more of the read styles failed to load for {ca.path}, check log for details", + ) + if md.is_empty: md = ca.metadata_from_filename( self.config[0].Filename_Parsing__filename_parser, diff --git a/comictaggerlib/settingswindow.py b/comictaggerlib/settingswindow.py index dc2d2bc..7c8d136 100644 --- a/comictaggerlib/settingswindow.py +++ b/comictaggerlib/settingswindow.py @@ -523,6 +523,7 @@ class SettingsWindow(QtWidgets.QDialog): if self.cbxShortMetadataNames.isChecked() != self.config[0].General__use_short_metadata_names: self.config[0].General__use_short_metadata_names = self.cbxShortMetadataNames.isChecked() self.parent().populate_style_names() + self.parent().adjust_load_style_combo() self.parent().adjust_save_style_combo() self.config[0].Issue_Identifier__series_match_identify_thresh = self.sbNameMatchIdentifyThresh.value() diff --git a/comictaggerlib/taggerwindow.py b/comictaggerlib/taggerwindow.py index b9d3faa..790eba4 100644 --- a/comictaggerlib/taggerwindow.py +++ b/comictaggerlib/taggerwindow.py @@ -211,18 +211,20 @@ class TaggerWindow(QtWidgets.QMainWindow): self.setWindowIcon(QtGui.QIcon(str(graphics_path / "app.png"))) - if config[0].Runtime_Options__type and isinstance(config[0].Runtime_Options__type[0], str): - # respect the command line option tag type - config[0].internal__save_data_style = config[0].Runtime_Options__type - config[0].internal__load_data_style = config[0].Runtime_Options__type[0] + # respect the command line option tag type + if config[0].Runtime_Options__type_modify: + config[0].internal__save_data_style = config[0].Runtime_Options__type_modify + if config[0].Runtime_Options__type_read: + config[0].internal__load_data_style = config[0].Runtime_Options__type_read for style in config[0].internal__save_data_style: if style not in metadata_styles: config[0].internal__save_data_style.remove(style) - if config[0].internal__load_data_style not in metadata_styles: - config[0].internal__load_data_style = list(metadata_styles.keys())[0] + for style in config[0].internal__load_data_style: + if style not in metadata_styles: + config[0].internal__load_data_style.remove(style) self.save_data_styles: list[str] = config[0].internal__save_data_style - self.load_data_style: str = config[0].internal__load_data_style + self.load_data_styles: list[str] = config[0].internal__load_data_style self.setAcceptDrops(True) self.view_tag_actions, self.remove_tag_actions = self.tag_actions() @@ -271,7 +273,7 @@ class TaggerWindow(QtWidgets.QMainWindow): self.cbMaturityRating.lineEdit().setAcceptDrops(False) # hook up the callbacks - self.cbLoadDataStyle.currentIndexChanged.connect(self.set_load_data_style) + self.cbLoadDataStyle.dropdownClosed.connect(self.set_load_data_style) self.cbSaveDataStyle.itemChecked.connect(self.set_save_data_style) self.cbx_sources.currentIndexChanged.connect(self.set_source) self.btnEditCredit.clicked.connect(self.edit_credit) @@ -433,7 +435,7 @@ class TaggerWindow(QtWidgets.QMainWindow): self.actionAutoTag.triggered.connect(self.auto_tag) self.actionCopyTags.setShortcut("Ctrl+C") - self.actionCopyTags.setStatusTip("Copy one tag style to another") + self.actionCopyTags.setStatusTip("Copy one tag style tags to enabled modify style(s)") self.actionCopyTags.triggered.connect(self.copy_tags) self.actionRemoveAuto.setShortcut("Ctrl+D") @@ -1188,7 +1190,7 @@ class TaggerWindow(QtWidgets.QMainWindow): failed_style = metadata_styles[style].name() break - self.comic_archive.load_cache(list(metadata_styles)) + self.comic_archive.load_cache(set(metadata_styles)) QtWidgets.QApplication.restoreOverrideCursor() if failed_style: @@ -1201,26 +1203,37 @@ class TaggerWindow(QtWidgets.QMainWindow): self.clear_dirty_flag() self.update_info_box() self.update_menus() - self.fileSelectionList.update_current_row() - self.metadata = self.comic_archive.read_metadata(self.load_data_style) + # Only try to read if write was successful + self.metadata, error = self.overlay_ca_read_style(self.load_data_styles, self.comic_archive) + if error is not None: + QtWidgets.QMessageBox.warning( + self, + "Read Failed!", + f"One or more of the read styles failed to load for {self.comic_archive.path}, check log for details", + ) + logger.error("Failed to load metadata for %s: %s", self.ca.path, error) + + self.fileSelectionList.update_current_row() self.update_ui_for_archive() else: QtWidgets.QMessageBox.information(self, "Whoops!", "No data to commit!") - def set_load_data_style(self, s: str) -> None: + def set_load_data_style(self, load_data_styles: list[str]) -> None: + """Should only be called from the combobox signal""" if self.dirty_flag_verification( - "Change Tag Read Style", "If you change read tag style now, data in the form will be lost. Are you sure?" + "Change Tag Read Style", + "If you change read tag style(s) now, data in the form will be lost. Are you sure?", ): - self.load_data_style = self.cbLoadDataStyle.itemData(s) - self.config[0].internal__load_data_style = self.load_data_style + self.load_data_styles = list(reversed(load_data_styles)) + self.config[0].internal__load_data_style = self.load_data_styles self.update_menus() if self.comic_archive is not None: self.load_archive(self.comic_archive) else: - self.cbLoadDataStyle.currentIndexChanged.disconnect(self.set_load_data_style) + self.cbLoadDataStyle.itemChanged.disconnect() self.adjust_load_style_combo() - self.cbLoadDataStyle.currentIndexChanged.connect(self.set_load_data_style) + self.cbLoadDataStyle.itemChanged.connect(self.set_load_data_style) def set_save_data_style(self) -> None: self.save_data_styles = self.cbSaveDataStyle.currentData() @@ -1392,28 +1405,38 @@ class TaggerWindow(QtWidgets.QMainWindow): self.cbx_sources.setCurrentIndex(self.cbx_sources.findData(self.config[0].Sources__source)) def adjust_load_style_combo(self) -> None: - # select the current style - self.cbLoadDataStyle.setCurrentIndex(self.cbLoadDataStyle.findData(self.load_data_style)) + """Select the enabled styles. Since metadata is merged in an overlay fashion the last item in the list takes priority. We reverse the order for display to the user""" + unchecked = set(metadata_styles.keys()) - set(self.load_data_styles) + for i, style in enumerate(reversed(self.load_data_styles)): + item_idx = self.cbLoadDataStyle.findData(style) + self.cbLoadDataStyle.setItemChecked(item_idx, True) + # Order matters, move items to list order + if item_idx != i: + self.cbLoadDataStyle.moveItem(item_idx, row=i) + for style in unchecked: + self.cbLoadDataStyle.setItemChecked(self.cbLoadDataStyle.findData(style), False) def adjust_save_style_combo(self) -> None: # select the current style unchecked = set(metadata_styles.keys()) - set(self.save_data_styles) for style in self.save_data_styles: - self.cbSaveDataStyle.setItemChecked(self.cbLoadDataStyle.findData(style), True) + self.cbSaveDataStyle.setItemChecked(self.cbSaveDataStyle.findData(style), True) for style in unchecked: - self.cbSaveDataStyle.setItemChecked(self.cbLoadDataStyle.findData(style), False) + self.cbSaveDataStyle.setItemChecked(self.cbSaveDataStyle.findData(style), False) self.update_metadata_style_tweaks() def populate_style_names(self) -> None: # First clear all entries (called from settingswindow.py) self.cbSaveDataStyle.clear() + self.cbLoadDataStyle.clear() # Add the entries to the tag style combobox for style in metadata_styles.values(): - self.cbLoadDataStyle.addItem(style.name(), style.short_name) if self.config[0].General__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: self.cbSaveDataStyle.addItem(style.name(), style.short_name) + self.cbLoadDataStyle.addItem(style.name(), style.short_name) def populate_combo_boxes(self) -> None: self.populate_style_names() @@ -1580,7 +1603,7 @@ class TaggerWindow(QtWidgets.QMainWindow): # Abandon any further tag removals to prevent any greater damage to archive break ca.reset_cache() - ca.load_cache(list(metadata_styles)) + ca.load_cache(set(metadata_styles)) progdialog.hide() QtCore.QCoreApplication.processEvents() @@ -1604,12 +1627,12 @@ class TaggerWindow(QtWidgets.QMainWindow): ca_list = self.fileSelectionList.get_selected_archive_list() has_src_count = 0 - src_style = self.load_data_style - dest_styles = self.save_data_styles + src_styles: list[str] = self.load_data_styles + dest_styles: list[str] = self.save_data_styles - # Remove the read style from the write style - if src_style in dest_styles: - dest_styles.remove(src_style) + if len(src_styles) == 1 and src_styles[0] in dest_styles: + # Remove the read style from the write style + dest_styles.remove(src_styles[0]) if not dest_styles: QtWidgets.QMessageBox.information( @@ -1618,12 +1641,16 @@ class TaggerWindow(QtWidgets.QMainWindow): return for ca in ca_list: - if ca.has_metadata(src_style): - has_src_count += 1 + for style in src_styles: + if ca.has_metadata(style): + has_src_count += 1 + continue if has_src_count == 0: QtWidgets.QMessageBox.information( - self, "Copy Tags", f"No archives with {metadata_styles[src_style].name()} tags selected!" + self, + "Copy Tags", + f"No archives with {', '.join([metadata_styles[style].name() for style in src_styles])} tags selected!", ) return @@ -1636,8 +1663,9 @@ class TaggerWindow(QtWidgets.QMainWindow): reply = QtWidgets.QMessageBox.question( self, "Copy Tags", - f"Are you sure you wish to copy the {metadata_styles[src_style].name()} " - f"tags to {', '.join([metadata_styles[style].name() for style in dest_styles])} tags in " + f"Are you sure you wish to copy the combined (with overlay order) tags of " + f"{', '.join([metadata_styles[style].name() for style in src_styles])} " + f"to {', '.join([metadata_styles[style].name() for style in dest_styles])} tags in " f"{has_src_count} archive(s)?", QtWidgets.QMessageBox.StandardButton.Yes, QtWidgets.QMessageBox.StandardButton.No, @@ -1655,10 +1683,11 @@ class TaggerWindow(QtWidgets.QMainWindow): success_count = 0 for prog_idx, ca in enumerate(ca_list, 1): ca_saved = False - - if ca.has_metadata(src_style) and ca.is_writable(): - md = ca.read_metadata(src_style) - else: + md, error = self.overlay_ca_read_style(src_styles, ca) + if error is not None: + failed_list.append(ca.path) + continue + if md.is_empty: continue for style in dest_styles: @@ -1683,7 +1712,7 @@ class TaggerWindow(QtWidgets.QMainWindow): failed_list.append(ca.path) ca.reset_cache() - ca.load_cache([self.load_data_style, *self.save_data_styles]) + ca.load_cache({*self.load_data_styles, *self.save_data_styles}) prog_dialog.hide() QtCore.QCoreApplication.processEvents() @@ -1727,11 +1756,16 @@ class TaggerWindow(QtWidgets.QMainWindow): ii = IssueIdentifier(ca, self.config[0], self.current_talker()) # read in metadata, and parse file name if not there - try: - md = ca.read_metadata(self.load_data_style) - except Exception as e: - md = GenericMetadata() - logger.error("Failed to load metadata for %s: %s", ca.path, e) + md, error = self.overlay_ca_read_style(self.load_data_styles, ca) + if error is not None: + QtWidgets.QMessageBox.warning( + self, + "Aborting...", + f"One or more of the read styles failed to load for {ca.path}. Aborting to prevent any possible further damage. Check log for details.", + ) + logger.error("Failed to load metadata for %s: %s", self.ca.path, error) + return False, match_results + if md.is_empty: md = ca.metadata_from_filename( self.config[0].Filename_Parsing__filename_parser, @@ -1888,7 +1922,7 @@ class TaggerWindow(QtWidgets.QMainWindow): match_results.write_failures.append(res) ca.reset_cache() - ca.load_cache([self.load_data_style] + self.save_data_styles) + ca.load_cache({*self.load_data_styles, *self.save_data_styles}) return success, match_results @@ -1937,7 +1971,7 @@ class TaggerWindow(QtWidgets.QMainWindow): self.auto_tag_log(f"Auto-Tagging {prog_idx} of {len(ca_list)}\n") self.auto_tag_log(f"{ca.path}\n") try: - cover_idx = ca.read_metadata(self.load_data_style).get_cover_page_index_list()[0] + cover_idx = ca.read_metadata(self.load_data_styles[0]).get_cover_page_index_list()[0] except Exception as e: cover_idx = 0 logger.error("Failed to load metadata for %s: %s", ca.path, e) @@ -2132,7 +2166,7 @@ class TaggerWindow(QtWidgets.QMainWindow): if self.dirty_flag_verification( "File Rename", "If you rename files now, unsaved data in the form will be lost. Are you sure?" ): - dlg = RenameWindow(self, ca_list, self.load_data_style, self.config, self.talkers) + dlg = RenameWindow(self, ca_list, self.load_data_styles, self.config, self.talkers) dlg.setModal(True) if dlg.exec() and self.comic_archive is not None: self.fileSelectionList.update_selected_rows() @@ -2151,15 +2185,28 @@ class TaggerWindow(QtWidgets.QMainWindow): self.config[0].internal__last_opened_folder = os.path.abspath(os.path.split(comic_archive.path)[0]) self.comic_archive = comic_archive - try: - self.metadata = self.comic_archive.read_metadata(self.load_data_style) - except Exception as e: - logger.error("Failed to load metadata for %s: %s", self.comic_archive.path, e) - self.exception(f"Failed to load metadata for {self.comic_archive.path}:\n\n{e}") - self.metadata = GenericMetadata() + + self.metadata, error = self.overlay_ca_read_style(self.load_data_styles, self.comic_archive) + if error is not None: + logger.error("Failed to load metadata for %s: %s", self.comic_archive.path, error) + self.exception(f"Failed to load metadata for {self.comic_archive.path}, see log for details\n\n") self.update_ui_for_archive() + def overlay_ca_read_style( + self, load_data_styles: list[str], ca: ComicArchive + ) -> tuple[GenericMetadata, Exception | None]: + md = GenericMetadata() + error = None + try: + for style in load_data_styles: + metadata = ca.read_metadata(style) + md.overlay(metadata) + except Exception as e: + error = e + + return md, error + def file_list_cleared(self) -> None: self.reset_app() diff --git a/comictaggerlib/ui/customwidgets.py b/comictaggerlib/ui/customwidgets.py index b4efabf..2401ccf 100644 --- a/comictaggerlib/ui/customwidgets.py +++ b/comictaggerlib/ui/customwidgets.py @@ -2,10 +2,21 @@ from __future__ import annotations +from enum import auto +from sys import platform from typing import Any from PyQt5 import QtGui, QtWidgets -from PyQt5.QtCore import QEvent, QRect, Qt, pyqtSignal +from PyQt5.QtCore import QEvent, QModelIndex, QPoint, QRect, QSize, Qt, pyqtSignal + +from comicapi.utils import StrEnum +from comictaggerlib.graphics import graphics_path + + +class ClickedButtonEnum(StrEnum): + up = auto() + down = auto() + main = auto() # Multiselect combobox from: https://gis.stackexchange.com/a/351152 (with custom changes) @@ -112,3 +123,309 @@ class CheckableComboBox(QtWidgets.QComboBox): self.setItemChecked(index, False) else: self.setItemChecked(index, True) + + +# Inspiration from https://github.com/marcel-goldschen-ohm/ModelViewPyQt and https://github.com/zxt50330/qitemdelegate-example +class ReadStyleItemDelegate(QtWidgets.QStyledItemDelegate): + buttonClicked = pyqtSignal(QModelIndex, ClickedButtonEnum) + + def __init__(self, parent: QtWidgets.QWidget): + super().__init__() + self.combobox = parent + + self.down_icon = QtGui.QImage(str(graphics_path / "down.png")) + self.up_icon = QtGui.QImage(str(graphics_path / "up.png")) + + self.button_width = self.down_icon.width() + self.button_padding = 5 + + # Tooltip messages + self.item_help: str = "" + self.up_help: str = "" + self.down_help: str = "" + + # Connect the signal to a slot in the delegate + self.combobox.itemClicked.connect(self.itemClicked) + + def paint(self, painter: QtGui.QPainter, option: QtWidgets.QStyleOptionViewItem, index: QModelIndex) -> None: + options = QtWidgets.QStyleOptionViewItem(option) + self.initStyleOption(options, index) + style = self.combobox.style() + + # Draw background with the same color as other widgets + palette = self.combobox.palette() + background_color = palette.color(QtGui.QPalette.Window) + painter.fillRect(options.rect, background_color) + + style.drawPrimitive(QtWidgets.QStyle.PE_PanelItemViewItem, options, painter, self.combobox) + + painter.save() + + # Checkbox drawing logic + checked = index.data(Qt.CheckStateRole) + opts = QtWidgets.QStyleOptionButton() + opts.state |= QtWidgets.QStyle.State_Active + opts.rect = self.getCheckBoxRect(options) + opts.state |= QtWidgets.QStyle.State_ReadOnly + if checked: + opts.state |= QtWidgets.QStyle.State_On + style.drawPrimitive( + QtWidgets.QStyle.PrimitiveElement.PE_IndicatorMenuCheckMark, opts, painter, self.combobox + ) + else: + opts.state |= QtWidgets.QStyle.State_Off + if platform != "darwin": + style.drawControl(QtWidgets.QStyle.CE_CheckBox, opts, painter, self.combobox) + + label = index.data(Qt.DisplayRole) + rectangle = options.rect + rectangle.setX(opts.rect.width() + 10) + painter.drawText(rectangle, Qt.AlignVCenter, label) + + # Draw buttons + if checked and (options.state & QtWidgets.QStyle.State_Selected): + up_rect = self._button_up_rect(options.rect) + down_rect = self._button_down_rect(options.rect) + + painter.drawImage(up_rect, self.up_icon) + painter.drawImage(down_rect, self.down_icon) + + painter.restore() + + def _button_up_rect(self, rect: QRect) -> QRect: + return QRect( + self.combobox.view().width() - (self.button_width * 2) - (self.button_padding * 2), + rect.top() + (rect.height() - self.button_width) // 2, + self.button_width, + self.button_width, + ) + + def _button_down_rect(self, rect: QRect = QRect(10, 1, 12, 12)) -> QRect: + return QRect( + self.combobox.view().width() - self.button_padding - self.button_width, + rect.top() + (rect.height() - self.button_width) // 2, + self.button_width, + self.button_width, + ) + + def getCheckBoxRect(self, option: QtWidgets.QStyleOptionViewItem) -> QRect: + # Get size of a standard checkbox. + opts = QtWidgets.QStyleOptionButton() + style = option.widget.style() + checkBoxRect = style.subElementRect(QtWidgets.QStyle.SE_CheckBoxIndicator, opts, None) + y = option.rect.y() + h = option.rect.height() + checkBoxTopLeftCorner = QPoint(5, int(y + h / 2 - checkBoxRect.height() / 2)) + + return QRect(checkBoxTopLeftCorner, checkBoxRect.size()) + + def itemClicked(self, index: QModelIndex, pos: QPoint) -> None: + item_rect = self.combobox.view().visualRect(index) + checked = index.data(Qt.CheckStateRole) + button_up_rect = self._button_up_rect(item_rect) + button_down_rect = self._button_down_rect(item_rect) + + if checked and button_up_rect.contains(pos): + self.buttonClicked.emit(index, ClickedButtonEnum.up) + elif checked and button_down_rect.contains(pos): + self.buttonClicked.emit(index, ClickedButtonEnum.down) + else: + self.buttonClicked.emit(index, ClickedButtonEnum.main) + + def setToolTip(self, item: str = "", up: str = "", down: str = "") -> None: + if item: + self.item_help = item + if up: + self.up_help = up + if down: + self.down_help = down + + def helpEvent( + self, + event: QtGui.QHelpEvent, + view: QtWidgets.QAbstractItemView, + option: QtWidgets.QStyleOptionViewItem, + index: QModelIndex, + ) -> bool: + item_rect = view.visualRect(index) + button_up_rect = self._button_up_rect(item_rect) + button_down_rect = self._button_down_rect(item_rect) + checked = index.data(Qt.CheckStateRole) + + if checked == Qt.Checked and button_up_rect.contains(event.pos()): + QtWidgets.QToolTip.showText(event.globalPos(), self.up_help, self.combobox, QRect(), 3000) + elif checked == Qt.Checked and button_down_rect.contains(event.pos()): + QtWidgets.QToolTip.showText(event.globalPos(), self.down_help, self.combobox, QRect(), 3000) + else: + QtWidgets.QToolTip.showText(event.globalPos(), self.item_help, self.combobox, QRect(), 3000) + return True + + def sizeHint(self, option: QtWidgets.QStyleOptionViewItem, index: QModelIndex) -> QSize: + # Reimpliment standard combobox sizeHint. Only height is used by view, width is ignored + menu_option = QtWidgets.QStyleOptionMenuItem() + return self.combobox.style().sizeFromContents( + QtWidgets.QStyle.ContentsType.CT_MenuItem, menu_option, option.rect.size(), self.combobox + ) + + +# Multiselect combobox from: https://gis.stackexchange.com/a/351152 (with custom changes) +class CheckableOrderComboBox(QtWidgets.QComboBox): + itemClicked = pyqtSignal(QModelIndex, QPoint) + dropdownClosed = pyqtSignal(list) + + def __init__(self, *args: Any, **kwargs: Any): + super().__init__(*args, **kwargs) + itemDelegate = ReadStyleItemDelegate(self) + itemDelegate.setToolTip( + "Select which read style(s) to use", "Move item up in priority", "Move item down in priority" + ) + self.setItemDelegate(itemDelegate) + + # Prevent popup from closing when clicking on an item + self.view().viewport().installEventFilter(self) + + # Go on a bit of a merry-go-round with the signals to avoid custom model/view + self.itemDelegate().buttonClicked.connect(self.buttonClicked) + + # Keeps track of when the combobox list is shown + self.justShown = False + + def buttonClicked(self, index: QModelIndex, button: ClickedButtonEnum) -> None: + if button == ClickedButtonEnum.up: + self.moveItem(index.row(), up=True) + elif button == ClickedButtonEnum.down: + self.moveItem(index.row(), up=False) + else: + self.toggleItem(index.row()) + + def resizeEvent(self, event: Any) -> None: + # Recompute text to elide as needed + super().resizeEvent(event) + self._updateText() + + def eventFilter(self, obj: Any, event: Any) -> bool: + # Allow events before the combobox list is shown + if obj == self.view().viewport(): + # We record that the combobox list has been shown + if event.type() == QEvent.Show: + self.justShown = True + # We record that the combobox list has hidden, + # this will happen if the user does not make a selection + # but clicks outside of the combobox list or presses escape + if event.type() == QEvent.Hide: + self._updateText() + self.justShown = False + # Reverse as the display order is in "priority" order for the user whereas overlay requires reversed + self.dropdownClosed.emit(self.currentData()) + # QEvent.MouseButtonPress is inconsistent on activation because double clicks are a thing + if event.type() == QEvent.MouseButtonRelease: + # If self.justShown is true it means that they clicked on the combobox to change the checked items + # This is standard behavior (on macos) but I think it is surprising when it has a multiple select + if self.justShown: + self.justShown = False + return True + + # Find the current index and item + index = self.view().indexAt(event.pos()) + if index.isValid(): + self.itemClicked.emit(index, event.pos()) + return True + + return False + + def currentData(self) -> list[Any]: + # Return the list of all checked items data + res = [] + for i in range(self.count()): + item = self.model().item(i) + if item.checkState() == Qt.Checked: + res.append(self.itemData(i)) + return res + + def addItem(self, text: str, data: Any = None) -> None: + super().addItem(text, data) + # Need to enable the checkboxes and require one checked item + # Expected that state of *all* checkboxes will be set ('adjust_save_style_combo' in taggerwindow.py) + if self.count() == 1: + self.model().item(0).setCheckState(Qt.CheckState.Checked) + + # Add room for "move" arrows + text_width = self.fontMetrics().width(text) + checkbox_width = 40 + total_width = text_width + checkbox_width + (self.itemDelegate().button_width * 2) + if total_width > self.view().minimumWidth(): + self.view().setMinimumWidth(total_width) + + def moveItem(self, index: int, up: bool = False, row: int | None = None) -> None: + """'Move' an item. Really swap the data and titles around on the two items""" + if row is None: + adjust = -1 if up else 1 + row = index + adjust + + # TODO Disable buttons at top and bottom. Do a check here for now + if up and index == 0: + return + if up is False and row == self.count(): + return + + # Grab values for the rows to swap + cur_data = self.model().item(index).data(Qt.UserRole) + cur_title = self.model().item(index).data(Qt.DisplayRole) + cur_state = self.model().item(index).data(Qt.CheckStateRole) + + swap_data = self.model().item(row).data(Qt.UserRole) + swap_title = self.model().item(row).data(Qt.DisplayRole) + swap_state = self.model().item(row).checkState() + + self.model().item(row).setData(cur_data, Qt.UserRole) + self.model().item(row).setCheckState(cur_state) + self.model().item(row).setText(cur_title) + + self.model().item(index).setData(swap_data, Qt.UserRole) + self.model().item(index).setCheckState(swap_state) + self.model().item(index).setText(swap_title) + + def _updateText(self) -> None: + texts = [] + for i in range(self.count()): + item = self.model().item(i) + if item.checkState() == Qt.Checked: + texts.append(item.text()) + text = ", ".join(texts) + + # Compute elided text (with "...") + + # The QStyleOptionComboBox is needed for the call to subControlRect + so = QtWidgets.QStyleOptionComboBox() + # init with the current widget + so.initFrom(self) + + # Ask the style for the size of the text field + rect = self.style().subControlRect(QtWidgets.QStyle.CC_ComboBox, so, QtWidgets.QStyle.SC_ComboBoxEditField) + + # Compute the elided text + elidedText = self.fontMetrics().elidedText(text, Qt.ElideRight, rect.width()) + + # This CheckableComboBox does not use the index, so we clear it and set the placeholder text + self.setCurrentIndex(-1) + self.setPlaceholderText(elidedText) + + def setItemChecked(self, index: Any, state: bool) -> None: + qt_state = Qt.Checked if state else Qt.Unchecked + item = self.model().item(index) + current = self.currentData() + # If we have at least one item checked emit itemChecked with the current check state and update text + # Require at least one item to be checked and provide a tooltip + if len(current) == 1 and not state and item.checkState() == Qt.Checked: + QtWidgets.QToolTip.showText(QtGui.QCursor.pos(), self.toolTip(), self, QRect(), 3000) + return + + if len(current) > 0: + item.setCheckState(qt_state) + self._updateText() + + def toggleItem(self, index: int) -> None: + if self.model().item(index).checkState() == Qt.Checked: + self.setItemChecked(index, False) + else: + self.setItemChecked(index, True) diff --git a/comictaggerlib/ui/taggerwindow.ui b/comictaggerlib/ui/taggerwindow.ui index cdf9bd3..2f25ac2 100644 --- a/comictaggerlib/ui/taggerwindow.ui +++ b/comictaggerlib/ui/taggerwindow.ui @@ -7,7 +7,7 @@ 0 0 1096 - 658 + 660 @@ -76,7 +76,11 @@ - + + + At least one read style must be selected + + @@ -1524,6 +1528,11 @@ QComboBox
comictaggerlib.ui.customwidgets
+ + CheckableOrderComboBox + QComboBox +
comictaggerlib.ui.customwidgets
+
diff --git a/tests/integration_test.py b/tests/integration_test.py index b375835..57ff9c1 100644 --- a/tests/integration_test.py +++ b/tests/integration_test.py @@ -39,8 +39,9 @@ def test_save( config[0].Runtime_Options__online = True # Use the temporary comic we created config[0].Runtime_Options__files = [tmp_comic.path] - # Save ComicRack tags - config[0].Runtime_Options__type = ["cr"] + # Read and save ComicRack tags + config[0].Runtime_Options__type_read = ["cr"] + config[0].Runtime_Options__type_modify = ["cr"] # Search using the correct series since we just put the wrong series name in the CBZ config[0].Runtime_Options__metadata = comicapi.genericmetadata.GenericMetadata(series=md_saved.series) # Run ComicTagger @@ -89,7 +90,7 @@ def test_delete( # Use the temporary comic we created config[0].Runtime_Options__files = [tmp_comic.path] # Delete ComicRack tags - config[0].Runtime_Options__type = ["cr"] + config[0].Runtime_Options__type_modify = ["cr"] # Run ComicTagger CLI(config[0], talkers).run()