Merge branch 'mizaki/multi_read' into develop

This commit is contained in:
Timmy Welch 2024-05-10 16:25:07 -07:00
commit 851339d4e3
13 changed files with 520 additions and 105 deletions

View File

@ -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()

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -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,

View File

@ -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()

View File

@ -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()

View File

@ -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)

View File

@ -7,7 +7,7 @@
<x>0</x>
<y>0</y>
<width>1096</width>
<height>658</height>
<height>660</height>
</rect>
</property>
<property name="sizePolicy">
@ -76,7 +76,11 @@
<widget class="QComboBox" name="cbx_sources"/>
</item>
<item row="1" column="1">
<widget class="QComboBox" name="cbLoadDataStyle"/>
<widget class="CheckableOrderComboBox" name="cbLoadDataStyle">
<property name="toolTip">
<string>At least one read style must be selected</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="CheckableComboBox" name="cbSaveDataStyle"/>
@ -1524,6 +1528,11 @@
<extends>QComboBox</extends>
<header>comictaggerlib.ui.customwidgets</header>
</customwidget>
<customwidget>
<class>CheckableOrderComboBox</class>
<extends>QComboBox</extends>
<header>comictaggerlib.ui.customwidgets</header>
</customwidget>
</customwidgets>
<resources/>
<connections>

View File

@ -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()