2328 lines
98 KiB
Python
2328 lines
98 KiB
Python
"""The main window of the ComicTagger app"""
|
|
|
|
#
|
|
# Copyright 2012-2014 ComicTagger Authors
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
from __future__ import annotations
|
|
|
|
import functools
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import operator
|
|
import os
|
|
import platform
|
|
import sys
|
|
import textwrap
|
|
import traceback
|
|
import webbrowser
|
|
from collections.abc import Callable, Sequence
|
|
from typing import Any
|
|
|
|
import natsort
|
|
import settngs
|
|
from PyQt6 import QtCore, QtGui, QtNetwork, QtWidgets, uic
|
|
|
|
import comicapi.merge
|
|
import comictaggerlib.ui
|
|
from comicapi import utils
|
|
from comicapi.comicarchive import ComicArchive, tags
|
|
from comicapi.filenameparser import FileNameParser
|
|
from comicapi.genericmetadata import Credit, FileHash, GenericMetadata
|
|
from comicapi.issuestring import IssueString
|
|
from comictaggerlib import ctsettings, ctversion
|
|
from comictaggerlib.applicationlogwindow import ApplicationLogWindow, QTextEditLogger
|
|
from comictaggerlib.autotagmatchwindow import AutoTagMatchWindow
|
|
from comictaggerlib.autotagprogresswindow import AutoTagProgressWindow, AutoTagThread
|
|
from comictaggerlib.autotagstartwindow import AutoTagSettings, AutoTagStartWindow
|
|
from comictaggerlib.cbltransformer import CBLTransformer
|
|
from comictaggerlib.coverimagewidget import CoverImageWidget
|
|
from comictaggerlib.crediteditorwindow import CreditEditorWindow
|
|
from comictaggerlib.ctsettings import ct_ns
|
|
from comictaggerlib.exportwindow import ExportConfig, ExportConflictOpts, ExportWindow
|
|
from comictaggerlib.fileselectionlist import FileSelectionList
|
|
from comictaggerlib.graphics import graphics_path
|
|
from comictaggerlib.gtinvalidator import is_valid_gtin
|
|
from comictaggerlib.logwindow import LogWindow
|
|
from comictaggerlib.md import prepare_metadata, read_selected_tags
|
|
from comictaggerlib.optionalmsgdialog import OptionalMessageDialog
|
|
from comictaggerlib.pagebrowser import PageBrowserWindow
|
|
from comictaggerlib.pagelisteditor import PageListEditor
|
|
from comictaggerlib.renamewindow import RenameWindow
|
|
from comictaggerlib.resulttypes import OnlineMatchResults
|
|
from comictaggerlib.seriesselectionwindow import SeriesSelectionWindow
|
|
from comictaggerlib.settingswindow import SettingsWindow
|
|
from comictaggerlib.ui import qtutils, ui_path
|
|
from comictaggerlib.ui.pyqttoast import Toast, ToastPreset
|
|
from comictaggerlib.ui.qtutils import center_window_on_parent, enable_widget
|
|
from comictaggerlib.versionchecker import VersionChecker
|
|
from comictalker.comictalker import ComicTalker, RLCallBack, TalkerError
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def execute(f: Callable[[], Any]) -> None:
|
|
f()
|
|
|
|
|
|
class QueryThread(QtCore.QThread): # TODO: Evaluate thread semantics. Specifically with signals
|
|
finish = QtCore.pyqtSignal(GenericMetadata)
|
|
ratelimit = QtCore.pyqtSignal(float, float)
|
|
|
|
def __init__(
|
|
self,
|
|
talker: ComicTalker,
|
|
issue_id: str,
|
|
series_id: str,
|
|
issue_number: str,
|
|
) -> None:
|
|
super().__init__()
|
|
self.issue_id = issue_id
|
|
self.series_id = series_id
|
|
self.issue_number = issue_number
|
|
self.talker = talker
|
|
|
|
def run(self) -> None:
|
|
try:
|
|
new_metadata = self.talker.fetch_comic_data(
|
|
issue_id=self.issue_id,
|
|
series_id=self.series_id,
|
|
issue_number=self.issue_number,
|
|
on_rate_limit=RLCallBack(lambda x, y: self.ratelimit.emit(x, y), 60),
|
|
)
|
|
except TalkerError as e:
|
|
OptionalMessageDialog.critical(None, f"{e.source} {e.code_name} Error", f"{e}")
|
|
return
|
|
self.finish.emit(new_metadata)
|
|
|
|
|
|
class TaggerWindow(QtWidgets.QMainWindow):
|
|
appName = "ComicTagger"
|
|
version = ctversion.version
|
|
ratelimit = QtCore.pyqtSignal(float, float)
|
|
query_finished = QtCore.pyqtSignal(GenericMetadata)
|
|
|
|
def __init__(
|
|
self,
|
|
config: settngs.Config[ct_ns],
|
|
talkers: dict[str, ComicTalker],
|
|
socket_server: QtNetwork.QLocalServer,
|
|
parent: QtWidgets.QWidget | None = None,
|
|
) -> None:
|
|
super().__init__(parent)
|
|
|
|
with (ui_path / "taggerwindow.ui").open(encoding="utf-8") as uifile:
|
|
uic.loadUi(uifile, self)
|
|
|
|
self.md_attributes = {
|
|
"data_origin": None,
|
|
"issue_id": None,
|
|
"original_hash": (self.cbHashName, self.leOriginalHash),
|
|
"series": self.leSeries,
|
|
"issue": self.leIssueNum,
|
|
"title": self.leTitle,
|
|
"publisher": self.lePublisher,
|
|
"month": self.lePubMonth,
|
|
"year": self.lePubYear,
|
|
"day": self.lePubDay,
|
|
"issue_count": self.leIssueCount,
|
|
"volume": self.leVolumeNum,
|
|
"genres": self.leGenre,
|
|
"language": self.cbLanguage,
|
|
"description": self.teComments,
|
|
"volume_count": self.leVolumeCount,
|
|
"critical_rating": self.dsbCriticalRating,
|
|
"country": self.cbCountry,
|
|
"alternate_series": self.leAltSeries,
|
|
"alternate_number": self.leAltIssueNum,
|
|
"alternate_count": self.leAltIssueCount,
|
|
"gtin": self.leGtin,
|
|
"imprint": self.leImprint,
|
|
"notes": self.teNotes,
|
|
"web_links": (self.leWebLink, self.btnOpenWebLink, self.btnAddWebLink, self.btnRemoveWebLink),
|
|
"format": self.cbFormat,
|
|
"manga": self.cbManga,
|
|
"black_and_white": self.cbBW,
|
|
"page_count": None,
|
|
"maturity_rating": self.cbMaturityRating,
|
|
"story_arcs": self.leStoryArc,
|
|
"series_groups": self.leSeriesGroup,
|
|
"scan_info": self.leScanInfo,
|
|
"characters": self.teCharacters,
|
|
"teams": self.teTeams,
|
|
"locations": self.teLocations,
|
|
"credits": (self.twCredits, self.btnAddCredit, self.btnEditCredit, self.btnRemoveCredit),
|
|
"credits.person": 3,
|
|
"credits.language": 2,
|
|
"credits.role": 1,
|
|
"credits.primary": 0,
|
|
"tags": self.teTags,
|
|
"pages": None,
|
|
"page.type": None,
|
|
"page.bookmark": None,
|
|
"page.double_page": None,
|
|
"page.image_index": None,
|
|
"page.size": None,
|
|
"page.height": None,
|
|
"page.width": None,
|
|
"price": None,
|
|
"is_version_of": None,
|
|
"rights": None,
|
|
"identifier": None,
|
|
"last_mark": None,
|
|
}
|
|
comictaggerlib.ui.qtutils.active_palette = self.leSeries.palette()
|
|
self.config = config
|
|
self.talkers = talkers
|
|
self.log_window = self.setup_logger()
|
|
|
|
self.archiveCoverWidget = CoverImageWidget(self.coverImageContainer, CoverImageWidget.ArchiveMode, None)
|
|
grid_layout = QtWidgets.QGridLayout(self.coverImageContainer)
|
|
grid_layout.addWidget(self.archiveCoverWidget)
|
|
grid_layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
self.page_list_editor = PageListEditor(self.tabPages)
|
|
grid_layout = QtWidgets.QGridLayout(self.tabPages)
|
|
grid_layout.addWidget(self.page_list_editor)
|
|
|
|
self.fileSelectionList = FileSelectionList(self.widgetListHolder, self.config[0], self.dirty_flag_verification)
|
|
grid_layout = QtWidgets.QGridLayout(self.widgetListHolder)
|
|
grid_layout.addWidget(self.fileSelectionList)
|
|
|
|
self.fileSelectionList.selectionChanged.connect(self.load_archive)
|
|
self.fileSelectionList.listCleared.connect(self.file_list_cleared)
|
|
self.fileSelectionList.set_sorting(
|
|
self.config[0].internal__sort_column, QtCore.Qt.SortOrder(self.config[0].internal__sort_direction)
|
|
)
|
|
self.socket_server = socket_server
|
|
self.socket_server.newConnection.connect(self.on_incoming_socket_connection)
|
|
|
|
# we can't specify relative font sizes in the UI designer, so
|
|
# walk through all the labels in the main form, and make them
|
|
# a smidge smaller TODO: there has to be a better way to do this
|
|
try:
|
|
for child in self.scrollAreaWidgetContents.children():
|
|
if isinstance(child, QtWidgets.QLabel):
|
|
f = child.font()
|
|
if f.pointSize() > 10:
|
|
f.setPointSize(f.pointSize() - 2)
|
|
f.setItalic(True)
|
|
child.setFont(f)
|
|
self.scrollAreaWidgetContents.adjustSize()
|
|
except Exception:
|
|
exc_type, exc_value, exc_traceback = sys.exc_info()
|
|
trace_back = "".join(traceback.format_tb(exc_traceback))
|
|
logger.exception("Failed to adjust sizes")
|
|
OptionalMessageDialog.critical(self, "Failed to adjust sizes", "", trace_back)
|
|
|
|
try:
|
|
self.qicon = QtGui.QIcon(str(graphics_path / "app.png"))
|
|
self.setWindowIcon(self.qicon)
|
|
# See self._toast for more info
|
|
self.tray = QtWidgets.QSystemTrayIcon(self)
|
|
self.tray.show()
|
|
self.tray.hide()
|
|
except Exception:
|
|
exc_type, exc_value, exc_traceback = sys.exc_info()
|
|
trace_back = "".join(traceback.format_tb(exc_traceback))
|
|
logger.exception("Failed to create a tray icon")
|
|
OptionalMessageDialog.critical(self, "Failed to create a tray icon", "", trace_back)
|
|
|
|
# If no tags on CLI, use last tags
|
|
if not config[0].Runtime_Options__tags_write:
|
|
config[0].Runtime_Options__tags_write = config[0].internal__write_tags
|
|
|
|
if not config[0].Runtime_Options__tags_read:
|
|
config[0].Runtime_Options__tags_read = config[0].internal__read_tags
|
|
|
|
# Ensure we have tags at all
|
|
config[0].Runtime_Options__tags_write = config[0].Runtime_Options__tags_write or list(self.enabled_tags())
|
|
config[0].Runtime_Options__tags_read = config[0].Runtime_Options__tags_read or list(self.enabled_tags())
|
|
|
|
# Remove any tags we don't know about
|
|
for tag_id in config[0].Runtime_Options__tags_write.copy():
|
|
if tag_id not in self.enabled_tags():
|
|
config[0].Runtime_Options__tags_write.remove(tag_id)
|
|
|
|
for tag_id in config[0].Runtime_Options__tags_read.copy():
|
|
if tag_id not in self.enabled_tags():
|
|
config[0].Runtime_Options__tags_read.remove(tag_id)
|
|
|
|
if not self.config[0].Runtime_Options__preferred_hash:
|
|
self.config[0].Runtime_Options__preferred_hash = self.config[0].internal__embedded_hash_type
|
|
|
|
self.setAcceptDrops(True)
|
|
self.view_tag_actions, self.remove_tag_actions = self.tag_actions()
|
|
try:
|
|
self.config_menus()
|
|
self.statusBar()
|
|
self.populate_combo_boxes()
|
|
except Exception:
|
|
exc_type, exc_value, exc_traceback = sys.exc_info()
|
|
trace_back = "".join(traceback.format_tb(exc_traceback))
|
|
logger.exception("Failed to populate forms")
|
|
OptionalMessageDialog.critical(self, "Failed to populate forms", "", trace_back)
|
|
|
|
self.page_browser: PageBrowserWindow | None = None
|
|
self.comic_archive: ComicArchive | None = None
|
|
self.dirty_flag = False
|
|
self.droppedFile = None
|
|
self.page_loader = None
|
|
self.droppedFiles: list[str] = []
|
|
self.metadata = GenericMetadata()
|
|
self.atprogdialog: AutoTagProgressWindow | None = None
|
|
self.reset_app()
|
|
|
|
# set up some basic field validators
|
|
validator = QtGui.QIntValidator(1900, 2099, self)
|
|
self.lePubYear.setValidator(validator)
|
|
|
|
validator = QtGui.QIntValidator(1, 12, self)
|
|
self.lePubMonth.setValidator(validator)
|
|
|
|
# TODO: for now keep it simple, ideally we should check the full date
|
|
validator = QtGui.QIntValidator(1, 31, self)
|
|
self.lePubDay.setValidator(validator)
|
|
|
|
validator = QtGui.QIntValidator(1, 99999, self)
|
|
self.leIssueCount.setValidator(validator)
|
|
self.leVolumeNum.setValidator(validator)
|
|
self.leVolumeCount.setValidator(validator)
|
|
self.leAltIssueNum.setValidator(validator)
|
|
self.leAltIssueCount.setValidator(validator)
|
|
|
|
# make sure some editable comboboxes don't take drop actions
|
|
self.cbFormat.lineEdit().setAcceptDrops(False)
|
|
self.cbMaturityRating.lineEdit().setAcceptDrops(False)
|
|
|
|
# hook up the callbacks
|
|
self.cbSelectedReadTags.dropdownClosed.connect(self.select_read_tags)
|
|
self.cbSelectedWriteTags.itemChecked.connect(self.select_write_tags)
|
|
self.cbx_sources.currentIndexChanged.connect(self.select_source)
|
|
self.btnEditCredit.clicked.connect(self.edit_credit)
|
|
self.btnAddCredit.clicked.connect(self.add_credit)
|
|
self.btnRemoveCredit.clicked.connect(self.remove_credit)
|
|
self.twCredits.cellDoubleClicked.connect(self.edit_credit)
|
|
self.btnOpenWebLink.clicked.connect(self.open_web_link)
|
|
self.connect_dirty_flag_signals()
|
|
self.page_list_editor.modified.connect(self.set_dirty_flag)
|
|
self.page_list_editor.firstFrontCoverChanged.connect(self.front_cover_changed)
|
|
self.page_list_editor.listOrderChanged.connect(self.page_list_order_changed)
|
|
self.tabWidget.currentChanged.connect(self.tab_changed)
|
|
self.leGtin.textChanged.connect(self.gtin_changed)
|
|
|
|
self.page_list_editor.set_blur(self.config[0].General__blur)
|
|
|
|
self.ratelimit.connect(self.on_ratelimit)
|
|
self.query_finished.connect(self.apply_query_metadata)
|
|
|
|
def _sync_blur(*args: Any) -> None:
|
|
self.config[0].General__blur = self.page_list_editor.blur
|
|
|
|
self.page_list_editor.cbxBlur.clicked.connect(_sync_blur)
|
|
|
|
self.splitter.splitterMoved.connect(self.splitter_moved_event)
|
|
|
|
self.fileSelectionList.add_app_action(self.actionAutoIdentify)
|
|
self.fileSelectionList.add_app_action(self.actionAutoTag)
|
|
self.fileSelectionList.add_app_action(self.actionCopyTags)
|
|
self.fileSelectionList.add_app_action(self.actionRename)
|
|
self.fileSelectionList.add_app_action(self.actionRemoveAuto)
|
|
self.fileSelectionList.add_app_action(self.actionRepackage)
|
|
self.export_window = ExportWindow(self)
|
|
self.export_window.export.connect(self._repackage_archive)
|
|
if self.enabled_tags():
|
|
# This should never be false
|
|
self.selected_write_tags = [self.enabled_tags()[0]]
|
|
self.selected_read_tags = [self.enabled_tags()[0]]
|
|
|
|
def _post_show(self, file_list: list[str]) -> None:
|
|
try:
|
|
self.update_tag_tweaks()
|
|
except Exception:
|
|
exc_type, exc_value, exc_traceback = sys.exc_info()
|
|
trace_back = "".join(traceback.format_tb(exc_traceback))
|
|
logger.exception("Failed to update form for current tags")
|
|
OptionalMessageDialog.warning(self, "Failed to update form for current tags", "", trace_back)
|
|
|
|
try:
|
|
if file_list:
|
|
self.fileSelectionList.add_path_list(file_list)
|
|
except Exception:
|
|
exc_type, exc_value, exc_traceback = sys.exc_info()
|
|
trace_back = "".join(traceback.format_tb(exc_traceback))
|
|
logger.exception("Failed to load comics")
|
|
OptionalMessageDialog.warning(self, "Failed to load comics", "", trace_back)
|
|
|
|
self.set_app_position()
|
|
if self.config[0].internal__form_width != -1:
|
|
self.splitter.setSizes([self.config[0].internal__form_width, self.config[0].internal__list_width])
|
|
self.raise_()
|
|
QtCore.QCoreApplication.processEvents()
|
|
self.resizeEvent(None)
|
|
|
|
if self.config[0].Dialog_Flags__show_disclaimer:
|
|
|
|
def set_checked(checked: bool) -> None:
|
|
self.config[0].Dialog_Flags__show_disclaimer = not checked
|
|
|
|
OptionalMessageDialog.msg(
|
|
self,
|
|
"Welcome!",
|
|
textwrap.dedent("""
|
|
Thanks for trying ComicTagger!
|
|
|
|
Be aware that this is beta-level software, and consider it experimental.
|
|
|
|
You should use it very carefully when modifying your data files. As the
|
|
license says, it's "AS IS!"
|
|
|
|
Also, be aware that writing tags to comic archives will change their file hashes,
|
|
which has implications with respect to other software packages. It's best to
|
|
use ComicTagger on local copies of your comics.
|
|
|
|
Additional metadata sources are listed [here] along with links to available plugins.
|
|
|
|
COMIC VINE NOTE: Using the default API key will serverly limit search and tagging
|
|
times. A personal API key will allow for a **5 times increase** in online search speed. See the
|
|
[Wiki page]
|
|
for more information.
|
|
|
|
Have fun!
|
|
|
|
[here]: https://github.com/comictagger/comictagger/wiki/Comic-and-Manga-Information-Sources
|
|
[Wiki page]: https://github.com/comictagger/comictagger/wiki/UserGuide#comic-vine
|
|
"""),
|
|
).check_status.connect(set_checked)
|
|
|
|
if self.config[0].Dialog_Flags__notify_plugin_changes:
|
|
|
|
def set_checked(checked: bool) -> None:
|
|
self.config[0].Dialog_Flags__notify_plugin_changes = not checked
|
|
|
|
self.dlg = OptionalMessageDialog.msg(
|
|
self,
|
|
"Plugins Have moved!",
|
|
textwrap.dedent(f"""
|
|
Due to techinical issues the Metron is not supported anymore and the GCD plugin is no longer bundled in ComicTagger!
|
|
|
|
You will need to download the .zip or .whl from the GitHub release page to:
|
|
```plain
|
|
{str(self.config[0].Runtime_Options__config.user_plugin_dir)}
|
|
```
|
|
GCD: https://github.com/comictagger/gcd_talker/releases
|
|
|
|
For more information on installing plugins see the wiki page:
|
|
|
|
https://github.com/comictagger/comictagger/wiki/Installing-plugins
|
|
"""),
|
|
).check_status.connect(set_checked)
|
|
|
|
try:
|
|
if self.config[0].General__check_for_new_version:
|
|
self.check_latest_version_online()
|
|
except Exception:
|
|
exc_type, exc_value, exc_traceback = sys.exc_info()
|
|
trace_back = "".join(traceback.format_tb(exc_traceback))
|
|
logger.exception("Failed to check for new version")
|
|
OptionalMessageDialog.critical(
|
|
self, "Failed to check for new version", "Failed to check for new version", trace_back
|
|
)
|
|
self.export_window = ExportWindow(self)
|
|
self.export_window.export.connect(self._repackage_archive)
|
|
|
|
def enabled_tags(self) -> Sequence[str]:
|
|
return [tag.id for tag in tags.values() if tag.enabled]
|
|
|
|
def tag_actions(self) -> tuple[dict[str, QtGui.QAction], dict[str, QtGui.QAction]]:
|
|
view_raw_tags: dict[str, QtGui.QAction] = {}
|
|
remove_raw_tags: dict[str, QtGui.QAction] = {}
|
|
for tag in tags.values():
|
|
view_raw_tags[tag.id] = self.menuViewRawTags.addAction(f"View Raw {tag.name()} Tags")
|
|
view_raw_tags[tag.id].setEnabled(tag.enabled)
|
|
view_raw_tags[tag.id].setStatusTip(f"View raw {tag.name()} tag block from file")
|
|
view_raw_tags[tag.id].triggered.connect(functools.partial(self.view_raw_tags, tag.id))
|
|
|
|
remove_raw_tags[tag.id] = self.menuRemove.addAction(f"Remove Raw {tag.name()} Tags")
|
|
remove_raw_tags[tag.id].setEnabled(tag.enabled)
|
|
remove_raw_tags[tag.id].setStatusTip(f"Remove {tag.name()} tags from comic archive")
|
|
remove_raw_tags[tag.id].triggered.connect(functools.partial(self.prompt_remove_tags, [tag.id]))
|
|
|
|
return view_raw_tags, remove_raw_tags
|
|
|
|
def current_talker(self) -> ComicTalker:
|
|
if self.config[0].Sources__source in self.talkers:
|
|
return self.talkers[self.config[0].Sources__source]
|
|
logger.error("Could not find the '%s' talker", self.config[0].Sources__source)
|
|
raise SystemExit(2)
|
|
|
|
def open_file_event(self, url: QtCore.QUrl) -> None:
|
|
logger.info(url.toLocalFile())
|
|
self.fileSelectionList.add_path_list([url.toLocalFile()])
|
|
|
|
def sigint_handler(self, *args: Any) -> None:
|
|
# defer the actual close in the app loop thread
|
|
QtCore.QTimer.singleShot(200, lambda: execute(self.close))
|
|
|
|
def setup_logger(self) -> ApplicationLogWindow:
|
|
try:
|
|
current_logs = (self.config[0].Runtime_Options__config.user_log_dir / "ComicTagger.log").read_text("utf-8")
|
|
except Exception:
|
|
current_logs = ""
|
|
root_logger = logging.getLogger()
|
|
qapplogwindow = ApplicationLogWindow(
|
|
self.config[0].Runtime_Options__config.user_log_dir,
|
|
QTextEditLogger(logging.Formatter("%(asctime)s | %(name)s | %(levelname)s | %(message)s"), logging.DEBUG),
|
|
parent=self,
|
|
)
|
|
qapplogwindow.addAction(self.actionExit)
|
|
qapplogwindow.textEdit.append(current_logs.strip())
|
|
root_logger.addHandler(qapplogwindow.log_handler)
|
|
return qapplogwindow
|
|
|
|
def reset_app(self) -> None:
|
|
self.archiveCoverWidget.clear()
|
|
self.comic_archive = None
|
|
self.dirty_flag = False
|
|
self.clear_form()
|
|
self.page_list_editor.reset_page()
|
|
if self.page_browser is not None:
|
|
self.page_browser.reset()
|
|
self.update_app_title()
|
|
self.update_menus()
|
|
self.update_info_box()
|
|
|
|
self.droppedFile = None
|
|
self.page_loader = None
|
|
|
|
def update_app_title(self) -> None:
|
|
if self.comic_archive is None:
|
|
self.setWindowTitle(self.appName)
|
|
return
|
|
|
|
mod_str = ""
|
|
ro_str = ""
|
|
|
|
if self.dirty_flag:
|
|
mod_str = " [modified]"
|
|
|
|
if not self.comic_archive.is_writable():
|
|
ro_str = " [read only]"
|
|
|
|
self.setWindowTitle(f"{self.appName} - {self.comic_archive.path}{mod_str}{ro_str}")
|
|
|
|
def toggle_enable_embedding_hashes(self) -> None:
|
|
self.config[0].Runtime_Options__enable_embedding_hashes = self.actionEnableEmbeddingHashes.isChecked()
|
|
enabled_widgets = set()
|
|
for tag_id in self.config[0].Runtime_Options__tags_write:
|
|
if not tags[tag_id].enabled:
|
|
continue
|
|
enabled_widgets.update(tags[tag_id].supported_attributes)
|
|
enable_widget(
|
|
self.md_attributes["original_hash"],
|
|
self.config[0].Runtime_Options__enable_embedding_hashes and "original_hash" in enabled_widgets,
|
|
)
|
|
if not self.leOriginalHash.text().strip():
|
|
self.cbHashName.setCurrentText(self.config[0].internal__embedded_hash_type)
|
|
if self.config[0].Runtime_Options__enable_embedding_hashes:
|
|
self.config[0].Runtime_Options__preferred_hash = self.config[0].internal__embedded_hash_type
|
|
else:
|
|
self.config[0].Runtime_Options__preferred_hash = ""
|
|
|
|
def config_menus(self) -> None:
|
|
# File Menu
|
|
self.actionAutoTag.triggered.connect(self.auto_tag)
|
|
self.actionCopyTags.triggered.connect(self.prompt_copy_tags)
|
|
self.actionExit.triggered.connect(self.close)
|
|
self.actionExit.setShortcutContext(QtCore.Qt.ShortcutContext.ApplicationShortcut) # Doesn't work
|
|
self.actionLoad.triggered.connect(self.select_file)
|
|
self.actionLoadFolder.triggered.connect(self.select_folder)
|
|
self.actionOpenFolderAsComic.triggered.connect(self.select_folder_archive)
|
|
self.actionRemoveAuto.triggered.connect(self.remove_auto)
|
|
self.actionRename.triggered.connect(self.rename_archive)
|
|
self.actionRepackage.triggered.connect(self.repackage_archive)
|
|
self.actionSettings.triggered.connect(self.show_settings)
|
|
self.actionWrite_Tags.triggered.connect(self.prompt_write_tags)
|
|
# Tag Menu
|
|
self.actionApplyCBLTransform.triggered.connect(self.apply_cbl_transform)
|
|
self.actionAutoIdentify.triggered.connect(self.auto_identify_search)
|
|
self.actionAutoImprint.triggered.connect(self.auto_imprint)
|
|
self.actionClearEntryForm.triggered.connect(self.clear_form)
|
|
self.actionLiteralSearch.triggered.connect(self.literal_search)
|
|
self.actionParse_Filename.triggered.connect(self.use_filename)
|
|
self.actionParse_Filename_split_words.triggered.connect(self.use_filename_split)
|
|
self.actionReCalcArchiveInfo.triggered.connect(self.recalc_archive_info)
|
|
self.actionSearchOnline.triggered.connect(self.query_online)
|
|
self.actionEnableEmbeddingHashes: QtGui.QAction
|
|
self.actionEnableEmbeddingHashes.triggered.connect(self.toggle_enable_embedding_hashes)
|
|
self.actionEnableEmbeddingHashes.setChecked(self.config[0].Runtime_Options__enable_embedding_hashes)
|
|
# Window Menu
|
|
self.actionLogWindow.triggered.connect(self.log_window.show)
|
|
self.actionPageBrowser.triggered.connect(self.show_page_browser)
|
|
# Help Menu
|
|
self.actionAbout.triggered.connect(self.about_app)
|
|
self.actionComicTaggerForum.triggered.connect(self.show_forum)
|
|
self.actionReportBug.triggered.connect(self.report_bug)
|
|
self.actionWiki.triggered.connect(self.show_wiki)
|
|
|
|
self.actionAddWebLink.triggered.connect(self.add_weblink_item)
|
|
self.actionRemoveWebLink.triggered.connect(self.remove_weblink_item)
|
|
|
|
self.leWebLink.addAction(self.actionAddWebLink)
|
|
self.leWebLink.addAction(self.actionRemoveWebLink)
|
|
|
|
def add_weblink_item(self, url: str = "") -> None:
|
|
item = ""
|
|
if isinstance(url, str):
|
|
item = url
|
|
self.leWebLink.addItem(item)
|
|
self.leWebLink.item(self.leWebLink.count() - 1).setFlags(
|
|
QtCore.Qt.ItemFlag.ItemIsEditable
|
|
| QtCore.Qt.ItemFlag.ItemIsEnabled
|
|
| QtCore.Qt.ItemFlag.ItemIsDragEnabled
|
|
| QtCore.Qt.ItemFlag.ItemIsSelectable
|
|
)
|
|
self.leWebLink.item(self.leWebLink.count() - 1).setSelected(True)
|
|
if not url:
|
|
self.leWebLink.editItem(self.leWebLink.item(self.leWebLink.count() - 1))
|
|
|
|
def remove_weblink_item(self) -> None:
|
|
item = self.leWebLink.takeItem(self.leWebLink.currentRow())
|
|
del item
|
|
|
|
def repackage_archive(self) -> None:
|
|
ca_list = self.fileSelectionList.get_selected_archive_list()
|
|
to_zip = [ca for ca in ca_list if not ca.is_zip()]
|
|
|
|
if not to_zip:
|
|
logger.warning("Export as Zip Archive. Only ZIP archives are selected")
|
|
OptionalMessageDialog.information(self, "Export as Zip Archive", "Only ZIP archives are selected!")
|
|
return
|
|
|
|
if not self.dirty_flag_verification(
|
|
"Export as Zip Archive",
|
|
"If you export archives as Zip now, unsaved data in the form may be lost. Are you sure?",
|
|
):
|
|
return
|
|
|
|
self.export_window.show(len(to_zip))
|
|
|
|
def _repackage_archive(self, export_config: ExportConfig) -> None:
|
|
largest_page_size = 0
|
|
ca_list = self.fileSelectionList.get_selected_archive_list()
|
|
to_zip = []
|
|
for ca in ca_list:
|
|
if not ca.is_zip():
|
|
to_zip.append(ca)
|
|
if ca.get_number_of_pages() > largest_page_size:
|
|
largest_page_size = ca.get_number_of_pages()
|
|
|
|
prog_dialog = None
|
|
if len(to_zip) > 3 or largest_page_size > 24:
|
|
prog_dialog = QtWidgets.QProgressDialog("", "Cancel", 0, len(to_zip), self)
|
|
prog_dialog.setWindowTitle("Exporting as ZIP")
|
|
prog_dialog.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
|
|
prog_dialog.setMinimumDuration(300)
|
|
center_window_on_parent(prog_dialog)
|
|
QtCore.QCoreApplication.processEvents()
|
|
|
|
new_archives_to_add = []
|
|
archives_to_remove = []
|
|
skipped_list = []
|
|
failed_list = []
|
|
success_count = 0
|
|
logger.debug("Exporting %d comics to zip", len(to_zip))
|
|
|
|
for prog_idx, ca in enumerate(to_zip, 1):
|
|
logger.debug("Exporting comic %d: %s", prog_idx, ca.path)
|
|
if prog_dialog is not None:
|
|
if prog_dialog.wasCanceled():
|
|
break
|
|
if prog_idx % 10 == 0 or len(ca_list) < 50:
|
|
prog_dialog.setValue(prog_idx)
|
|
prog_dialog.setLabelText(str(ca.path))
|
|
QtCore.QCoreApplication.processEvents()
|
|
|
|
export_name = ca.path.with_suffix(".cbz")
|
|
export = True
|
|
|
|
if export_config.conflict == ExportConflictOpts.DONT_CREATE:
|
|
export = False
|
|
skipped_list.append(ca.path)
|
|
elif export_config.conflict == ExportConflictOpts.CREATE_UNIQUE:
|
|
export_name = utils.unique_file(export_name)
|
|
|
|
if export:
|
|
logger.debug("Exporting %s to %s", ca.path, export_name)
|
|
if ca.export_as_zip(export_name):
|
|
success_count += 1
|
|
if export_config.add_to_list:
|
|
new_archives_to_add.append(str(export_name))
|
|
if export_config.delete_original:
|
|
archives_to_remove.append(ca)
|
|
ca.path.unlink(missing_ok=True)
|
|
|
|
else:
|
|
# last export failed, so remove the zip, if it exists
|
|
failed_list.append(ca.path)
|
|
if export_name.exists():
|
|
export_name.unlink(missing_ok=True)
|
|
|
|
if prog_dialog is not None:
|
|
prog_dialog.hide()
|
|
self.fileSelectionList.remove_archive_list(archives_to_remove)
|
|
|
|
summary = f"Successfully created {success_count} Zip archive(s)."
|
|
if skipped_list:
|
|
summary += f"\n\nThe following {len(skipped_list)} archive(s) were skipped due to file name conflicts:\n"
|
|
for f in skipped_list:
|
|
summary += f"\t{f}\n"
|
|
if failed_list:
|
|
summary += f"\n\nThe following {len(failed_list)} archive(s) failed to export due to read/write errors:\n"
|
|
for f in failed_list:
|
|
summary += f"\t{f}\n"
|
|
|
|
logger.info(summary)
|
|
dlg = LogWindow(self)
|
|
dlg.set_text(summary)
|
|
dlg.setWindowTitle("Archive Export to Zip Summary")
|
|
dlg.show()
|
|
self.fileSelectionList.add_path_list(new_archives_to_add)
|
|
|
|
def about_app(self) -> None:
|
|
website = "https://github.com/comictagger/comictagger"
|
|
email = "comictagger@gmail.com"
|
|
license_link = "http://www.apache.org/licenses/LICENSE-2.0"
|
|
license_name = "Apache License 2.0"
|
|
|
|
msg_box = QtWidgets.QMessageBox(self)
|
|
msg_box.setWindowTitle("About " + self.appName)
|
|
msg_box.setTextFormat(QtCore.Qt.TextFormat.RichText)
|
|
msg_box.setIconPixmap(QtGui.QPixmap(":/graphics/about.png"))
|
|
msg_box.setText(
|
|
"<br><br><br>"
|
|
+ self.appName
|
|
+ f" v{self.version}"
|
|
+ "<br>"
|
|
+ "©2014-2022 ComicTagger Devs<br><br>"
|
|
+ f"<a href='{website}'>{website}</a><br><br>"
|
|
+ f"<a href='mailto:{email}'>{email}</a><br><br>"
|
|
+ f"License: <a href='{license_link}'>{license_name}</a>"
|
|
)
|
|
msg_box.addAction(self.actionExit)
|
|
|
|
msg_box.setStandardButtons(QtWidgets.QMessageBox.StandardButton.Ok)
|
|
msg_box.show()
|
|
|
|
def dragEnterEvent(self, event: QtGui.QDragEnterEvent) -> None:
|
|
self.droppedFiles = []
|
|
if event.mimeData().hasUrls():
|
|
# walk through the URL list and build a file list
|
|
for url in event.mimeData().urls():
|
|
if url.isValid() and url.scheme() == "file":
|
|
self.droppedFiles.append(url.toLocalFile())
|
|
|
|
if self.droppedFiles is not None:
|
|
event.accept()
|
|
|
|
def dropEvent(self, event: QtGui.QDropEvent) -> None:
|
|
control_pressed = event.modifiers() & QtCore.Qt.KeyboardModifier.ControlModifier
|
|
|
|
if control_pressed:
|
|
for folder_archive in self.droppedFiles:
|
|
self.fileSelectionList.twList.selectRow(self.fileSelectionList.add_path_item(folder_archive)[0])
|
|
else:
|
|
self.fileSelectionList.add_path_list(self.droppedFiles)
|
|
event.accept()
|
|
|
|
def update_ui_for_archive(self, parse_filename: bool = True) -> None:
|
|
if self.comic_archive is not None:
|
|
if self.metadata.is_empty and parse_filename:
|
|
self.use_filename()
|
|
|
|
self.metadata.apply_default_page_list(self.comic_archive.get_page_name_list())
|
|
|
|
self.update_cover_image()
|
|
|
|
if self.page_browser is not None and self.comic_archive is not None:
|
|
self.page_browser.set_comic_archive(self.comic_archive)
|
|
self.page_browser.metadata = self.metadata
|
|
|
|
self.metadata_to_form()
|
|
self.clear_dirty_flag() # also updates the app title
|
|
self.update_info_box()
|
|
self.update_menus()
|
|
self.update_app_title()
|
|
|
|
def update_cover_image(self) -> None:
|
|
cover_idx = self.metadata.get_cover_page_index_list()[0]
|
|
if self.comic_archive is not None:
|
|
self.archiveCoverWidget.set_archive(self.comic_archive, cover_idx)
|
|
|
|
def update_menus(self) -> None:
|
|
enabled = self.comic_archive is not None
|
|
writeable = self.comic_archive is not None and self.comic_archive.is_writable()
|
|
self.actionApplyCBLTransform.setEnabled(enabled)
|
|
self.actionAutoIdentify.setEnabled(enabled)
|
|
self.actionAutoTag.setEnabled(enabled)
|
|
self.actionCopyTags.setEnabled(enabled)
|
|
self.actionParse_Filename.setEnabled(enabled)
|
|
self.actionParse_Filename_split_words.setEnabled(enabled)
|
|
self.actionReCalcArchiveInfo.setEnabled(enabled)
|
|
self.actionRemoveAuto.setEnabled(enabled)
|
|
self.actionRename.setEnabled(enabled)
|
|
self.actionRepackage.setEnabled(enabled)
|
|
|
|
self.menuRemove.setEnabled(enabled)
|
|
self.menuViewRawTags.setEnabled(enabled)
|
|
if self.comic_archive is not None:
|
|
for tag_id in tags:
|
|
self.view_tag_actions[tag_id].setEnabled(tags[tag_id].enabled and self.comic_archive.has_tags(tag_id))
|
|
self.remove_tag_actions[tag_id].setEnabled(tags[tag_id].enabled and self.comic_archive.has_tags(tag_id))
|
|
|
|
if writeable:
|
|
self.actionWrite_Tags
|
|
self.actionWrite_Tags.triggered.disconnect()
|
|
self.actionWrite_Tags.triggered.connect(self.prompt_write_tags)
|
|
self.actionWrite_Tags.setToolTip("")
|
|
self.actionWrite_Tags.setStatusTip("")
|
|
else:
|
|
self.actionWrite_Tags.triggered.disconnect()
|
|
self.actionWrite_Tags.triggered.connect(
|
|
functools.partial(
|
|
self._toast,
|
|
"Unable to write Tags",
|
|
f"Archive is not writeable\n{self.comic_archive.path}",
|
|
5000,
|
|
)
|
|
)
|
|
self.actionWrite_Tags.setToolTip("Archive is not writeable")
|
|
self.actionWrite_Tags.setStatusTip("Archive is not writeable")
|
|
|
|
def update_info_box(self) -> None:
|
|
ca = self.comic_archive
|
|
|
|
if ca is None:
|
|
self.lblFilename.setText("")
|
|
self.lblArchiveType.setText("")
|
|
self.lblTagList.setText("")
|
|
self.lblPageCount.setText("")
|
|
return
|
|
|
|
filename = os.path.basename(ca.path)
|
|
filename = os.path.splitext(filename)[0]
|
|
filename = FileNameParser().fix_spaces(filename, False)
|
|
|
|
self.lblFilename.setText(filename)
|
|
|
|
self.lblArchiveType.setText(ca.archiver.name() + " archive")
|
|
|
|
page_count = f" ({ca.get_number_of_pages()} pages)"
|
|
self.lblPageCount.setText(page_count)
|
|
|
|
supported_md = ca.get_supported_tags()
|
|
tag_info = ""
|
|
for md in supported_md:
|
|
if ca.has_tags(md):
|
|
tag_info += "• " + tags[md].name() + "\n"
|
|
|
|
self.lblTagList.setText(tag_info)
|
|
|
|
def set_dirty_flag(self) -> None:
|
|
if not self.dirty_flag:
|
|
self.dirty_flag = True
|
|
self.fileSelectionList.set_modified_flag(True)
|
|
self.update_app_title()
|
|
|
|
def clear_dirty_flag(self) -> None:
|
|
if self.dirty_flag:
|
|
self.dirty_flag = False
|
|
self.fileSelectionList.set_modified_flag(False)
|
|
self.update_app_title()
|
|
|
|
def connect_dirty_flag_signals(self) -> None:
|
|
# recursively connect the tab form child slots
|
|
self.connect_child_dirty_flag_signals(self.tabWidget)
|
|
|
|
def connect_child_dirty_flag_signals(self, widget: QtCore.QObject) -> None:
|
|
if isinstance(widget, QtWidgets.QLineEdit):
|
|
widget.textEdited.connect(self.set_dirty_flag)
|
|
if isinstance(widget, QtWidgets.QTextEdit):
|
|
widget.textChanged.connect(self.set_dirty_flag)
|
|
if isinstance(widget, QtWidgets.QComboBox):
|
|
widget.currentIndexChanged.connect(self.set_dirty_flag)
|
|
if isinstance(widget, QtWidgets.QCheckBox):
|
|
widget.stateChanged.connect(self.set_dirty_flag)
|
|
if isinstance(widget, QtWidgets.QListWidget):
|
|
widget.itemChanged.connect(self.set_dirty_flag)
|
|
|
|
# recursive call on children
|
|
for child in widget.children():
|
|
if child != self.page_list_editor:
|
|
self.connect_child_dirty_flag_signals(child)
|
|
|
|
def clear_form(self) -> None:
|
|
# get a minty fresh metadata object
|
|
self.metadata = GenericMetadata()
|
|
|
|
# recursively clear the tab form
|
|
self.clear_children(self.tabWidget)
|
|
|
|
# clear the dirty flag, since there is nothing in there now to lose
|
|
self.clear_dirty_flag()
|
|
self.update_ui_for_archive(parse_filename=False)
|
|
|
|
def clear_children(self, widget: QtCore.QObject) -> None:
|
|
if isinstance(widget, (QtWidgets.QLineEdit, QtWidgets.QTextEdit)):
|
|
widget.setText("")
|
|
if isinstance(widget, QtWidgets.QComboBox):
|
|
widget.setCurrentIndex(0)
|
|
if isinstance(widget, QtWidgets.QCheckBox):
|
|
widget.setChecked(False)
|
|
if isinstance(widget, QtWidgets.QTableWidget):
|
|
widget.setRowCount(0)
|
|
if isinstance(widget, QtWidgets.QListWidget):
|
|
widget.clear()
|
|
|
|
# recursive call on children
|
|
for child in widget.children():
|
|
self.clear_children(child)
|
|
|
|
# Copy all of the metadata object into the form.
|
|
# Merging of metadata should be done via the overlay function
|
|
def metadata_to_form(self) -> None:
|
|
def assign_text(field: QtWidgets.QLineEdit | QtWidgets.QTextEdit, value: Any) -> None:
|
|
if value is not None:
|
|
if isinstance(field, QtWidgets.QTextEdit) and False:
|
|
field.setPlainText(str(value))
|
|
else:
|
|
field.setText(str(value))
|
|
|
|
md = self.metadata
|
|
|
|
original_hash = md.original_hash or FileHash("", "")
|
|
self.cbHashName.setCurrentText(original_hash.name or self.config[0].internal__embedded_hash_type)
|
|
assign_text(self.leOriginalHash, original_hash.hash)
|
|
assign_text(self.leSeries, md.series)
|
|
assign_text(self.leIssueNum, md.issue)
|
|
assign_text(self.leIssueCount, md.issue_count)
|
|
assign_text(self.leVolumeNum, md.volume)
|
|
assign_text(self.leVolumeCount, md.volume_count)
|
|
assign_text(self.leTitle, md.title)
|
|
assign_text(self.lePublisher, md.publisher)
|
|
assign_text(self.lePubMonth, md.month)
|
|
assign_text(self.lePubYear, md.year)
|
|
assign_text(self.lePubDay, md.day)
|
|
assign_text(self.leGenre, ",".join(md.genres))
|
|
assign_text(self.leImprint, md.imprint)
|
|
assign_text(self.teComments, md.description)
|
|
assign_text(self.teNotes, md.notes)
|
|
assign_text(self.leStoryArc, ",".join(md.story_arcs))
|
|
assign_text(self.leScanInfo, md.scan_info)
|
|
assign_text(self.leSeriesGroup, ",".join(md.series_groups))
|
|
assign_text(self.leAltSeries, md.alternate_series)
|
|
assign_text(self.leAltIssueNum, md.alternate_number)
|
|
assign_text(self.leAltIssueCount, md.alternate_count)
|
|
self.leWebLink.clear()
|
|
for u in md.web_links:
|
|
self.add_weblink_item(u.url)
|
|
assign_text(self.teCharacters, "\n".join(md.characters))
|
|
assign_text(self.teTeams, "\n".join(md.teams))
|
|
assign_text(self.teLocations, "\n".join(md.locations))
|
|
|
|
assign_text(self.leGtin, md.gtin)
|
|
|
|
self.dsbCriticalRating.setValue(md.critical_rating or 0.0)
|
|
|
|
if md.format is not None and md.format != "":
|
|
i = self.cbFormat.findText(md.format)
|
|
if i == -1:
|
|
self.cbFormat.setEditText(md.format)
|
|
else:
|
|
self.cbFormat.setCurrentIndex(i)
|
|
|
|
if md.maturity_rating is not None and md.maturity_rating != "":
|
|
i = self.cbMaturityRating.findText(md.maturity_rating)
|
|
if i == -1:
|
|
self.cbMaturityRating.setEditText(md.maturity_rating)
|
|
else:
|
|
self.cbMaturityRating.setCurrentIndex(i)
|
|
else:
|
|
self.cbMaturityRating.setCurrentIndex(0)
|
|
|
|
if md.language is not None:
|
|
i = self.cbLanguage.findData(md.language)
|
|
self.cbLanguage.setCurrentIndex(i)
|
|
else:
|
|
self.cbLanguage.setCurrentIndex(0)
|
|
|
|
if md.country is not None:
|
|
i = self.cbCountry.findText(md.country)
|
|
self.cbCountry.setCurrentIndex(i)
|
|
else:
|
|
self.cbCountry.setCurrentIndex(0)
|
|
|
|
if md.manga is not None:
|
|
i = self.cbManga.findData(md.manga)
|
|
self.cbManga.setCurrentIndex(i)
|
|
else:
|
|
self.cbManga.setCurrentIndex(0)
|
|
|
|
if md.black_and_white:
|
|
self.cbBW.setChecked(True)
|
|
else:
|
|
self.cbBW.setChecked(False)
|
|
|
|
self.teTags.setText(", ".join(md.tags))
|
|
|
|
self.twCredits.setRowCount(0)
|
|
|
|
if md.credits is not None and len(md.credits) != 0:
|
|
self.twCredits.setSortingEnabled(False)
|
|
|
|
for row, credit in enumerate(md.credits):
|
|
# Always add to the list. We don't want to accidentally remove credits a user may want
|
|
self.add_new_credit_entry(row, credit)
|
|
if self.comic_archive:
|
|
self.page_list_editor.set_data(self.comic_archive, self.metadata.pages)
|
|
|
|
self.twCredits.setSortingEnabled(True)
|
|
self.update_credit_colors()
|
|
self.toggle_enable_embedding_hashes()
|
|
|
|
def add_new_credit_entry(self, row: int, credit: Credit) -> None:
|
|
self.twCredits.insertRow(row)
|
|
|
|
item = QtWidgets.QTableWidgetItem(credit.role)
|
|
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
|
|
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, credit.role)
|
|
self.twCredits.setItem(row, self.md_attributes["credits.role"], item)
|
|
|
|
language = utils.get_language_from_iso(credit.language) or credit.language
|
|
item = QtWidgets.QTableWidgetItem(language)
|
|
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, credit.language)
|
|
item.setData(QtCore.Qt.ItemDataRole.UserRole, credit.language)
|
|
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
|
|
self.twCredits.setItem(row, self.md_attributes["credits.language"], item)
|
|
|
|
item = QtWidgets.QTableWidgetItem(credit.person)
|
|
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, credit.person)
|
|
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
|
|
self.twCredits.setItem(row, self.md_attributes["credits.person"], item)
|
|
|
|
item = QtWidgets.QTableWidgetItem("")
|
|
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
|
|
self.twCredits.setItem(row, self.md_attributes["credits.primary"], item)
|
|
self.update_credit_primary_flag(row, credit.primary)
|
|
|
|
def get_dupe_credit(self, row: int | None, role: str, name: str) -> int:
|
|
for r in range(self.twCredits.rowCount()):
|
|
if r == row:
|
|
continue
|
|
|
|
if (
|
|
self.twCredits.item(r, self.md_attributes["credits.role"]).text() == role
|
|
and self.twCredits.item(r, self.md_attributes["credits.person"]).text() == name
|
|
):
|
|
return r
|
|
|
|
return -1
|
|
|
|
def form_to_metadata(self) -> None:
|
|
# copy the data from the form into the metadata
|
|
md = GenericMetadata()
|
|
md.is_empty = False
|
|
|
|
if utils.xlate(self.cbHashName.currentText()) and utils.xlate(self.leOriginalHash.text()):
|
|
md.original_hash = FileHash(
|
|
utils.xlate(self.cbHashName.currentText()) or "", utils.xlate(self.leOriginalHash.text()) or ""
|
|
)
|
|
md.alternate_number = utils.xlate(IssueString(self.leAltIssueNum.text()).as_string())
|
|
md.issue = utils.xlate(IssueString(self.leIssueNum.text()).as_string())
|
|
md.issue_count = utils.xlate_int(self.leIssueCount.text())
|
|
md.volume = utils.xlate_int(self.leVolumeNum.text())
|
|
md.volume_count = utils.xlate_int(self.leVolumeCount.text())
|
|
md.month = utils.xlate_int(self.lePubMonth.text())
|
|
md.year = utils.xlate_int(self.lePubYear.text())
|
|
md.day = utils.xlate_int(self.lePubDay.text())
|
|
md.alternate_count = utils.xlate_int(self.leAltIssueCount.text())
|
|
|
|
md.series = utils.xlate(self.leSeries.text())
|
|
md.title = utils.xlate(self.leTitle.text())
|
|
md.publisher = utils.xlate(self.lePublisher.text())
|
|
md.genres = set(utils.split(self.leGenre.text(), ","))
|
|
md.imprint = utils.xlate(self.leImprint.text())
|
|
md.description = utils.xlate(self.teComments.toPlainText())
|
|
md.notes = utils.xlate(self.teNotes.toPlainText())
|
|
md.maturity_rating = utils.xlate(self.cbMaturityRating.currentText())
|
|
|
|
md.critical_rating = utils.xlate_float(self.dsbCriticalRating.cleanText())
|
|
if md.critical_rating == 0.0:
|
|
md.critical_rating = None
|
|
|
|
md.story_arcs = utils.split(self.leStoryArc.text(), ",")
|
|
md.scan_info = utils.xlate(self.leScanInfo.text())
|
|
md.series_groups = utils.split(self.leSeriesGroup.text(), ",")
|
|
md.alternate_series = utils.xlate(self.leAltSeries.text())
|
|
md.gtin = utils.xlate(self.leGtin.text())
|
|
md.web_links = [utils.parse_url(self.leWebLink.item(i).text()) for i in range(self.leWebLink.count())]
|
|
md.characters = set(utils.split(self.teCharacters.toPlainText(), "\n"))
|
|
md.teams = set(utils.split(self.teTeams.toPlainText(), "\n"))
|
|
md.locations = set(utils.split(self.teLocations.toPlainText(), "\n"))
|
|
|
|
md.format = utils.xlate(self.cbFormat.currentText())
|
|
md.country = utils.xlate(self.cbCountry.currentText())
|
|
|
|
md.language = utils.xlate(self.cbLanguage.itemData(self.cbLanguage.currentIndex()))
|
|
|
|
md.manga = utils.xlate(self.cbManga.itemData(self.cbManga.currentIndex()))
|
|
|
|
# Make a list from the comma delimited tags string
|
|
md.tags = set(utils.split(self.teTags.toPlainText(), ","))
|
|
|
|
md.black_and_white = self.cbBW.isChecked()
|
|
|
|
# get the credits from the table
|
|
md.credits = []
|
|
|
|
for row in range(self.twCredits.rowCount()):
|
|
role = self.twCredits.item(row, self.md_attributes["credits.role"]).text()
|
|
lang = (
|
|
self.twCredits.item(row, self.md_attributes["credits.language"]).data(QtCore.Qt.ItemDataRole.UserRole)
|
|
or self.twCredits.item(row, self.md_attributes["credits.language"]).text()
|
|
)
|
|
name = self.twCredits.item(row, self.md_attributes["credits.person"]).text()
|
|
primary_flag = self.twCredits.item(row, self.md_attributes["credits.primary"]).text() != ""
|
|
|
|
md.add_credit(name, role, bool(primary_flag), lang)
|
|
|
|
md.pages = self.page_list_editor.get_page_list()
|
|
|
|
# Preserve hidden md values
|
|
md.data_origin = self.metadata.data_origin
|
|
md.issue_id = self.metadata.issue_id
|
|
md.series_id = self.metadata.series_id
|
|
|
|
md.price = self.metadata.price
|
|
md.identifier = self.metadata.identifier
|
|
md.rights = self.metadata.rights
|
|
|
|
self.metadata = md
|
|
|
|
def use_filename(self) -> None:
|
|
self._use_filename()
|
|
|
|
def _use_filename(self, split_words: bool = False) -> None:
|
|
if self.comic_archive is not None:
|
|
# copy the form onto metadata object
|
|
self.form_to_metadata()
|
|
new_metadata = self.comic_archive.metadata_from_filename(
|
|
self.config[0].Filename_Parsing__filename_parser,
|
|
self.config[0].Filename_Parsing__remove_c2c,
|
|
self.config[0].Filename_Parsing__remove_fcbd,
|
|
self.config[0].Filename_Parsing__remove_publisher,
|
|
split_words,
|
|
self.config[0].Filename_Parsing__allow_issue_start_with_letter,
|
|
self.config[0].Filename_Parsing__protofolius_issue_number_scheme,
|
|
)
|
|
self.metadata.overlay(new_metadata, mode=comicapi.merge.Mode.OVERLAY, merge_lists=False)
|
|
self.metadata_to_form()
|
|
|
|
def use_filename_split(self) -> None:
|
|
self._use_filename(True)
|
|
|
|
def select_folder(self) -> None:
|
|
self.select_file(folder_mode=True)
|
|
|
|
def select_folder_archive(self) -> None:
|
|
self.select_file(folder_mode=True, recursive=False)
|
|
|
|
def select_file(self, folder_mode: bool = False, recursive: bool = True) -> None:
|
|
dialog = self.file_dialog(folder_mode=folder_mode)
|
|
if recursive:
|
|
dialog.filesSelected.connect(self._load_files)
|
|
else:
|
|
dialog.fileSelected.connect(self._load_single_file)
|
|
dialog.open()
|
|
|
|
def _load_single_file(self, file: str) -> None:
|
|
if file:
|
|
self.fileSelectionList.twList.selectRow(self.fileSelectionList.add_path_item(file)[0])
|
|
|
|
def _load_files(self, files: list[str]) -> None:
|
|
if files:
|
|
self.fileSelectionList.add_path_list(files)
|
|
|
|
def file_dialog(self, folder_mode: bool = False) -> QtWidgets.QFileDialog:
|
|
dialog = QtWidgets.QFileDialog(self)
|
|
if folder_mode:
|
|
dialog.setFileMode(QtWidgets.QFileDialog.FileMode.Directory)
|
|
else:
|
|
archive_filter = "Comic archive files (*.cbz *.zip *.cbr *.rar *.cb7 *.7z)"
|
|
filters = [archive_filter, "Any files (*)"]
|
|
dialog.setNameFilters(filters)
|
|
dialog.setFileMode(QtWidgets.QFileDialog.FileMode.ExistingFiles)
|
|
|
|
if os.environ.get("XDG_SESSION_DESKTOP", "") == "KDE":
|
|
dialog.setOption(QtWidgets.QFileDialog.Option.DontUseNativeDialog)
|
|
|
|
if self.config[0].internal__last_opened_folder is not None:
|
|
dialog.setDirectory(self.config[0].internal__last_opened_folder)
|
|
return dialog
|
|
|
|
def auto_identify_search(self) -> None:
|
|
if self.comic_archive is None:
|
|
OptionalMessageDialog.warning(self, "Automatic Identify Search", "You need to load a comic first!")
|
|
return
|
|
|
|
self.query_online(autoselect=True)
|
|
|
|
def literal_search(self) -> None:
|
|
self.query_online(autoselect=False, literal=True)
|
|
|
|
def query_online(self, autoselect: bool = False, literal: bool = False) -> None:
|
|
issue_number = str(self.leIssueNum.text()).strip()
|
|
|
|
# Only need this check is the source has issue level data.
|
|
if autoselect and issue_number == "":
|
|
OptionalMessageDialog.information(
|
|
self,
|
|
"Automatic Identify Search",
|
|
"Can't auto-identify without an issue number. The auto-tag function has the 'If no issue number, assume \"1\"' option if desired.",
|
|
)
|
|
return
|
|
|
|
if str(self.leSeries.text()).strip() != "":
|
|
series_name = str(self.leSeries.text()).strip()
|
|
else:
|
|
OptionalMessageDialog.information(self, "Online Search", "Need to enter a series name to search.")
|
|
return
|
|
|
|
year = utils.xlate_int(self.lePubYear.text())
|
|
|
|
issue_count = utils.xlate_int(self.leIssueCount.text())
|
|
|
|
self.selector = SeriesSelectionWindow(
|
|
self,
|
|
self.config[0],
|
|
self.current_talker(),
|
|
series_name,
|
|
issue_number,
|
|
self.comic_archive,
|
|
year,
|
|
issue_count,
|
|
autoselect,
|
|
literal,
|
|
)
|
|
self.selector.ratelimit.connect(self.on_ratelimit)
|
|
|
|
self.selector.setWindowTitle(f"Search: '{series_name}' - Select Series")
|
|
self.selector.finished.connect(self.finish_query)
|
|
|
|
self.selector.perform_query()
|
|
|
|
def finish_query(self, result: list[GenericMetadata]) -> None:
|
|
if not (result and self.selector):
|
|
return
|
|
|
|
self.querythread = QueryThread(
|
|
self.current_talker(),
|
|
self.selector.issue_id,
|
|
self.selector.series_id,
|
|
self.selector.issue_number,
|
|
)
|
|
self.querythread.finish.connect(self.query_finished)
|
|
self.querythread.ratelimit.connect(self.ratelimit)
|
|
self.querythread.start()
|
|
|
|
def apply_query_metadata(self, new_metadata: GenericMetadata) -> None:
|
|
QtWidgets.QApplication.restoreOverrideCursor()
|
|
|
|
# copy the form onto metadata object
|
|
self.form_to_metadata()
|
|
|
|
if new_metadata is None or new_metadata.is_empty:
|
|
OptionalMessageDialog.critical(self, "Search", f"Could not find an issue {new_metadata} for that series")
|
|
return
|
|
|
|
self.metadata = prepare_metadata(self.metadata, new_metadata, self.config[0])
|
|
# Now push the new combined data into the edit controls
|
|
self.metadata_to_form()
|
|
|
|
def on_ratelimit(self, full_time: float, sleep_time: float) -> None:
|
|
self._toast(
|
|
"Rate Limit Hit!",
|
|
f"Rate limit reached: {full_time:.0f}s until next request. Waiting {sleep_time:.0f}s for ratelimit",
|
|
abs(int(sleep_time * 1000) + 200),
|
|
)
|
|
|
|
def _toast(self, title: str, text: str, duration: int) -> None:
|
|
# QT doesn't initialize QSystemTrayIcon until you call show. on_ratelimit calls .show, .showMessage, and then .hide so that a tray item is not persistent.
|
|
# Specifically macOS will make an invisible tray icon if you keep it visible, even without an icon
|
|
# macOS wil also not popup the notification if the tray icon is hidden immediately after sending a message
|
|
# testing indicates 200ms wait is needed to let the popup show
|
|
|
|
if QtWidgets.QSystemTrayIcon.supportsMessages():
|
|
self.tray.show()
|
|
self.tray.showMessage(
|
|
title,
|
|
text,
|
|
self.windowIcon(),
|
|
abs(duration),
|
|
)
|
|
QtCore.QTimer.singleShot(200, self.tray.hide)
|
|
return
|
|
self.toast = Toast(self)
|
|
# self.toast.__position_relative_to_widget = self
|
|
if qtutils.is_dark_mode():
|
|
self.toast.applyPreset(ToastPreset.WARNING_DARK)
|
|
else:
|
|
self.toast.applyPreset(ToastPreset.WARNING)
|
|
|
|
# Convert to milliseconds, add 200ms because python is slow
|
|
self.toast.setDuration(abs(duration))
|
|
self.toast.setResetDurationOnHover(False)
|
|
self.toast.setFadeOutDuration(50)
|
|
self.toast.setTitle(title)
|
|
self.toast.setText(text)
|
|
self.toast.setAlwaysOnMainScreen(True)
|
|
self.toast.show()
|
|
|
|
def prompt_write_tags(self) -> None:
|
|
if self.metadata is None or self.comic_archive is None:
|
|
OptionalMessageDialog.information(self, "Whoops!", "No data to write!")
|
|
return
|
|
if not self.comic_archive.is_writable():
|
|
self._toast("Unable to write Tags", f"Archive is not writeable\n{self.comic_archive.path}", 5000)
|
|
return
|
|
|
|
if not self.config[0].General__prompt_on_save:
|
|
return self.write_tags()
|
|
|
|
qmsg = OptionalMessageDialog.msg(
|
|
parent=self,
|
|
title="Save Tags",
|
|
msg=f"Are you sure you wish to save {', '.join([tags[tag_id].name() for tag_id in self.config[0].Runtime_Options__tags_write])} tags to this archive?",
|
|
icon=OptionalMessageDialog.Icon.Question,
|
|
)
|
|
qmsg.accepted.connect(self.write_tags)
|
|
qmsg.check_status.connect(self._update_prompt_on_save)
|
|
|
|
def _update_prompt_on_save(self, check: bool) -> None:
|
|
self.config[0].General__prompt_on_save = not check
|
|
|
|
def write_tags(self) -> None:
|
|
QtWidgets.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.CursorShape.WaitCursor))
|
|
self.form_to_metadata()
|
|
assert self.comic_archive
|
|
failed_tag: str = ""
|
|
# Save each tag
|
|
for tag_id in self.config[0].Runtime_Options__tags_write:
|
|
success = self.comic_archive.write_tags(self.metadata, tag_id)
|
|
if not success:
|
|
failed_tag = tags[tag_id].name()
|
|
break
|
|
|
|
self.comic_archive.load_cache(set(tags))
|
|
QtWidgets.QApplication.restoreOverrideCursor()
|
|
|
|
if failed_tag:
|
|
OptionalMessageDialog.warning(
|
|
self,
|
|
"Save failed",
|
|
f"The tag save operation seemed to fail for: {failed_tag}",
|
|
)
|
|
else:
|
|
self.clear_dirty_flag()
|
|
self.update_info_box()
|
|
self.update_menus()
|
|
|
|
# Only try to read if write was successful
|
|
self.metadata, _, error = self.read_selected_tags(
|
|
self.config[0].Runtime_Options__tags_read, self.comic_archive
|
|
)
|
|
if error is not None:
|
|
logger.error("Failed to load metadata for %s: %s", self.comic_archive.path, error)
|
|
OptionalMessageDialog.warning(
|
|
self,
|
|
"Read Failed!",
|
|
f"One or more of the selected read tags failed to load for {self.comic_archive.path}, check log for details",
|
|
)
|
|
|
|
self.fileSelectionList.update_current_row()
|
|
self.update_ui_for_archive()
|
|
|
|
def select_read_tags(self, tag_ids: list[str]) -> None:
|
|
"""Should only be called from the combobox signal"""
|
|
if self.dirty_flag_verification(
|
|
"Change Read Tags",
|
|
"If you change read tag(s) now, data in the form will be lost. Are you sure?",
|
|
):
|
|
# Tags are reversed for display to the user
|
|
self.config[0].Runtime_Options__tags_read = list(reversed(tag_ids))
|
|
self.update_menus()
|
|
if self.comic_archive is not None:
|
|
self.load_archive(self.comic_archive)
|
|
else:
|
|
self.cbSelectedReadTags.dropdownClosed.disconnect()
|
|
self.adjust_tags_combo()
|
|
self.cbSelectedReadTags.dropdownClosed.connect(self.select_read_tags)
|
|
|
|
def select_write_tags(self) -> None:
|
|
self.config[0].Runtime_Options__tags_write = self.cbSelectedWriteTags.currentData()
|
|
self.update_tag_tweaks()
|
|
self.update_menus()
|
|
|
|
def select_source(self, s: int) -> None:
|
|
self.config[0].Sources__source = self.cbx_sources.itemData(s)
|
|
|
|
def update_credit_colors(self) -> None:
|
|
selected_tags = [tags[tag_id] for tag_id in self.config[0].Runtime_Options__tags_write]
|
|
enabled = set()
|
|
for tag in selected_tags:
|
|
enabled.update(tag.supported_attributes)
|
|
|
|
credit_attributes = [x for x in self.md_attributes.items() if "credits." in x[0]]
|
|
|
|
for r in range(self.twCredits.rowCount()):
|
|
w = self.twCredits.item(r, 1)
|
|
supports_role = any(tag.supports_credit_role(str(w.text())) for tag in selected_tags)
|
|
for credit in credit_attributes:
|
|
widget_enabled = credit[0] in enabled
|
|
widget = self.twCredits.item(r, credit[1])
|
|
if credit[0] == "credits.role":
|
|
widget_enabled = widget_enabled and supports_role
|
|
enable_widget(widget, widget_enabled)
|
|
|
|
def update_tag_tweaks(self) -> None:
|
|
# depending on the current data tag, certain fields are disabled
|
|
enabled_widgets = set()
|
|
for tag_id in self.config[0].Runtime_Options__tags_write:
|
|
if not tags[tag_id].enabled:
|
|
continue
|
|
enabled_widgets.update(tags[tag_id].supported_attributes)
|
|
|
|
for md_field, widget in self.md_attributes.items():
|
|
if widget is not None and not isinstance(widget, (int)):
|
|
enable_widget(widget, md_field in enabled_widgets)
|
|
|
|
self.update_credit_colors()
|
|
self.page_list_editor.select_write_tags(self.config[0].Runtime_Options__tags_write)
|
|
self.toggle_enable_embedding_hashes()
|
|
|
|
def add_credit(self) -> None:
|
|
row = self.twCredits.rowCount()
|
|
editor = CreditEditorWindow(self, self.config[0].Runtime_Options__tags_write, row, Credit())
|
|
editor.creditChanged.connect(self._credit_added)
|
|
editor.show()
|
|
|
|
def edit_credit(self) -> None:
|
|
if self.twCredits.currentRow() < 0:
|
|
return
|
|
row = self.twCredits.currentRow()
|
|
lang = str(
|
|
self.twCredits.item(row, self.md_attributes["credits.language"]).data(QtCore.Qt.ItemDataRole.UserRole)
|
|
or utils.get_language_iso(self.twCredits.item(row, self.md_attributes["credits.language"]).text())
|
|
)
|
|
old = Credit(
|
|
self.twCredits.item(row, self.md_attributes["credits.person"]).text(),
|
|
self.twCredits.item(row, self.md_attributes["credits.role"]).text(),
|
|
self.twCredits.item(row, self.md_attributes["credits.primary"]).text() != "",
|
|
lang,
|
|
)
|
|
editor = CreditEditorWindow(self, self.config[0].Runtime_Options__tags_write, row, old, "Edit Credit")
|
|
editor.creditChanged.connect(self._credit_changed)
|
|
editor.show()
|
|
|
|
def update_credit_primary_flag(self, row: int, primary: bool) -> None:
|
|
# if we're clearing a flag do it and quit
|
|
if not primary:
|
|
self.twCredits.item(row, self.md_attributes["credits.primary"]).setText("")
|
|
return
|
|
|
|
# otherwise, we need to check for, and clear, other primaries with same role
|
|
role = str(self.twCredits.item(row, self.md_attributes["credits.role"]).text())
|
|
r = 0
|
|
for r in range(self.twCredits.rowCount()):
|
|
if (
|
|
self.twCredits.item(r, self.md_attributes["credits.primary"]).text() != ""
|
|
and str(self.twCredits.item(r, self.md_attributes["credits.role"]).text()).casefold() == role.casefold()
|
|
):
|
|
self.twCredits.item(r, self.md_attributes["credits.primary"]).setText("")
|
|
|
|
# Now set our new primary
|
|
self.twCredits.item(row, self.md_attributes["credits.primary"]).setText("Yes")
|
|
|
|
def _update_credit(self, credit: Credit, row: int) -> None:
|
|
assert isinstance(row, int)
|
|
lang = utils.get_language_from_iso(credit.language) or credit.language
|
|
self.twCredits.item(row, self.md_attributes["credits.role"]).setText(credit.role)
|
|
self.twCredits.item(row, self.md_attributes["credits.person"]).setText(credit.person)
|
|
self.twCredits.item(row, self.md_attributes["credits.language"]).setText(lang)
|
|
self.twCredits.item(row, self.md_attributes["credits.language"]).setData(
|
|
QtCore.Qt.ItemDataRole.UserRole, credit.language
|
|
)
|
|
self.update_credit_primary_flag(row, credit.primary)
|
|
|
|
self.update_credit_colors()
|
|
self.set_dirty_flag()
|
|
|
|
def _add_credit(self, credit: Credit) -> None:
|
|
# add new entry
|
|
row = self.twCredits.rowCount()
|
|
self.add_new_credit_entry(row, credit)
|
|
|
|
self.update_credit_colors()
|
|
self.set_dirty_flag()
|
|
|
|
def _credit_changed(self, credit: Credit, row: int) -> None:
|
|
dupe_index = self.get_dupe_credit(row, credit.role, credit.person)
|
|
if dupe_index < 0:
|
|
return self._update_credit(credit, row)
|
|
# delete the dupe credit from list
|
|
qmsg = QtWidgets.QMessageBox(parent=self)
|
|
qmsg.addAction(self.actionExit)
|
|
# Qt sucks
|
|
cancel = QtGui.QAction(self)
|
|
cancel.triggered.connect(qmsg.reject)
|
|
cancel.setShortcut(QtGui.QKeySequence.StandardKey.Cancel)
|
|
qmsg.setText("Duplicate Credit!")
|
|
qmsg.setInformativeText(
|
|
"This will create a duplicate credit entry. Would you like to merge the entries, or create a duplicate?"
|
|
)
|
|
qmsg.addButton("Merge", QtWidgets.QMessageBox.ButtonRole.AcceptRole)
|
|
qmsg.addButton("Duplicate", QtWidgets.QMessageBox.ButtonRole.ActionRole)
|
|
|
|
def _merge(credit: Credit, row: int, existing: int) -> None:
|
|
self.twCredits.removeRow(row)
|
|
self._update_credit(credit, existing)
|
|
|
|
qmsg.accepted.connect(functools.partial(_merge, credit, row, dupe_index))
|
|
qmsg.finished.connect(functools.partial(self._credit_finished, credit=credit, row=row))
|
|
|
|
qmsg.show()
|
|
|
|
def _credit_finished(self, code: int, *, credit: Credit, row: int) -> None:
|
|
if code in (QtWidgets.QDialog.DialogCode.Rejected, QtWidgets.QDialog.DialogCode.Accepted):
|
|
return
|
|
self._update_credit(credit, row)
|
|
|
|
def _credit_added(self, credit: Credit) -> None:
|
|
dupe_index = self.get_dupe_credit(None, credit.role, credit.person)
|
|
if dupe_index < 0:
|
|
self._add_credit(credit)
|
|
return
|
|
# delete the dupe credit from list
|
|
qmsg = QtWidgets.QMessageBox(parent=self)
|
|
qmsg.addAction(self.actionExit)
|
|
# Qt sucks
|
|
cancel = QtGui.QAction(self)
|
|
cancel.triggered.connect(qmsg.reject)
|
|
cancel.setShortcut(QtGui.QKeySequence.StandardKey.Cancel)
|
|
qmsg.setText("Duplicate Credit!")
|
|
qmsg.setInformativeText(
|
|
"This will create a duplicate credit entry. Would you like to merge the entries, or create a duplicate?"
|
|
)
|
|
qmsg.addButton("Merge", QtWidgets.QMessageBox.ButtonRole.AcceptRole)
|
|
qmsg.addButton("Duplicate", QtWidgets.QMessageBox.ButtonRole.ActionRole)
|
|
qmsg.accepted.connect(functools.partial(self._update_credit, credit, dupe_index))
|
|
qmsg.finished.connect(functools.partial(self._credit_added_finished, credit=credit))
|
|
qmsg.show()
|
|
|
|
def _credit_added_finished(self, code: int, *, credit: Credit) -> None:
|
|
if code in (QtWidgets.QDialog.DialogCode.Rejected, QtWidgets.QDialog.DialogCode.Accepted):
|
|
return
|
|
self._add_credit(credit)
|
|
|
|
def remove_credit(self) -> None:
|
|
row = self.twCredits.currentRow()
|
|
if row != -1:
|
|
self.twCredits.removeRow(row)
|
|
self.set_dirty_flag()
|
|
|
|
def open_web_link(self) -> None:
|
|
row = self.leWebLink.currentRow()
|
|
if row < 0:
|
|
if self.leWebLink.count() < 1:
|
|
return
|
|
row = 0
|
|
web_link = self.leWebLink.item(row).text()
|
|
try:
|
|
utils.parse_url(web_link)
|
|
webbrowser.open_new_tab(web_link)
|
|
except utils.LocationParseError:
|
|
OptionalMessageDialog.warning(self, "Web Link", "Web Link is invalid.")
|
|
|
|
def show_settings(self) -> None:
|
|
self.settingswin = SettingsWindow(self, self.config, self.talkers)
|
|
self.settingswin.finished.connect(self.adjust_source_combo)
|
|
self.settingswin.accepted.connect(self.settings_accept)
|
|
self.settingswin.show()
|
|
|
|
def settings_accept(self) -> None:
|
|
self.config = self.settingswin.config
|
|
|
|
def set_app_position(self) -> None:
|
|
if self.config[0].internal__window_width != 0:
|
|
self.move(self.config[0].internal__window_x, self.config[0].internal__window_y)
|
|
self.resize(self.config[0].internal__window_width, self.config[0].internal__window_height)
|
|
else:
|
|
screen = QtGui.QGuiApplication.primaryScreen().geometry()
|
|
size = self.frameGeometry()
|
|
self.move(int((screen.width() - size.width()) / 2), int((screen.height() - size.height()) / 2))
|
|
|
|
def adjust_source_combo(self) -> None:
|
|
self.cbx_sources.setCurrentIndex(self.cbx_sources.findData(self.config[0].Sources__source))
|
|
|
|
def adjust_tags_combo(self) -> None:
|
|
"""Select the enabled tags. Since tags are merged in an overlay fashion the last item in the list takes priority. We reverse the order for display to the user"""
|
|
unchecked = set(self.enabled_tags()) - set(self.config[0].Runtime_Options__tags_read)
|
|
for i, tag_id in enumerate(reversed(self.config[0].Runtime_Options__tags_read)):
|
|
if not tags[tag_id].enabled:
|
|
continue
|
|
item_idx = self.cbSelectedReadTags.findData(tag_id)
|
|
self.cbSelectedReadTags.setItemChecked(item_idx, True)
|
|
# Order matters, move items to list order
|
|
if item_idx != i:
|
|
self.cbSelectedReadTags.moveItem(item_idx, row=i)
|
|
for tag_id in unchecked:
|
|
self.cbSelectedReadTags.setItemChecked(self.cbSelectedReadTags.findData(tag_id), False)
|
|
|
|
# select the current tag_id
|
|
unchecked = set(self.enabled_tags()) - set(self.config[0].Runtime_Options__tags_write)
|
|
for tag_id in self.config[0].Runtime_Options__tags_write:
|
|
if not tags[tag_id].enabled:
|
|
continue
|
|
self.cbSelectedWriteTags.setItemChecked(self.cbSelectedWriteTags.findData(tag_id), True)
|
|
for tag_id in unchecked:
|
|
self.cbSelectedWriteTags.setItemChecked(self.cbSelectedWriteTags.findData(tag_id), False)
|
|
self.update_tag_tweaks()
|
|
|
|
def populate_tag_names(self) -> None:
|
|
# First clear all entries (called from settingswindow.py)
|
|
self.cbSelectedWriteTags.clear()
|
|
self.cbSelectedReadTags.clear()
|
|
# Add the entries to the tag comboboxes
|
|
for tag in tags.values():
|
|
if not tag.enabled:
|
|
continue
|
|
if self.config[0].Metadata_Options__use_short_tag_names:
|
|
self.cbSelectedWriteTags.addItem(tag.id.upper(), tag.id)
|
|
self.cbSelectedReadTags.addItem(tag.id.upper(), tag.id)
|
|
else:
|
|
self.cbSelectedWriteTags.addItem(tag.name(), tag.id)
|
|
self.cbSelectedReadTags.addItem(tag.name(), tag.id)
|
|
|
|
def populate_combo_boxes(self) -> None:
|
|
self.populate_tag_names()
|
|
|
|
self.adjust_tags_combo()
|
|
|
|
self.cbHashName: QtWidgets.QComboBox
|
|
self.cbHashName.addItems(sorted(hashlib.algorithms_available))
|
|
|
|
# Add talker entries
|
|
for t_id, talker in self.talkers.items():
|
|
self.cbx_sources.addItem(talker.name, t_id)
|
|
self.adjust_source_combo()
|
|
|
|
# Add the entries to the country combobox
|
|
self.cbCountry.addItem("", "")
|
|
for f in natsort.humansorted(utils.countries().items(), operator.itemgetter(1)):
|
|
self.cbCountry.addItem(f[1], f[0])
|
|
|
|
# Add the entries to the language combobox
|
|
self.cbLanguage.addItem("", "")
|
|
|
|
for f in natsort.humansorted(utils.languages().items(), operator.itemgetter(1)):
|
|
self.cbLanguage.addItem(f[1], f[0])
|
|
|
|
# Add the entries to the manga combobox
|
|
self.cbManga.addItem("", "")
|
|
self.cbManga.addItem("Yes", "Yes")
|
|
self.cbManga.addItem("Yes (Right to Left)", "YesAndRightToLeft")
|
|
self.cbManga.addItem("No", "No")
|
|
|
|
# Add the entries to the maturity combobox
|
|
self.cbMaturityRating.addItem("", "")
|
|
self.cbMaturityRating.addItem("Everyone", "")
|
|
self.cbMaturityRating.addItem("G", "")
|
|
self.cbMaturityRating.addItem("Early Childhood", "")
|
|
self.cbMaturityRating.addItem("Everyone 10+", "")
|
|
self.cbMaturityRating.addItem("PG", "")
|
|
self.cbMaturityRating.addItem("Kids to Adults", "")
|
|
self.cbMaturityRating.addItem("Teen", "")
|
|
self.cbMaturityRating.addItem("M", "")
|
|
self.cbMaturityRating.addItem("MA15+", "")
|
|
self.cbMaturityRating.addItem("Mature 17+", "")
|
|
self.cbMaturityRating.addItem("R18+", "")
|
|
self.cbMaturityRating.addItem("X18+", "")
|
|
self.cbMaturityRating.addItem("Adults Only 18+", "")
|
|
self.cbMaturityRating.addItem("Rating Pending", "")
|
|
|
|
# Add entries to the format combobox
|
|
self.cbFormat.addItem("")
|
|
self.cbFormat.addItem(".1")
|
|
self.cbFormat.addItem("-1")
|
|
self.cbFormat.addItem("1 Shot")
|
|
self.cbFormat.addItem("1/2")
|
|
self.cbFormat.addItem("1-Shot")
|
|
self.cbFormat.addItem("Annotation")
|
|
self.cbFormat.addItem("Annotations")
|
|
self.cbFormat.addItem("Annual")
|
|
self.cbFormat.addItem("Anthology")
|
|
self.cbFormat.addItem("B&W")
|
|
self.cbFormat.addItem("B/W")
|
|
self.cbFormat.addItem("B&&W")
|
|
self.cbFormat.addItem("Black & White")
|
|
self.cbFormat.addItem("Box Set")
|
|
self.cbFormat.addItem("Box-Set")
|
|
self.cbFormat.addItem("Crossover")
|
|
self.cbFormat.addItem("Director's Cut")
|
|
self.cbFormat.addItem("Epilogue")
|
|
self.cbFormat.addItem("Event")
|
|
self.cbFormat.addItem("FCBD")
|
|
self.cbFormat.addItem("Flyer")
|
|
self.cbFormat.addItem("Giant")
|
|
self.cbFormat.addItem("Giant Size")
|
|
self.cbFormat.addItem("Giant-Size")
|
|
self.cbFormat.addItem("Graphic Novel")
|
|
self.cbFormat.addItem("Hardcover")
|
|
self.cbFormat.addItem("Hard-Cover")
|
|
self.cbFormat.addItem("King")
|
|
self.cbFormat.addItem("King Size")
|
|
self.cbFormat.addItem("King-Size")
|
|
self.cbFormat.addItem("Limited Series")
|
|
self.cbFormat.addItem("Magazine")
|
|
self.cbFormat.addItem("-1")
|
|
self.cbFormat.addItem("NSFW")
|
|
self.cbFormat.addItem("One Shot")
|
|
self.cbFormat.addItem("One-Shot")
|
|
self.cbFormat.addItem("Point 1")
|
|
self.cbFormat.addItem("Preview")
|
|
self.cbFormat.addItem("Prologue")
|
|
self.cbFormat.addItem("Reference")
|
|
self.cbFormat.addItem("Review")
|
|
self.cbFormat.addItem("Reviewed")
|
|
self.cbFormat.addItem("Scanlation")
|
|
self.cbFormat.addItem("Script")
|
|
self.cbFormat.addItem("Series")
|
|
self.cbFormat.addItem("Sketch")
|
|
self.cbFormat.addItem("Special")
|
|
self.cbFormat.addItem("TPB")
|
|
self.cbFormat.addItem("Trade Paper Back")
|
|
self.cbFormat.addItem("WebComic")
|
|
self.cbFormat.addItem("Web Comic")
|
|
self.cbFormat.addItem("Year 1")
|
|
self.cbFormat.addItem("Year One")
|
|
|
|
def remove_auto(self) -> None:
|
|
self.prompt_remove_tags(self.config[0].Runtime_Options__tags_write)
|
|
|
|
def prompt_remove_tags(self, tag_ids: list[str]) -> None:
|
|
# remove the indicated tag_ids from the archive
|
|
ca_list = self.fileSelectionList.get_selected_archive_list()
|
|
md_count = 0
|
|
file_md_count = {}
|
|
for tag_id in tag_ids:
|
|
file_md_count[tag_id] = 0
|
|
for ca in ca_list:
|
|
for tag_id in tag_ids:
|
|
if ca.has_tags(tag_id):
|
|
md_count += 1
|
|
file_md_count[tag_id] += 1
|
|
|
|
if md_count == 0:
|
|
OptionalMessageDialog.information(
|
|
self,
|
|
"Remove Tags",
|
|
f"No archives with {', '.join([tags[tag_id].name() for tag_id in tag_ids])} tags selected!",
|
|
)
|
|
return
|
|
|
|
if md_count != 0 and not self.dirty_flag_verification(
|
|
"Remove Tags", "If you remove tags now, unsaved data in the form will be lost. Are you sure?"
|
|
):
|
|
return
|
|
|
|
OptionalMessageDialog.question(
|
|
self,
|
|
"Remove Tags",
|
|
f"Are you sure you wish to remove {', '.join([f'{tags[tag_id].name()} tags from {count} files' for tag_id, count in file_md_count.items()])} removing a total of {md_count} tag(s)?",
|
|
check_text=None,
|
|
).accepted.connect(functools.partial(self.remove_tags, tag_ids, md_count))
|
|
|
|
def remove_tags(self, tag_ids: list[str], md_count: int) -> None:
|
|
progdialog = QtWidgets.QProgressDialog("", "Cancel", 0, md_count, self)
|
|
progdialog.setWindowTitle("Removing Tags")
|
|
progdialog.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
|
|
progdialog.setMinimumDuration(300)
|
|
center_window_on_parent(progdialog)
|
|
|
|
ca_list = self.fileSelectionList.get_selected_archive_list()
|
|
|
|
failed_list = []
|
|
success_count = 0
|
|
for prog_idx, ca in enumerate(ca_list, 1):
|
|
if prog_idx % 10 == 0:
|
|
QtCore.QCoreApplication.processEvents()
|
|
if progdialog.wasCanceled():
|
|
break
|
|
progdialog.setValue(prog_idx)
|
|
progdialog.setLabelText(str(ca.path))
|
|
for tag_id in tag_ids:
|
|
if ca.has_tags(tag_id) and ca.is_writable():
|
|
if ca.remove_tags(tag_id):
|
|
success_count += 1
|
|
else:
|
|
failed_list.append(ca.path)
|
|
# Abandon any further tag removals to prevent any greater damage to archive
|
|
break
|
|
ca.reset_cache()
|
|
ca.load_cache(self.enabled_tags())
|
|
|
|
progdialog.hide()
|
|
QtCore.QCoreApplication.processEvents()
|
|
self._reload_page()
|
|
self.update_info_box()
|
|
self.update_menus()
|
|
|
|
summary = f"Successfully removed {success_count} tags in archive(s)."
|
|
if failed_list:
|
|
summary += f"\n\nThe remove operation failed in the following {len(failed_list)} archive(s):\n"
|
|
for f in failed_list:
|
|
summary += f"\t{f}\n"
|
|
|
|
dlg = LogWindow(self)
|
|
dlg.set_text(summary)
|
|
dlg.setWindowTitle("Tag Remove Summary")
|
|
dlg.show()
|
|
|
|
def prompt_copy_tags(self) -> None:
|
|
# copy the indicated tags in the archive
|
|
ca_list = self.fileSelectionList.get_selected_archive_list()
|
|
src_count = 0
|
|
|
|
src_tag_ids: list[str] = self.config[0].Runtime_Options__tags_read
|
|
dest_tag_ids: list[str] = self.config[0].Runtime_Options__tags_write
|
|
|
|
if len(src_tag_ids) == 1 and src_tag_ids[0] in dest_tag_ids:
|
|
# Remove the read tag from the write tag
|
|
dest_tag_ids.remove(src_tag_ids[0])
|
|
|
|
if not dest_tag_ids:
|
|
OptionalMessageDialog.information(
|
|
self, "Copy Tags", "Can't copy tag tag onto itself. Read tag and modify tag must be different."
|
|
)
|
|
return
|
|
|
|
for ca in ca_list:
|
|
for tag_id in src_tag_ids:
|
|
if ca.has_tags(tag_id):
|
|
src_count += 1
|
|
continue
|
|
|
|
if src_count == 0:
|
|
OptionalMessageDialog.information(
|
|
self,
|
|
"Copy Tags",
|
|
f"No archives with {', '.join([tags[tag_id].name() for tag_id in src_tag_ids])} tags selected!",
|
|
)
|
|
return
|
|
|
|
if src_count != 0 and not self.dirty_flag_verification(
|
|
"Copy Tags", "If you copy tags now, unsaved data in the form may be lost. Are you sure?"
|
|
):
|
|
return
|
|
|
|
details = (
|
|
f"Are you sure you wish to copy the combined (with overlay order) tags of "
|
|
f"{', '.join([tags[tag_id].name() for tag_id in src_tag_ids])} "
|
|
f"to {', '.join([tags[tag_id].name() for tag_id in dest_tag_ids])} tags in "
|
|
f"{src_count} archive(s)?"
|
|
)
|
|
|
|
OptionalMessageDialog.question(
|
|
self,
|
|
"Copy Tags",
|
|
details,
|
|
icon=OptionalMessageDialog.Icon.Question,
|
|
check_text=None,
|
|
).accepted.connect(functools.partial(self.copy_tags, src_tag_ids, dest_tag_ids, src_count))
|
|
|
|
def copy_tags(self, src_tag_ids: list[str], dest_tag_ids: list[str], src_count: int) -> None:
|
|
ca_list = self.fileSelectionList.get_selected_archive_list()
|
|
prog_dialog = QtWidgets.QProgressDialog("", "Cancel", 0, src_count, self)
|
|
prog_dialog.setWindowTitle("Copying Tags")
|
|
prog_dialog.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
|
|
prog_dialog.setMinimumDuration(1000)
|
|
center_window_on_parent(prog_dialog)
|
|
QtCore.QCoreApplication.processEvents()
|
|
|
|
failed_list = []
|
|
success_count = 0
|
|
for prog_idx, ca in enumerate(ca_list, 1):
|
|
if prog_idx % 10 == 0:
|
|
QtCore.QCoreApplication.processEvents()
|
|
ca_saved = False
|
|
md, _, error = self.read_selected_tags(src_tag_ids, ca)
|
|
if error is not None:
|
|
failed_list.append(ca.path)
|
|
continue
|
|
if md.is_empty:
|
|
continue
|
|
|
|
for tag_id in dest_tag_ids:
|
|
if ca.has_tags(tag_id):
|
|
if prog_dialog.wasCanceled():
|
|
break
|
|
|
|
prog_dialog.setValue(prog_idx)
|
|
prog_dialog.setLabelText(str(ca.path))
|
|
center_window_on_parent(prog_dialog)
|
|
|
|
if tag_id == "cbi" and self.config[0].Metadata_Options__apply_transform_on_bulk_operation:
|
|
md = CBLTransformer(md, self.config[0]).apply()
|
|
|
|
if ca.write_tags(md, tag_id):
|
|
if not ca_saved:
|
|
success_count += 1
|
|
ca_saved = True
|
|
else:
|
|
failed_list.append(ca.path)
|
|
|
|
ca.reset_cache()
|
|
ca.load_cache({*self.config[0].Runtime_Options__tags_read, *self.config[0].Runtime_Options__tags_write})
|
|
|
|
prog_dialog.hide()
|
|
QtCore.QCoreApplication.processEvents()
|
|
self._reload_page()
|
|
self.update_info_box()
|
|
self.update_menus()
|
|
|
|
summary = f"Successfully copied tags in {success_count} archive(s)."
|
|
if failed_list:
|
|
summary += f"\n\nThe copy operation failed in the following {len(failed_list)} archive(s):\n"
|
|
for f in failed_list:
|
|
summary += f"\t{f}\n"
|
|
|
|
dlg = LogWindow(self)
|
|
dlg.set_text(summary)
|
|
dlg.setWindowTitle("Tag Copy Summary")
|
|
dlg.show()
|
|
|
|
def auto_tag_log(self, text: str) -> None:
|
|
if self.atprogdialog is not None:
|
|
self.atprogdialog.textEdit.append(text.rstrip())
|
|
self.atprogdialog.textEdit.ensureCursorVisible()
|
|
QtCore.QCoreApplication.processEvents()
|
|
|
|
def auto_tag(self) -> None:
|
|
ca_list = self.fileSelectionList.get_selected_archive_list()
|
|
tag_names = ", ".join([tags[tag_id].name() for tag_id in self.config[0].Runtime_Options__tags_write])
|
|
|
|
if not ca_list:
|
|
OptionalMessageDialog.information(self, "Auto-Tag", "No archives selected!")
|
|
return
|
|
|
|
if not self.dirty_flag_verification(
|
|
"Auto-Tag", "If you auto-tag now, unsaved data in the form will be lost. Are you sure?"
|
|
):
|
|
return
|
|
|
|
atstartdlg = AutoTagStartWindow(
|
|
self,
|
|
self.config[0],
|
|
(
|
|
f"You have selected {len(ca_list)} archive(s) to automatically identify and write "
|
|
+ f"{tag_names} tags to.\n\nPlease choose config below, and select OK to Auto-Tag."
|
|
),
|
|
)
|
|
|
|
atstartdlg.startAutoTag.connect(self._start_auto_tag)
|
|
|
|
atstartdlg.open()
|
|
|
|
def _start_auto_tag(self, auto_tag: AutoTagSettings) -> None:
|
|
# persist some settings because it's probably going to be used next time
|
|
self.config[0].Auto_Tag__save_on_low_confidence = auto_tag.settings["save_on_low_confidence"]
|
|
self.config[0].Auto_Tag__use_year_when_identifying = auto_tag.settings["use_year_when_identifying"]
|
|
self.config[0].Auto_Tag__assume_issue_one = auto_tag.settings["assume_issue_one"]
|
|
self.config[0].Auto_Tag__ignore_leading_numbers_in_filename = auto_tag.settings[
|
|
"ignore_leading_numbers_in_filename"
|
|
]
|
|
self.config[0].internal__remove_archive_after_successful_match = auto_tag.remove_after_success
|
|
|
|
ca_list = self.fileSelectionList.get_selected_archive_list()
|
|
self.atprogdialog = AutoTagProgressWindow(self, self.current_talker())
|
|
self.atprogdialog.progressBar.setMaximum(len(ca_list))
|
|
self.atprogdialog.setWindowTitle("Auto-Tagging")
|
|
|
|
center_window_on_parent(self.atprogdialog)
|
|
temp_config = auto_tag.new_settings(self.config[0])
|
|
self.autotagthread = AutoTagThread(auto_tag.search_string, ca_list, temp_config, self.current_talker())
|
|
|
|
self.autotagthread.autoTagComplete.connect(functools.partial(self.auto_tag_finished, config=temp_config))
|
|
self.autotagthread.autoTagLogMsg.connect(self.auto_tag_log)
|
|
self.autotagthread.autoTagProgress.connect(self.atprogdialog.on_progress)
|
|
self.autotagthread.ratelimit.connect(self.ratelimit)
|
|
|
|
# We don't use rejected as that closes the dialog
|
|
self.atprogdialog.cancel.connect(self.autotagthread.cancel)
|
|
|
|
self.auto_tag_log("==========================================================================\n")
|
|
self.auto_tag_log(f"Auto-Tagging Started for {len(ca_list)} items\n")
|
|
self.autotagthread.start()
|
|
self.atprogdialog.open()
|
|
|
|
def auto_tag_finished(
|
|
self, match_results: OnlineMatchResults, archives_to_remove: list[ComicArchive], *, config: ct_ns
|
|
) -> None:
|
|
tag_names = ", ".join([tags[tag_id].name() for tag_id in self.config[0].Runtime_Options__tags_write])
|
|
if self.atprogdialog:
|
|
self.atprogdialog.accept()
|
|
|
|
self.fileSelectionList.remove_archive_list(archives_to_remove)
|
|
self._reload_page()
|
|
self.atprogdialog = None
|
|
|
|
summary = f"<p>{self.current_talker().attribution}</p>"
|
|
summary += f"Successfully added {tag_names} tags to {len(match_results.good_matches)} archive(s)<br/>"
|
|
|
|
if match_results.multiple_matches:
|
|
summary += f"Archives with multiple matches: {len(match_results.multiple_matches)}<br/>"
|
|
if match_results.low_confidence_matches:
|
|
summary += (
|
|
f"Archives with one or more low-confidence matches: {len(match_results.low_confidence_matches)}<br/>"
|
|
)
|
|
if match_results.no_matches:
|
|
summary += f"Archives with no matches: {len(match_results.no_matches)}<br/>"
|
|
if match_results.fetch_data_failures:
|
|
summary += f"Archives that failed due to data fetch errors: {len(match_results.fetch_data_failures)}<br/>"
|
|
if match_results.write_failures:
|
|
summary += f"Archives that failed due to file writing errors: {len(match_results.write_failures)}<br/>"
|
|
|
|
self.auto_tag_log(summary)
|
|
|
|
selectable = match_results.multiple_matches or match_results.low_confidence_matches
|
|
|
|
title = "Auto-Tag Summary"
|
|
if not selectable:
|
|
logger.info(summary)
|
|
OptionalMessageDialog.msg_no_checkbox(
|
|
self,
|
|
title,
|
|
summary,
|
|
icon=OptionalMessageDialog.Icon.Information,
|
|
)
|
|
return
|
|
|
|
summary += (
|
|
"\n\nDo you want to manually select the ones with multiple matches and/or low-confidence matches now?"
|
|
)
|
|
logger.info(summary)
|
|
OptionalMessageDialog.question(
|
|
self,
|
|
title,
|
|
summary,
|
|
check_text=None,
|
|
).accepted.connect(functools.partial(self.open_auto_tag_match_window, match_results, config=config))
|
|
# TODO: Validate that open_auto_tag_match_window still works
|
|
|
|
def open_auto_tag_match_window(self, match_results: OnlineMatchResults, *, config: ct_ns) -> None:
|
|
match_results.multiple_matches.extend(match_results.low_confidence_matches)
|
|
auto_tagged_archives = {a.path: a for a in self.fileSelectionList.get_selected_archive_list()}
|
|
matchdlg = AutoTagMatchWindow(
|
|
self,
|
|
[(m, auto_tagged_archives[m.original_path]) for m in match_results.multiple_matches],
|
|
config,
|
|
self.current_talker(),
|
|
)
|
|
|
|
matchdlg.open()
|
|
matchdlg.finished.connect(self._reload_page)
|
|
matchdlg.matched_files.connect(self.fileSelectionList.remove_archive_list)
|
|
|
|
def _reload_page(self) -> None:
|
|
self.fileSelectionList.update_selected_rows()
|
|
new_ca = self.fileSelectionList.get_current_archive()
|
|
if new_ca is not None:
|
|
self.load_archive(new_ca)
|
|
|
|
def dirty_flag_verification(self, title: str, desc: str) -> bool:
|
|
if not self.dirty_flag:
|
|
return True
|
|
reply = QtWidgets.QMessageBox.question(
|
|
self,
|
|
title,
|
|
desc,
|
|
(
|
|
QtWidgets.QMessageBox.StandardButton.Save
|
|
| QtWidgets.QMessageBox.StandardButton.Cancel
|
|
| QtWidgets.QMessageBox.StandardButton.Discard
|
|
),
|
|
QtWidgets.QMessageBox.StandardButton.Cancel,
|
|
)
|
|
|
|
if reply == QtWidgets.QMessageBox.StandardButton.Discard:
|
|
return True
|
|
if reply == QtWidgets.QMessageBox.StandardButton.Save:
|
|
self.prompt_write_tags()
|
|
return True
|
|
return False
|
|
|
|
def closeEvent(self, event: QtGui.QCloseEvent) -> None:
|
|
if self.dirty_flag_verification(
|
|
f"Exit {self.appName}", "If you quit now, data in the form will be lost. Are you sure?"
|
|
):
|
|
appsize = self.size()
|
|
self.config[0].internal__window_width = appsize.width()
|
|
self.config[0].internal__window_height = appsize.height()
|
|
self.config[0].internal__window_x = self.x()
|
|
self.config[0].internal__window_y = self.y()
|
|
self.config[0].internal__form_width = self.splitter.sizes()[0]
|
|
self.config[0].internal__list_width = self.splitter.sizes()[1]
|
|
(
|
|
self.config[0].internal__sort_column,
|
|
self.config[0].internal__sort_direction,
|
|
) = self.fileSelectionList.get_sorting()
|
|
self.config[0].internal__write_tags = self.config[0].Runtime_Options__tags_write
|
|
self.config[0].internal__read_tags = self.config[0].Runtime_Options__tags_read
|
|
ctsettings.save_file(self.config, self.config[0].Runtime_Options__config.user_config_dir / "settings.json")
|
|
|
|
event.accept()
|
|
else:
|
|
event.ignore()
|
|
|
|
def show_page_browser(self) -> None:
|
|
if self.page_browser is None:
|
|
self.page_browser = PageBrowserWindow(self, self.metadata)
|
|
if self.comic_archive is not None:
|
|
self.page_browser.set_comic_archive(self.comic_archive)
|
|
self.page_browser.finished.connect(self.page_browser_closed)
|
|
|
|
def page_browser_closed(self) -> None:
|
|
self.page_browser = None
|
|
|
|
def view_raw_tags(self, tag_id: str) -> None:
|
|
tag = tags[tag_id]
|
|
if self.comic_archive is not None and self.comic_archive.has_tags(tag.id):
|
|
dlg = LogWindow(self)
|
|
dlg.set_text(self.comic_archive.read_raw_tags(tag.id))
|
|
dlg.setWindowTitle(f"Raw {tag.name()} Tag View")
|
|
dlg.open()
|
|
|
|
def show_wiki(self) -> None:
|
|
webbrowser.open("https://github.com/comictagger/comictagger/wiki")
|
|
|
|
def report_bug(self) -> None:
|
|
webbrowser.open("https://github.com/comictagger/comictagger/issues")
|
|
|
|
def show_forum(self) -> None:
|
|
webbrowser.open("https://github.com/comictagger/comictagger/discussions")
|
|
|
|
def front_cover_changed(self) -> None:
|
|
self.metadata.pages = self.page_list_editor.get_page_list()
|
|
self.update_cover_image()
|
|
|
|
def page_list_order_changed(self) -> None:
|
|
self.metadata.pages = self.page_list_editor.get_page_list()
|
|
|
|
def apply_cbl_transform(self) -> None:
|
|
self.form_to_metadata()
|
|
self.metadata = CBLTransformer(self.metadata, self.config[0]).apply()
|
|
self.metadata_to_form()
|
|
|
|
def recalc_archive_info(self) -> None:
|
|
QtWidgets.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.CursorShape.WaitCursor))
|
|
for p in self.metadata.pages:
|
|
p.byte_size = None
|
|
p.height = None
|
|
p.width = None
|
|
if self.comic_archive and self.config[0].Runtime_Options__preferred_hash:
|
|
self.metadata.original_hash = None
|
|
self.comic_archive.apply_archive_info_to_metadata(
|
|
self.metadata, True, hash_archive=self.cbHashName.currentText()
|
|
)
|
|
original_hash = self.metadata.original_hash or FileHash("", "")
|
|
self.leOriginalHash.setText(original_hash.hash)
|
|
self.cbHashName.setCurrentText(original_hash.name or self.config[0].internal__embedded_hash_type)
|
|
self.page_list_editor.set_data(self.comic_archive, self.metadata.pages)
|
|
|
|
self.set_dirty_flag()
|
|
QtWidgets.QApplication.restoreOverrideCursor()
|
|
|
|
def rename_archive(self) -> None:
|
|
ca_list = self.fileSelectionList.get_selected_archive_list()
|
|
|
|
if not ca_list:
|
|
OptionalMessageDialog.information(self, "Rename", "No archives selected!")
|
|
return
|
|
|
|
if self.dirty_flag_verification(
|
|
"File Rename", "If you rename files now, unsaved data in the form will be lost. Are you sure?"
|
|
):
|
|
self.rename_window = RenameWindow(
|
|
self, ca_list, self.config[0].Runtime_Options__tags_read, self.config, self.talkers
|
|
)
|
|
self.rename_window.finished.connect(self._reload_page)
|
|
self.rename_window.finished.connect(self._rename_finished)
|
|
self.rename_window.open()
|
|
|
|
def _rename_finished(self) -> None:
|
|
# We may have edited the config during renaming
|
|
self.config = self.rename_window.config
|
|
|
|
def load_archive(self, comic_archive: ComicArchive) -> None:
|
|
self.comic_archive = None
|
|
self.clear_form()
|
|
self.metadata = GenericMetadata()
|
|
|
|
if not os.path.exists(comic_archive.path):
|
|
self.fileSelectionList.dirty_flag = False
|
|
self.fileSelectionList.remove_deleted()
|
|
return
|
|
|
|
self.config[0].internal__last_opened_folder = os.path.abspath(os.path.split(comic_archive.path)[0])
|
|
self.comic_archive = comic_archive
|
|
|
|
self.metadata, _, error = self.read_selected_tags(self.config[0].Runtime_Options__tags_read, self.comic_archive)
|
|
if error is not None:
|
|
logger.error("Failed to load tags from %s: %s", self.comic_archive.path, error, exc_info=error)
|
|
trace = "\n".join(traceback.format_exception(type(error), error, error.__traceback__))
|
|
OptionalMessageDialog.critical(
|
|
parent=self,
|
|
title="Failed to load tags",
|
|
text=f"Failed to load tags from {error}, see log for details",
|
|
details=trace,
|
|
)
|
|
|
|
self.update_ui_for_archive()
|
|
|
|
def read_selected_tags(
|
|
self, tag_ids: list[str], ca: ComicArchive
|
|
) -> tuple[GenericMetadata, list[str], Exception | None]:
|
|
return read_selected_tags(
|
|
tag_ids, ca, self.config[0].Metadata_Options__tag_merge, self.config[0].Metadata_Options__tag_merge_lists
|
|
)
|
|
|
|
def file_list_cleared(self) -> None:
|
|
self.reset_app()
|
|
|
|
def splitter_moved_event(self, w1: int, w2: int) -> None:
|
|
scrollbar_w = 0
|
|
if self.scrollArea.verticalScrollBar().isVisible():
|
|
scrollbar_w = self.scrollArea.verticalScrollBar().width()
|
|
|
|
new_w = self.scrollArea.width() - scrollbar_w - 5
|
|
self.scrollAreaWidgetContents.resize(new_w, self.scrollAreaWidgetContents.height())
|
|
|
|
def resizeEvent(self, ev: QtGui.QResizeEvent | None) -> None:
|
|
self.splitter_moved_event(0, 0)
|
|
|
|
def tab_changed(self, idx: int) -> None:
|
|
if idx == 0:
|
|
self.splitter_moved_event(0, 0)
|
|
|
|
def gtin_changed(self) -> None:
|
|
# GTIN changed, so we check if it's valid
|
|
gtin = self.leGtin.text().strip()
|
|
is_valid = is_valid_gtin(gtin) if gtin else True
|
|
if is_valid:
|
|
self.leGtin.setStyleSheet("")
|
|
else:
|
|
self.leGtin.setStyleSheet("background-color: salmon;")
|
|
|
|
def check_latest_version_online(self) -> None:
|
|
version_checker = VersionChecker()
|
|
self.version_check_complete(version_checker.get_latest_version())
|
|
|
|
def version_check_complete(self, new_version: tuple[str, str]) -> None:
|
|
if new_version[0] not in (self.version, self.config[0].Dialog_Flags__dont_notify_about_this_version):
|
|
from packaging.version import parse
|
|
|
|
if parse(new_version[0]) <= parse(self.version):
|
|
return
|
|
website = "https://github.com/comictagger/comictagger"
|
|
|
|
def set_checked(checked: bool) -> None:
|
|
if checked:
|
|
self.config[0].Dialog_Flags__dont_notify_about_this_version = new_version[0]
|
|
|
|
OptionalMessageDialog.msg(
|
|
self,
|
|
"New version available!",
|
|
f"New version ({new_version[1]}) available!<br>(You are currently running {self.version})<br><br>"
|
|
f"Visit <a href='{website}/releases/latest'>{website}/releases/latest</a> for more info.<br><br>",
|
|
checked=False,
|
|
check_text="Don't tell me about this version again",
|
|
).check_status.connect(set_checked)
|
|
|
|
def on_incoming_socket_connection(self) -> None:
|
|
# Accept connection from other instance.
|
|
# Read in the file list if they're giving it, and add to our own list
|
|
while local_socket := self.socket_server.nextPendingConnection():
|
|
try:
|
|
if not local_socket.waitForReadyRead(3000):
|
|
continue
|
|
byte_array = local_socket.readAll().data()
|
|
if not byte_array:
|
|
continue
|
|
obj = json.loads(byte_array)
|
|
if not isinstance(obj, list):
|
|
continue
|
|
self.fileSelectionList.add_path_list(obj)
|
|
finally:
|
|
local_socket.disconnectFromServer()
|
|
|
|
self.bring_to_top()
|
|
|
|
def bring_to_top(self) -> None:
|
|
if platform.system() == "Windows":
|
|
self.showNormal()
|
|
self.raise_()
|
|
self.activateWindow()
|
|
try:
|
|
import win32con
|
|
import win32gui
|
|
|
|
hwnd = self.effectiveWinId()
|
|
rect = win32gui.GetWindowRect(hwnd)
|
|
x = rect[0]
|
|
y = rect[1]
|
|
w = rect[2] - x
|
|
h = rect[3] - y
|
|
# mark it "always on top", just for a moment, to force it to
|
|
# the top
|
|
win32gui.SetWindowPos(hwnd, win32con.HWND_TOPMOST, x, y, w, h, 0)
|
|
win32gui.SetWindowPos(hwnd, win32con.HWND_NOTOPMOST, x, y, w, h, 0)
|
|
except Exception:
|
|
logger.exception("Fail to bring window to top")
|
|
elif platform.system() == "Darwin":
|
|
self.raise_()
|
|
self.showNormal()
|
|
self.activateWindow()
|
|
else:
|
|
flags = QtCore.Qt.WindowType(self.windowFlags())
|
|
self.setWindowFlags(
|
|
flags | QtCore.Qt.WindowType.WindowStaysOnTopHint | QtCore.Qt.WindowType.X11BypassWindowManagerHint
|
|
)
|
|
self.setWindowFlags(flags)
|
|
self.show()
|
|
|
|
def auto_imprint(self) -> None:
|
|
self.form_to_metadata()
|
|
self.metadata.fix_publisher()
|
|
self.metadata_to_form()
|