Compare commits

...

13 Commits

Author SHA1 Message Date
3ade47a7e0 Convert bytes to str when printing raw tags. Fixes #510 2023-09-05 04:05:20 -04:00
5bc44650d6 Change --only-set-cv-key to --only-save-config 2023-09-05 03:56:56 -04:00
8b1bcd93e6 Add a combobox to select a metadata source in the main window Fixes #508 2023-09-05 03:55:18 -04:00
d70a98ed29 Fix --darkmode 2023-09-05 03:55:18 -04:00
05e6eaf88e Update setting group names
Make group names presentable to users and add builtin plugins during namespace generation.
Revamp talkeruigenerator.py to use generated group and setting names and remove as much hard-coded strings as possible
Add a --list-plugins commandline option
2023-09-05 03:55:12 -04:00
90eb1c3980 Fix date display in the issue selection window 2023-09-05 03:14:55 -04:00
7a63474769 Fix cbr tests and update pre-commit 2023-09-04 19:56:18 -05:00
0f07fc3153 Use a dictionary instead of a list in the issue/series selection windows
List lookups were done by row number which became inaccurate if any sorting was done

Fixes #507
2023-09-03 15:18:56 -07:00
e832b19f2f Fix attribute names 2023-09-03 15:12:06 -07:00
9499aeae10 PyrateLimter version 2 only for now. 2023-08-30 23:23:19 +01:00
f72ebdb149 Simplify ComicCacher to store a single binary data field and ID(s)
If the ComicCacher is to be a generic cache for talkers it must assume
 very little. Current assumptions:
 - There are issues that can be queried individually by an "Issue ID" and they have a relation to a single series
 - There are series that can be queried individually by an "Series ID" and they have a relation to zero or more issues
 - There are Searches that can be queried by the search term and they have a relation to zero or more series

Each series and issue have a boolean `complete` attribute which is up to the talker to decide what it means.
Data is returned as a tuple ([series, complete] or [issue, complete]) or a list of tuples
An issue consists of an ID, an series ID and a binary data attribute which is up to the talker to determine what it means.
An series consists of in ID and a binary data attribute which is up to the talker to determine what it means.

The data attribute is binary to allow for compression and efficient storage of binary data (e.g. pickle) it is suggested to store it as json or similar text format encoded with utf-8. If the talker is using a website API it is suggested to store the raw response from the server.

All caches automatically expire 7 days after insertion.
2023-08-05 03:02:12 -07:00
ea84031b87 Add more 4-digit issue number tests 2023-08-04 21:04:21 -07:00
611c40fe0b Add test for split 2023-08-03 01:06:10 -07:00
34 changed files with 898 additions and 894 deletions

View File

@ -14,12 +14,12 @@ repos:
hooks:
- id: setup-cfg-fmt
- repo: https://github.com/PyCQA/autoflake
rev: v2.2.0
rev: v2.2.1
hooks:
- id: autoflake
args: [-i, --remove-all-unused-imports, --ignore-init-module-imports]
- repo: https://github.com/asottile/pyupgrade
rev: v3.8.0
rev: v3.10.1
hooks:
- id: pyupgrade
args: [--py39-plus]
@ -29,16 +29,16 @@ repos:
- id: isort
args: [--af,--add-import, 'from __future__ import annotations']
- repo: https://github.com/psf/black
rev: 23.3.0
rev: 23.7.0
hooks:
- id: black
- repo: https://github.com/PyCQA/flake8
rev: 6.0.0
rev: 6.1.0
hooks:
- id: flake8
additional_dependencies: [flake8-encodings, flake8-warnings, flake8-builtins, flake8-length, flake8-print]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.4.1
rev: v1.5.1
hooks:
- id: mypy
additional_dependencies: [types-setuptools, types-requests, settngs>=0.7.1]

View File

@ -9,6 +9,7 @@ import comictaggerlib.main
def generate() -> str:
app = comictaggerlib.main.App()
app.load_plugins(app.initial_arg_parser.parse_known_args()[0])
app.register_settings()
return settngs.generate_ns(app.manager.definitions)

View File

@ -332,7 +332,7 @@ class GenericMetadata:
add_attr_string("day")
add_attr_string("volume")
add_attr_string("volume_count")
add_attr_string("genre")
add_attr_string("genres")
add_attr_string("language")
add_attr_string("country")
add_attr_string("critical_rating")
@ -353,13 +353,13 @@ class GenericMetadata:
if self.black_and_white:
add_attr_string("black_and_white")
add_attr_string("maturity_rating")
add_attr_string("story_arc")
add_attr_string("series_group")
add_attr_string("story_arcs")
add_attr_string("series_groups")
add_attr_string("scan_info")
add_attr_string("characters")
add_attr_string("teams")
add_attr_string("locations")
add_attr_string("comments")
add_attr_string("description")
add_attr_string("notes")
add_string("tags", ", ".join(self.tags))

View File

@ -52,7 +52,7 @@ class AutoTagMatchWindow(QtWidgets.QDialog):
self.current_match_set: MultipleMatch = match_set_list[0]
self.altCoverWidget = CoverImageWidget(
self.altCoverContainer, CoverImageWidget.AltCoverMode, config.runtime_config.user_cache_dir, talker
self.altCoverContainer, CoverImageWidget.AltCoverMode, config.Runtime_Options_config.user_cache_dir, talker
)
gridlayout = QtWidgets.QGridLayout(self.altCoverContainer)
gridlayout.addWidget(self.altCoverWidget)
@ -233,10 +233,10 @@ class AutoTagMatchWindow(QtWidgets.QDialog):
md = ca.read_metadata(self._style)
if md.is_empty:
md = ca.metadata_from_filename(
self.config.filename_complicated_parser,
self.config.filename_remove_c2c,
self.config.filename_remove_fcbd,
self.config.filename_remove_publisher,
self.config.Filename_Parsing_complicated_parser,
self.config.Filename_Parsing_remove_c2c,
self.config.Filename_Parsing_remove_fcbd,
self.config.Filename_Parsing_remove_publisher,
)
# now get the particular issue data

View File

@ -40,15 +40,15 @@ class AutoTagStartWindow(QtWidgets.QDialog):
self.cbxSpecifySearchString.setChecked(False)
self.cbxSplitWords.setChecked(False)
self.sbNameMatchSearchThresh.setValue(self.config.identifier_series_match_identify_thresh)
self.sbNameMatchSearchThresh.setValue(self.config.Issue_Identifier_series_match_identify_thresh)
self.leSearchString.setEnabled(False)
self.cbxSaveOnLowConfidence.setChecked(self.config.autotag_save_on_low_confidence)
self.cbxDontUseYear.setChecked(self.config.autotag_dont_use_year_when_identifying)
self.cbxAssumeIssueOne.setChecked(self.config.autotag_assume_1_if_no_issue_num)
self.cbxIgnoreLeadingDigitsInFilename.setChecked(self.config.autotag_ignore_leading_numbers_in_filename)
self.cbxRemoveAfterSuccess.setChecked(self.config.autotag_remove_archive_after_successful_match)
self.cbxAutoImprint.setChecked(self.config.identifier_auto_imprint)
self.cbxSaveOnLowConfidence.setChecked(self.config.Auto_Tag_save_on_low_confidence)
self.cbxDontUseYear.setChecked(self.config.Auto_Tag_dont_use_year_when_identifying)
self.cbxAssumeIssueOne.setChecked(self.config.Auto_Tag_assume_1_if_no_issue_num)
self.cbxIgnoreLeadingDigitsInFilename.setChecked(self.config.Auto_Tag_ignore_leading_numbers_in_filename)
self.cbxRemoveAfterSuccess.setChecked(self.config.Auto_Tag_remove_archive_after_successful_match)
self.cbxAutoImprint.setChecked(self.config.Issue_Identifier_auto_imprint)
nlmt_tip = """<html>The <b>Name Match Ratio Threshold: Auto-Identify</b> is for eliminating automatic
search matches that are too long compared to your series name search. The lower
@ -73,7 +73,7 @@ class AutoTagStartWindow(QtWidgets.QDialog):
self.ignore_leading_digits_in_filename = False
self.remove_after_success = False
self.search_string = ""
self.name_length_match_tolerance = self.config.identifier_series_match_search_thresh
self.name_length_match_tolerance = self.config.Issue_Identifier_series_match_search_thresh
self.split_words = self.cbxSplitWords.isChecked()
def search_string_toggle(self) -> None:
@ -92,11 +92,11 @@ class AutoTagStartWindow(QtWidgets.QDialog):
self.split_words = self.cbxSplitWords.isChecked()
# persist some settings
self.config.autotag_save_on_low_confidence = self.auto_save_on_low
self.config.autotag_dont_use_year_when_identifying = self.dont_use_year
self.config.autotag_assume_1_if_no_issue_num = self.assume_issue_one
self.config.autotag_ignore_leading_numbers_in_filename = self.ignore_leading_digits_in_filename
self.config.autotag_remove_archive_after_successful_match = self.remove_after_success
self.config.Auto_Tag_save_on_low_confidence = self.auto_save_on_low
self.config.Auto_Tag_dont_use_year_when_identifying = self.dont_use_year
self.config.Auto_Tag_assume_1_if_no_issue_num = self.assume_issue_one
self.config.Auto_Tag_ignore_leading_numbers_in_filename = self.ignore_leading_digits_in_filename
self.config.Auto_Tag_remove_archive_after_successful_match = self.remove_after_success
if self.cbxSpecifySearchString.isChecked():
self.search_string = self.leSearchString.text()

View File

@ -29,7 +29,7 @@ class CBLTransformer:
self.config = config
def apply(self) -> GenericMetadata:
if self.config.cbl_assume_lone_credit_is_primary:
if self.config.Comic_Book_Lover_assume_lone_credit_is_primary:
# helper
def set_lone_primary(role_list: list[str]) -> tuple[Credit | None, int]:
lone_credit: Credit | None = None
@ -55,19 +55,19 @@ class CBLTransformer:
c["primary"] = False
self.metadata.add_credit(c["person"], "Artist", True)
if self.config.cbl_copy_characters_to_tags:
if self.config.Comic_Book_Lover_copy_characters_to_tags:
self.metadata.tags.update(x for x in self.metadata.characters)
if self.config.cbl_copy_teams_to_tags:
if self.config.Comic_Book_Lover_copy_teams_to_tags:
self.metadata.tags.update(x for x in self.metadata.teams)
if self.config.cbl_copy_locations_to_tags:
if self.config.Comic_Book_Lover_copy_locations_to_tags:
self.metadata.tags.update(x for x in self.metadata.locations)
if self.config.cbl_copy_storyarcs_to_tags:
if self.config.Comic_Book_Lover_copy_storyarcs_to_tags:
self.metadata.tags.update(x for x in self.metadata.story_arcs)
if self.config.cbl_copy_notes_to_comments:
if self.config.Comic_Book_Lover_copy_notes_to_comments:
if self.metadata.notes is not None:
if self.metadata.description is None:
self.metadata.description = ""
@ -76,7 +76,7 @@ class CBLTransformer:
if self.metadata.notes not in self.metadata.description:
self.metadata.description += self.metadata.notes
if self.config.cbl_copy_weblink_to_comments:
if self.config.Comic_Book_Lover_copy_weblink_to_comments:
if self.metadata.web_link is not None:
if self.metadata.description is None:
self.metadata.description = ""

View File

@ -16,12 +16,10 @@
# limitations under the License.
from __future__ import annotations
import json
import logging
import os
import sys
from datetime import datetime
from pprint import pprint
from comicapi import utils
from comicapi.comicarchive import ComicArchive, MetaDataStyle
@ -46,9 +44,9 @@ class CLI:
self.batch_mode = False
def current_talker(self) -> ComicTalker:
if self.config.talker_source in self.talkers:
return self.talkers[self.config.talker_source]
logger.error("Could not find the '%s' talker", self.config.talker_source)
if self.config.Sources_source in self.talkers:
return self.talkers[self.config.Sources_source]
logger.error("Could not find the '%s' talker", self.config.Sources_source)
raise SystemExit(2)
def actual_issue_data_fetch(self, issue_id: str) -> GenericMetadata:
@ -59,14 +57,14 @@ class CLI:
logger.exception(f"Error retrieving issue details. Save aborted.\n{e}")
return GenericMetadata()
if self.config.cbl_apply_transform_on_import:
if self.config.Comic_Book_Lover_apply_transform_on_import:
ct_md = CBLTransformer(ct_md, self.config).apply()
return ct_md
def actual_metadata_save(self, ca: ComicArchive, md: GenericMetadata) -> bool:
if not self.config.runtime_dryrun:
for metadata_style in self.config.runtime_type:
if not self.config.Runtime_Options_dryrun:
for metadata_style in self.config.Runtime_Options_type:
# write out the new data
if not ca.write_metadata(md, metadata_style):
logger.error("The tag save seemed to fail for style: %s!", MetaDataStyle.name[metadata_style])
@ -75,7 +73,7 @@ class CLI:
print("Save complete.")
logger.info("Save complete.")
else:
if self.config.runtime_quiet:
if self.config.Runtime_Options_quiet:
logger.info("dry-run option was set, so nothing was written")
print("dry-run option was set, so nothing was written")
else:
@ -102,7 +100,7 @@ class CLI:
m["issue_title"],
)
)
if self.config.runtime_interactive:
if self.config.Runtime_Options_interactive:
while True:
i = input("Choose a match #, or 's' to skip: ")
if (i.isdigit() and int(i) in range(1, len(match_set.matches) + 1)) or i == "s":
@ -113,7 +111,7 @@ class CLI:
ca = match_set.ca
md = self.create_local_metadata(ca)
ct_md = self.actual_issue_data_fetch(match_set.matches[int(i) - 1]["issue_id"])
if self.config.identifier_clear_metadata_on_import:
if self.config.Issue_Identifier_clear_metadata_on_import:
md = ct_md
else:
notes = (
@ -122,14 +120,14 @@ class CLI:
)
md.overlay(ct_md.replace(notes=utils.combine_notes(md.notes, notes, "Tagged with ComicTagger")))
if self.config.identifier_auto_imprint:
if self.config.Issue_Identifier_auto_imprint:
md.fix_publisher()
self.actual_metadata_save(ca, md)
def post_process_matches(self, match_results: OnlineMatchResults) -> None:
# now go through the match results
if self.config.runtime_summary:
if self.config.Runtime_Options_summary:
if len(match_results.good_matches) > 0:
print("\nSuccessful matches:\n------------------")
for f in match_results.good_matches:
@ -150,7 +148,7 @@ class CLI:
for f in match_results.fetch_data_failures:
print(f)
if not self.config.runtime_summary and not self.config.runtime_interactive:
if not self.config.Runtime_Options_summary and not self.config.Runtime_Options_interactive:
# just quit if we're not interactive or showing the summary
return
@ -170,38 +168,41 @@ class CLI:
self.display_match_set_for_choice(label, match_set)
def run(self) -> None:
if len(self.config.runtime_files) < 1:
if len(self.config.Runtime_Options_files) < 1:
logger.error("You must specify at least one filename. Use the -h option for more info")
return
match_results = OnlineMatchResults()
self.batch_mode = len(self.config.runtime_files) > 1
self.batch_mode = len(self.config.Runtime_Options_files) > 1
for f in self.config.runtime_files:
for f in self.config.Runtime_Options_files:
self.process_file_cli(f, match_results)
sys.stdout.flush()
self.post_process_matches(match_results)
print(f"\nFiles tagged with metadata provided by {self.current_talker().name} {self.current_talker().website}")
if self.config.Runtime_Options_online:
print(
f"\nFiles tagged with metadata provided by {self.current_talker().name} {self.current_talker().website}"
)
def create_local_metadata(self, ca: ComicArchive) -> GenericMetadata:
md = GenericMetadata()
md.set_default_page_list(ca.get_number_of_pages())
# now, overlay the parsed filename info
if self.config.runtime_parse_filename:
if self.config.Runtime_Options_parse_filename:
f_md = ca.metadata_from_filename(
self.config.filename_complicated_parser,
self.config.filename_remove_c2c,
self.config.filename_remove_fcbd,
self.config.filename_remove_publisher,
self.config.runtime_split_words,
self.config.Filename_Parsing_complicated_parser,
self.config.Filename_Parsing_remove_c2c,
self.config.Filename_Parsing_remove_fcbd,
self.config.Filename_Parsing_remove_publisher,
self.config.Runtime_Options_split_words,
)
md.overlay(f_md)
for metadata_style in self.config.runtime_type:
for metadata_style in self.config.Runtime_Options_type:
if ca.has_metadata(metadata_style):
try:
t_md = ca.read_metadata(metadata_style)
@ -211,12 +212,12 @@ class CLI:
logger.error("Failed to load metadata for %s: %s", ca.path, e)
# finally, use explicit stuff
md.overlay(self.config.runtime_metadata)
md.overlay(self.config.Runtime_Options_metadata)
return md
def print(self, ca: ComicArchive) -> None:
if not self.config.runtime_type:
if not self.config.Runtime_Options_type:
page_count = ca.get_number_of_pages()
brief = ""
@ -246,49 +247,59 @@ class CLI:
print(brief)
if self.config.runtime_quiet:
if self.config.Runtime_Options_quiet:
return
print()
if not self.config.runtime_type or MetaDataStyle.CIX in self.config.runtime_type:
raw: str | bytes = ""
if not self.config.Runtime_Options_type or MetaDataStyle.CIX in self.config.Runtime_Options_type:
if ca.has_metadata(MetaDataStyle.CIX):
print("--------- ComicRack tags ---------")
try:
if self.config.runtime_raw:
print(ca.read_raw_cix())
if self.config.Runtime_Options_raw:
raw = ca.read_raw_cix()
if isinstance(raw, bytes):
raw = raw.decode("utf-8")
print(raw)
else:
print(ca.read_cix())
except Exception as e:
logger.error("Failed to load metadata for %s: %s", ca.path, e)
if not self.config.runtime_type or MetaDataStyle.CBI in self.config.runtime_type:
if not self.config.Runtime_Options_type or MetaDataStyle.CBI in self.config.Runtime_Options_type:
if ca.has_metadata(MetaDataStyle.CBI):
print("------- ComicBookLover tags -------")
try:
if self.config.runtime_raw:
pprint(json.loads(ca.read_raw_cbi()))
if self.config.Runtime_Options_raw:
raw = ca.read_raw_cbi()
if isinstance(raw, bytes):
raw = raw.decode("utf-8")
print(raw)
else:
print(ca.read_cbi())
except Exception as e:
logger.error("Failed to load metadata for %s: %s", ca.path, e)
if not self.config.runtime_type or MetaDataStyle.COMET in self.config.runtime_type:
if not self.config.Runtime_Options_type or MetaDataStyle.COMET in self.config.Runtime_Options_type:
if ca.has_metadata(MetaDataStyle.COMET):
print("----------- CoMet tags -----------")
try:
if self.config.runtime_raw:
print(ca.read_raw_comet())
if self.config.Runtime_Options_raw:
raw = ca.read_raw_comet()
if isinstance(raw, bytes):
raw = raw.decode("utf-8")
print(raw)
else:
print(ca.read_comet())
except Exception as e:
logger.error("Failed to load metadata for %s: %s", ca.path, e)
def delete(self, ca: ComicArchive) -> None:
for metadata_style in self.config.runtime_type:
for metadata_style in self.config.Runtime_Options_type:
style_name = MetaDataStyle.name[metadata_style]
if ca.has_metadata(metadata_style):
if not self.config.runtime_dryrun:
if not self.config.Runtime_Options_dryrun:
if not ca.remove_metadata(metadata_style):
print(f"{ca.path}: Tag removal seemed to fail!")
else:
@ -299,25 +310,25 @@ class CLI:
print(f"{ca.path}: This archive doesn't have {style_name} tags to remove.")
def copy(self, ca: ComicArchive) -> None:
for metadata_style in self.config.runtime_type:
for metadata_style in self.config.Runtime_Options_type:
dst_style_name = MetaDataStyle.name[metadata_style]
if not self.config.runtime_overwrite and ca.has_metadata(metadata_style):
if not self.config.Runtime_Options_overwrite and ca.has_metadata(metadata_style):
print(f"{ca.path}: Already has {dst_style_name} tags. Not overwriting.")
return
if self.config.commands_copy == metadata_style:
if self.config.Commands_copy == metadata_style:
print(f"{ca.path}: Destination and source are same: {dst_style_name}. Nothing to do.")
return
src_style_name = MetaDataStyle.name[self.config.commands_copy]
if ca.has_metadata(self.config.commands_copy):
if not self.config.runtime_dryrun:
src_style_name = MetaDataStyle.name[self.config.Commands_copy]
if ca.has_metadata(self.config.Commands_copy):
if not self.config.Runtime_Options_dryrun:
try:
md = ca.read_metadata(self.config.commands_copy)
md = ca.read_metadata(self.config.Commands_copy)
except Exception as e:
md = GenericMetadata()
logger.error("Failed to load metadata for %s: %s", ca.path, e)
if self.config.cbl_apply_transform_on_bulk_operation == MetaDataStyle.CBI:
if self.config.Comic_Book_Lover_apply_transform_on_bulk_operation == MetaDataStyle.CBI:
md = CBLTransformer(md, self.config).apply()
if not ca.write_metadata(md, metadata_style):
@ -330,8 +341,8 @@ class CLI:
print(f"{ca.path}: This archive doesn't have {src_style_name} tags to copy.")
def save(self, ca: ComicArchive, match_results: OnlineMatchResults) -> None:
if not self.config.runtime_overwrite:
for metadata_style in self.config.runtime_type:
if not self.config.Runtime_Options_overwrite:
for metadata_style in self.config.Runtime_Options_type:
if ca.has_metadata(metadata_style):
print(f"{ca.path}: Already has {MetaDataStyle.name[metadata_style]} tags. Not overwriting.")
return
@ -341,26 +352,26 @@ class CLI:
md = self.create_local_metadata(ca)
if md.issue is None or md.issue == "":
if self.config.autotag_assume_1_if_no_issue_num:
if self.config.Auto_Tag_assume_1_if_no_issue_num:
md.issue = "1"
# now, search online
if self.config.runtime_online:
if self.config.runtime_issue_id is not None:
if self.config.Runtime_Options_online:
if self.config.Runtime_Options_issue_id is not None:
# we were given the actual issue ID to search with
try:
ct_md = self.current_talker().fetch_comic_data(self.config.runtime_issue_id)
ct_md = self.current_talker().fetch_comic_data(self.config.Runtime_Options_issue_id)
except TalkerError as e:
logger.exception(f"Error retrieving issue details. Save aborted.\n{e}")
match_results.fetch_data_failures.append(str(ca.path.absolute()))
return
if ct_md is None:
logger.error("No match for ID %s was found.", self.config.runtime_issue_id)
logger.error("No match for ID %s was found.", self.config.Runtime_Options_issue_id)
match_results.no_matches.append(str(ca.path.absolute()))
return
if self.config.cbl_apply_transform_on_import:
if self.config.Comic_Book_Lover_apply_transform_on_import:
ct_md = CBLTransformer(ct_md, self.config).apply()
else:
if md is None or md.is_empty:
@ -371,7 +382,7 @@ class CLI:
ii = IssueIdentifier(ca, self.config, self.current_talker())
def myoutput(text: str) -> None:
if self.config.runtime_verbose:
if self.config.Runtime_Options_verbose:
IssueIdentifier.default_write_output(text)
# use our overlaid MD struct to search
@ -411,7 +422,7 @@ class CLI:
logger.error("Online search: Multiple good matches. Save aborted")
match_results.multiple_matches.append(MultipleMatch(ca, matches))
return
if low_confidence and self.config.runtime_abort_on_low_confidence:
if low_confidence and self.config.Runtime_Options_abort_on_low_confidence:
logger.error("Online search: Low confidence match. Save aborted")
match_results.low_confidence_matches.append(MultipleMatch(ca, matches))
return
@ -428,7 +439,7 @@ class CLI:
match_results.fetch_data_failures.append(str(ca.path.absolute()))
return
if self.config.identifier_clear_metadata_on_import:
if self.config.Issue_Identifier_clear_metadata_on_import:
md = GenericMetadata()
notes = (
@ -438,11 +449,11 @@ class CLI:
md.overlay(
ct_md.replace(
notes=utils.combine_notes(md.notes, notes, "Tagged with ComicTagger"),
description=cleanup_html(ct_md.description, self.config.talker_remove_html_tables),
description=cleanup_html(ct_md.description, self.config.Sources_remove_html_tables),
)
)
if self.config.identifier_auto_imprint:
if self.config.Issue_Identifier_auto_imprint:
md.fix_publisher()
# ok, done building our metadata. time to save
@ -464,18 +475,18 @@ class CLI:
return
new_ext = "" # default
if self.config.rename_set_extension_based_on_archive:
if self.config.File_Rename_set_extension_based_on_archive:
new_ext = ca.extension()
renamer = FileRenamer(
md,
platform="universal" if self.config.rename_strict else "auto",
replacements=self.config.rename_replacements,
platform="universal" if self.config.File_Rename_strict else "auto",
replacements=self.config.File_Rename_replacements,
)
renamer.set_template(self.config.rename_template)
renamer.set_issue_zero_padding(self.config.rename_issue_number_padding)
renamer.set_smart_cleanup(self.config.rename_use_smart_string_cleanup)
renamer.move = self.config.rename_move_to_dir
renamer.set_template(self.config.File_Rename_template)
renamer.set_issue_zero_padding(self.config.File_Rename_issue_number_padding)
renamer.set_smart_cleanup(self.config.File_Rename_use_smart_string_cleanup)
renamer.move = self.config.File_Rename_move_to_dir
try:
new_name = renamer.determine_name(ext=new_ext)
@ -487,14 +498,14 @@ class CLI:
"Please consult the template help in the settings "
"and the documentation on the format at "
"https://docs.python.org/3/library/string.html#format-string-syntax",
self.config.rename_template,
self.config.File_Rename_template,
)
return
except Exception:
logger.exception("Formatter failure: %s metadata: %s", self.config.rename_template, renamer.metadata)
logger.exception("Formatter failure: %s metadata: %s", self.config.File_Rename_template, renamer.metadata)
return
folder = get_rename_dir(ca, self.config.rename_dir if self.config.rename_move_to_dir else None)
folder = get_rename_dir(ca, self.config.File_Rename_dir if self.config.File_Rename_move_to_dir else None)
full_path = folder / new_name
@ -503,7 +514,7 @@ class CLI:
return
suffix = ""
if not self.config.runtime_dryrun:
if not self.config.Runtime_Options_dryrun:
# rename the file
try:
ca.rename(utils.unique_file(full_path))
@ -526,7 +537,7 @@ class CLI:
filename_path = ca.path
new_file = filename_path.with_suffix(".cbz")
if self.config.runtime_abort_on_conflict and new_file.exists():
if self.config.Runtime_Options_abort_on_conflict and new_file.exists():
print(msg_hdr + f"{new_file.name} already exists in the that folder.")
return
@ -534,10 +545,10 @@ class CLI:
delete_success = False
export_success = False
if not self.config.runtime_dryrun:
if not self.config.Runtime_Options_dryrun:
if ca.export_as_zip(new_file):
export_success = True
if self.config.runtime_delete_after_zip_export:
if self.config.Runtime_Options_delete_after_zip_export:
try:
filename_path.unlink(missing_ok=True)
delete_success = True
@ -549,7 +560,7 @@ class CLI:
new_file.unlink(missing_ok=True)
else:
msg = msg_hdr + f"Dry-run: Would try to create {os.path.split(new_file)[1]}"
if self.config.runtime_delete_after_zip_export:
if self.config.Runtime_Options_delete_after_zip_export:
msg += " and delete original."
print(msg)
return
@ -557,7 +568,7 @@ class CLI:
msg = msg_hdr
if export_success:
msg += f"Archive exported successfully to: {os.path.split(new_file)[1]}"
if self.config.runtime_delete_after_zip_export and delete_success:
if self.config.Runtime_Options_delete_after_zip_export and delete_success:
msg += " (Original deleted) "
else:
msg += "Archive failed to export!"
@ -576,28 +587,28 @@ class CLI:
return
if not ca.is_writable() and (
self.config.commands_delete
or self.config.commands_copy
or self.config.commands_save
or self.config.commands_rename
self.config.Commands_delete
or self.config.Commands_copy
or self.config.Commands_save
or self.config.Commands_rename
):
logger.error("This archive is not writable")
return
if self.config.commands_print:
if self.config.Commands_print:
self.print(ca)
elif self.config.commands_delete:
elif self.config.Commands_delete:
self.delete(ca)
elif self.config.commands_copy is not None:
elif self.config.Commands_copy is not None:
self.copy(ca)
elif self.config.commands_save:
elif self.config.Commands_save:
self.save(ca, match_results)
elif self.config.commands_rename:
elif self.config.Commands_rename:
self.rename(ca)
elif self.config.commands_export_to_zip:
elif self.config.Commands_export_to_zip:
self.export(ca)

View File

@ -6,7 +6,7 @@ from comictaggerlib.ctsettings.commandline import (
validate_commandline_settings,
)
from comictaggerlib.ctsettings.file import register_file_settings, validate_file_settings
from comictaggerlib.ctsettings.plugin import register_plugin_settings, validate_plugin_settings
from comictaggerlib.ctsettings.plugin import group_for_plugin, register_plugin_settings, validate_plugin_settings
from comictaggerlib.ctsettings.settngs_namespace import settngs_namespace as ct_ns
from comictaggerlib.ctsettings.types import ComicTaggerPaths
from comictalker import ComicTalker
@ -23,4 +23,5 @@ __all__ = [
"validate_plugin_settings",
"ComicTaggerPaths",
"ct_ns",
"group_for_plugin",
]

View File

@ -234,63 +234,74 @@ def register_commands(parser: settngs.Manager) -> None:
file=False,
)
parser.add_setting(
"--only-set-cv-key",
"--only-save-config",
action="store_true",
help="Only set the Comic Vine API key and quit.\n\n",
help="Only save the configuration (eg, Comic Vine API key) and quit.",
file=False,
)
parser.add_setting(
"--list-plugins",
action="store_true",
help="List the available plugins.\n\n",
file=False,
)
def register_commandline_settings(parser: settngs.Manager) -> None:
parser.add_group("commands", register_commands, True)
parser.add_persistent_group("runtime", register_runtime)
parser.add_group("Commands", register_commands, True)
parser.add_persistent_group("Runtime Options", register_runtime)
def validate_commandline_settings(config: settngs.Config[ct_ns], parser: settngs.Manager) -> settngs.Config[ct_ns]:
if config[0].commands_version:
if config[0].Commands_version:
parser.exit(
status=1,
message=f"ComicTagger {ctversion.version}: Copyright (c) 2012-2022 ComicTagger Team\n"
"Distributed under Apache License 2.0 (http://www.apache.org/licenses/LICENSE-2.0)\n",
)
config[0].runtime_no_gui = any(
config[0].Runtime_Options_no_gui = any(
[
config[0].commands_print,
config[0].commands_delete,
config[0].commands_save,
config[0].commands_copy,
config[0].commands_rename,
config[0].commands_export_to_zip,
config[0].commands_only_set_cv_key,
config[0].runtime_no_gui,
config[0].Commands_print,
config[0].Commands_delete,
config[0].Commands_save,
config[0].Commands_copy,
config[0].Commands_rename,
config[0].Commands_export_to_zip,
config[0].Commands_only_save_config,
config[0].Commands_list_plugins,
config[0].Runtime_Options_no_gui,
]
)
if platform.system() == "Windows" and config[0].runtime_glob:
if platform.system() == "Windows" and config[0].Runtime_Options_glob:
# no globbing on windows shell, so do it for them
import glob
globs = config[0].runtime_files
config[0].runtime_files = []
globs = config[0].Runtime_Options_files
config[0].Runtime_Options_files = []
for item in globs:
config[0].runtime_files.extend(glob.glob(item))
config[0].Runtime_Options_files.extend(glob.glob(item))
if not config[0].commands_only_set_cv_key and config[0].runtime_no_gui and not config[0].runtime_files:
if (
not config[0].Commands_only_save_config
and config[0].Runtime_Options_no_gui
and not config[0].Runtime_Options_files
):
parser.exit(message="Command requires at least one filename!\n", status=1)
if config[0].commands_delete and not config[0].runtime_type:
if config[0].Commands_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_save and not config[0].runtime_type:
if config[0].Commands_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_copy:
if not config[0].runtime_type:
if config[0].Commands_copy:
if not config[0].Runtime_Options_type:
parser.exit(message="Please specify the type to copy to with -t\n", status=1)
if config[0].runtime_recursive:
config[0].runtime_files = utils.get_recursive_filelist(config[0].runtime_files)
if config[0].Runtime_Options_recursive:
config[0].Runtime_Options_files = utils.get_recursive_filelist(config[0].Runtime_Options_files)
# take a crack at finding rar exe if it's not in the path
if not utils.which("rar"):

View File

@ -123,7 +123,11 @@ def filename(parser: settngs.Manager) -> None:
def talker(parser: settngs.Manager) -> None:
# General settings for talkers
parser.add_setting("--source", default="comicvine", help="Use a specified source by source ID")
parser.add_setting(
"--source",
default="comicvine",
help="Use a specified source by source ID (use --list-plugins to list all sources)",
)
parser.add_setting(
"--remove-html-tables",
default=False,
@ -219,7 +223,7 @@ def autotag(parser: settngs.Manager) -> None:
def validate_file_settings(config: settngs.Config[ct_ns]) -> settngs.Config[ct_ns]:
new_filter = []
remove = []
for x in config[0].identifier_publisher_filter:
for x in config[0].Issue_Identifier_publisher_filter:
x = x.strip()
if x: # ignore empty arguments
if x[-1] == "-": # this publisher needs to be removed. We remove after all publishers have been enumerated
@ -230,22 +234,22 @@ def validate_file_settings(config: settngs.Config[ct_ns]) -> settngs.Config[ct_n
for x in remove: # remove publishers
if x in new_filter:
new_filter.remove(x)
config[0].identifier_publisher_filter = new_filter
config[0].Issue_Identifier_publisher_filter = new_filter
config[0].rename_replacements = Replacements(
[Replacement(x[0], x[1], x[2]) for x in config[0].rename_replacements[0]],
[Replacement(x[0], x[1], x[2]) for x in config[0].rename_replacements[1]],
config[0].File_Rename_replacements = Replacements(
[Replacement(x[0], x[1], x[2]) for x in config[0].File_Rename_replacements[0]],
[Replacement(x[0], x[1], x[2]) for x in config[0].File_Rename_replacements[1]],
)
return config
def register_file_settings(parser: settngs.Manager) -> None:
parser.add_group("general", general, False)
parser.add_group("internal", internal, False)
parser.add_group("identifier", identifier, False)
parser.add_group("dialog", dialog, False)
parser.add_group("filename", filename, False)
parser.add_group("talker", talker, False)
parser.add_group("cbl", cbl, False)
parser.add_group("rename", rename, False)
parser.add_group("autotag", autotag, False)
parser.add_group("Issue Identifier", identifier, False)
parser.add_group("Filename Parsing", filename, False)
parser.add_group("Sources", talker, False)
parser.add_group("Comic Book Lover", cbl, False)
parser.add_group("File Rename", rename, False)
parser.add_group("Auto-Tag", autotag, False)
parser.add_group("General", general, False)
parser.add_group("Dialog Flags", dialog, False)

View File

@ -7,12 +7,23 @@ from typing import cast
import settngs
import comicapi.comicarchive
import comicapi.utils
import comictaggerlib.ctsettings
from comicapi.comicarchive import Archiver
from comictaggerlib.ctsettings.settngs_namespace import settngs_namespace as ct_ns
from comictalker.comictalker import ComicTalker
logger = logging.getLogger("comictagger")
def group_for_plugin(plugin: Archiver | ComicTalker) -> str:
if isinstance(plugin, ComicTalker):
return f"Source {plugin.id}"
if isinstance(plugin, Archiver):
return "Archive"
raise NotImplementedError(f"Invalid plugin received: {plugin=}")
def archiver(manager: settngs.Manager) -> None:
for archiver in comicapi.comicarchive.archivers:
if archiver.exe:
@ -26,27 +37,28 @@ def archiver(manager: settngs.Manager) -> None:
def register_talker_settings(manager: settngs.Manager) -> None:
for talker_id, talker in comictaggerlib.ctsettings.talkers.items():
for talker in comictaggerlib.ctsettings.talkers.values():
def api_options(manager: settngs.Manager) -> None:
# The default needs to be unset or None.
# This allows this setting to be unset with the empty string, allowing the default to change
manager.add_setting(
f"--{talker_id}-key",
f"--{talker.id}-key",
display_name="API Key",
help=f"API Key for {talker.name} (default: {talker.default_api_key})",
)
manager.add_setting(
f"--{talker_id}-url",
f"--{talker.id}-url",
display_name="URL",
help=f"URL for {talker.name} (default: {talker.default_api_url})",
)
try:
manager.add_persistent_group("talker_" + talker_id, api_options, False)
manager.add_persistent_group("talker_" + talker_id, talker.register_settings, False)
manager.add_persistent_group(group_for_plugin(talker), api_options, False)
if hasattr(talker, "register_settings"):
manager.add_persistent_group(group_for_plugin(talker), talker.register_settings, False)
except Exception:
logger.exception("Failed to register settings for %s", talker_id)
logger.exception("Failed to register settings for %s", talker.id)
def validate_archive_settings(config: settngs.Config[ct_ns]) -> settngs.Config[ct_ns]:
@ -55,11 +67,11 @@ def validate_archive_settings(config: settngs.Config[ct_ns]) -> settngs.Config[c
cfg = settngs.normalize_config(config, file=True, cmdline=True, default=False)
for archiver in comicapi.comicarchive.archivers:
exe_name = settngs.sanitize_name(archiver.exe)
if exe_name in cfg[0]["archiver"] and cfg[0]["archiver"][exe_name]:
if os.path.basename(cfg[0]["archiver"][exe_name]) == archiver.exe:
comicapi.utils.add_to_path(os.path.dirname(cfg[0]["archiver"][exe_name]))
if exe_name in cfg[0][group_for_plugin(archiver())] and cfg[0][group_for_plugin(archiver())][exe_name]:
if os.path.basename(cfg[0][group_for_plugin(archiver())][exe_name]) == archiver.exe:
comicapi.utils.add_to_path(os.path.dirname(cfg[0][group_for_plugin(archiver())][exe_name]))
else:
archiver.exe = cfg[0]["archiver"][exe_name]
archiver.exe = cfg[0][group_for_plugin(archiver())][exe_name]
return config
@ -67,12 +79,12 @@ def validate_archive_settings(config: settngs.Config[ct_ns]) -> settngs.Config[c
def validate_talker_settings(config: settngs.Config[ct_ns]) -> settngs.Config[ct_ns]:
# Apply talker settings from config file
cfg = settngs.normalize_config(config, True, True)
for talker_id, talker in list(comictaggerlib.ctsettings.talkers.items()):
for talker in list(comictaggerlib.ctsettings.talkers.values()):
try:
cfg[0]["talker_" + talker_id] = talker.parse_settings(cfg[0]["talker_" + talker_id])
cfg[0][group_for_plugin(talker)] = talker.parse_settings(cfg[0][group_for_plugin(talker)])
except Exception as e:
# Remove talker as we failed to apply the settings
del comictaggerlib.ctsettings.talkers[talker_id]
del comictaggerlib.ctsettings.talkers[talker.id]
logger.exception("Failed to initialize talker settings: %s", e)
return cast(settngs.Config[ct_ns], settngs.get_namespace(cfg, file=True, cmdline=True))
@ -85,5 +97,5 @@ def validate_plugin_settings(config: settngs.Config[ct_ns]) -> settngs.Config[ct
def register_plugin_settings(manager: settngs.Manager) -> None:
manager.add_persistent_group("archiver", archiver, False)
manager.add_persistent_group("Archive", archiver, False)
register_talker_settings(manager)

View File

@ -8,40 +8,39 @@ import comictaggerlib.defaults
class settngs_namespace(settngs.TypedNS):
commands_version: bool
commands_print: bool
commands_delete: bool
commands_copy: int
commands_save: bool
commands_rename: bool
commands_export_to_zip: bool
commands_only_set_cv_key: bool
Commands_version: bool
Commands_print: bool
Commands_delete: bool
Commands_copy: int
Commands_save: bool
Commands_rename: bool
Commands_export_to_zip: bool
Commands_only_save_config: bool
Commands_list_plugins: bool
runtime_config: comictaggerlib.ctsettings.types.ComicTaggerPaths
runtime_verbose: int
runtime_abort_on_conflict: bool
runtime_delete_after_zip_export: bool
runtime_parse_filename: bool
runtime_issue_id: str
runtime_online: bool
runtime_metadata: comicapi.genericmetadata.GenericMetadata
runtime_interactive: bool
runtime_abort_on_low_confidence: bool
runtime_summary: bool
runtime_raw: bool
runtime_recursive: bool
runtime_script: str
runtime_split_words: bool
runtime_dryrun: bool
runtime_darkmode: bool
runtime_glob: bool
runtime_quiet: bool
runtime_type: list[int]
runtime_overwrite: bool
runtime_no_gui: bool
runtime_files: list[str]
general_check_for_new_version: bool
Runtime_Options_config: comictaggerlib.ctsettings.types.ComicTaggerPaths
Runtime_Options_verbose: int
Runtime_Options_abort_on_conflict: bool
Runtime_Options_delete_after_zip_export: bool
Runtime_Options_parse_filename: bool
Runtime_Options_issue_id: str
Runtime_Options_online: bool
Runtime_Options_metadata: comicapi.genericmetadata.GenericMetadata
Runtime_Options_interactive: bool
Runtime_Options_abort_on_low_confidence: bool
Runtime_Options_summary: bool
Runtime_Options_raw: bool
Runtime_Options_recursive: bool
Runtime_Options_script: str
Runtime_Options_split_words: bool
Runtime_Options_dryrun: bool
Runtime_Options_darkmode: bool
Runtime_Options_glob: bool
Runtime_Options_quiet: bool
Runtime_Options_type: list[int]
Runtime_Options_overwrite: bool
Runtime_Options_no_gui: bool
Runtime_Options_files: list[str]
internal_install_id: str
internal_save_data_style: int
@ -56,50 +55,56 @@ class settngs_namespace(settngs.TypedNS):
internal_sort_column: int
internal_sort_direction: int
identifier_series_match_identify_thresh: int
identifier_border_crop_percent: int
identifier_publisher_filter: list[str]
identifier_series_match_search_thresh: int
identifier_clear_metadata_on_import: bool
identifier_auto_imprint: bool
identifier_sort_series_by_year: bool
identifier_exact_series_matches_first: bool
identifier_always_use_publisher_filter: bool
identifier_clear_form_before_populating: bool
Issue_Identifier_series_match_identify_thresh: int
Issue_Identifier_border_crop_percent: int
Issue_Identifier_publisher_filter: list[str]
Issue_Identifier_series_match_search_thresh: int
Issue_Identifier_clear_metadata_on_import: bool
Issue_Identifier_auto_imprint: bool
Issue_Identifier_sort_series_by_year: bool
Issue_Identifier_exact_series_matches_first: bool
Issue_Identifier_always_use_publisher_filter: bool
Issue_Identifier_clear_form_before_populating: bool
dialog_show_disclaimer: bool
dialog_dont_notify_about_this_version: str
dialog_ask_about_usage_stats: bool
Filename_Parsing_complicated_parser: bool
Filename_Parsing_remove_c2c: bool
Filename_Parsing_remove_fcbd: bool
Filename_Parsing_remove_publisher: bool
filename_complicated_parser: bool
filename_remove_c2c: bool
filename_remove_fcbd: bool
filename_remove_publisher: bool
Sources_source: str
Sources_remove_html_tables: bool
talker_source: str
talker_remove_html_tables: bool
Comic_Book_Lover_assume_lone_credit_is_primary: bool
Comic_Book_Lover_copy_characters_to_tags: bool
Comic_Book_Lover_copy_teams_to_tags: bool
Comic_Book_Lover_copy_locations_to_tags: bool
Comic_Book_Lover_copy_storyarcs_to_tags: bool
Comic_Book_Lover_copy_notes_to_comments: bool
Comic_Book_Lover_copy_weblink_to_comments: bool
Comic_Book_Lover_apply_transform_on_import: bool
Comic_Book_Lover_apply_transform_on_bulk_operation: bool
cbl_assume_lone_credit_is_primary: bool
cbl_copy_characters_to_tags: bool
cbl_copy_teams_to_tags: bool
cbl_copy_locations_to_tags: bool
cbl_copy_storyarcs_to_tags: bool
cbl_copy_notes_to_comments: bool
cbl_copy_weblink_to_comments: bool
cbl_apply_transform_on_import: bool
cbl_apply_transform_on_bulk_operation: bool
File_Rename_template: str
File_Rename_issue_number_padding: int
File_Rename_use_smart_string_cleanup: bool
File_Rename_set_extension_based_on_archive: bool
File_Rename_dir: str
File_Rename_move_to_dir: bool
File_Rename_strict: bool
File_Rename_replacements: comictaggerlib.defaults.Replacements
rename_template: str
rename_issue_number_padding: int
rename_use_smart_string_cleanup: bool
rename_set_extension_based_on_archive: bool
rename_dir: str
rename_move_to_dir: bool
rename_strict: bool
rename_replacements: comictaggerlib.defaults.Replacements
Auto_Tag_save_on_low_confidence: bool
Auto_Tag_dont_use_year_when_identifying: bool
Auto_Tag_assume_1_if_no_issue_num: bool
Auto_Tag_ignore_leading_numbers_in_filename: bool
Auto_Tag_remove_archive_after_successful_match: bool
autotag_save_on_low_confidence: bool
autotag_dont_use_year_when_identifying: bool
autotag_assume_1_if_no_issue_num: bool
autotag_ignore_leading_numbers_in_filename: bool
autotag_remove_archive_after_successful_match: bool
General_check_for_new_version: bool
Dialog_Flags_show_disclaimer: bool
Dialog_Flags_dont_notify_about_this_version: str
Dialog_Flags_ask_about_usage_stats: bool
Source_comicvine_comicvine_key: str
Source_comicvine_comicvine_url: str
Source_comicvine_cv_use_series_start_as_volume: bool

View File

@ -95,10 +95,9 @@ def open_tagger_window(
talkers: dict[str, ComicTalker], config: settngs.Config[ct_ns], error: tuple[str, bool] | None
) -> None:
os.environ["QtWidgets.QT_AUTO_SCREEN_SCALE_FACTOR"] = "1"
args = []
if config[0].runtime_darkmode:
args = [sys.argv[0]]
if config[0].Runtime_Options_darkmode:
args.extend(["-platform", "windows:darkmode=2"])
args.extend(sys.argv)
app = Application(args)
if error is not None:
show_exception_box(error[0])
@ -106,7 +105,7 @@ def open_tagger_window(
raise SystemExit(1)
# needed to catch initial open file events (macOS)
app.openFileRequest.connect(lambda x: config[0].runtime_files.append(x.toLocalFile()))
app.openFileRequest.connect(lambda x: config[0].Runtime_Options_files.append(x.toLocalFile()))
if platform.system() == "Darwin":
# Set the MacOS dock icon
@ -134,7 +133,7 @@ def open_tagger_window(
QtWidgets.QApplication.processEvents()
try:
tagger_window = TaggerWindow(config[0].runtime_files, config, talkers)
tagger_window = TaggerWindow(config[0].Runtime_Options_files, config, talkers)
tagger_window.setWindowIcon(QtGui.QIcon(str(graphics_path / "app.png")))
tagger_window.show()

View File

@ -96,10 +96,10 @@ class IssueIdentifier:
# used to eliminate series names that are too long based on our search
# string
self.series_match_thresh = config.identifier_series_match_identify_thresh
self.series_match_thresh = config.Issue_Identifier_series_match_identify_thresh
# used to eliminate unlikely publishers
self.publisher_filter = [s.strip().casefold() for s in config.identifier_publisher_filter]
self.publisher_filter = [s.strip().casefold() for s in config.Issue_Identifier_publisher_filter]
self.additional_metadata = GenericMetadata()
self.output_function: Callable[[str], None] = IssueIdentifier.default_write_output
@ -239,10 +239,10 @@ class IssueIdentifier:
# try to get some metadata from filename
md_from_filename = ca.metadata_from_filename(
self.config.filename_complicated_parser,
self.config.filename_remove_c2c,
self.config.filename_remove_fcbd,
self.config.filename_remove_publisher,
self.config.Filename_Parsing_complicated_parser,
self.config.Filename_Parsing_remove_c2c,
self.config.Filename_Parsing_remove_fcbd,
self.config.Filename_Parsing_remove_publisher,
)
working_md = md_from_filename.copy()
@ -291,7 +291,7 @@ class IssueIdentifier:
return Score(score=0, url="", hash=0)
try:
url_image_data = ImageFetcher(self.config.runtime_config.user_cache_dir).fetch(
url_image_data = ImageFetcher(self.config.Runtime_Options_config.user_cache_dir).fetch(
primary_img_url, blocking=True
)
except ImageFetcherException as e:
@ -313,7 +313,7 @@ class IssueIdentifier:
if use_remote_alternates:
for alt_url in alt_urls:
try:
alt_url_image_data = ImageFetcher(self.config.runtime_config.user_cache_dir).fetch(
alt_url_image_data = ImageFetcher(self.config.Runtime_Options_config.user_cache_dir).fetch(
alt_url, blocking=True
)
except ImageFetcherException as e:
@ -499,7 +499,7 @@ class IssueIdentifier:
if narrow_cover_hash is not None:
hash_list.append(narrow_cover_hash)
cropped_border = self.crop_border(cover_image_data, self.config.identifier_border_crop_percent)
cropped_border = self.crop_border(cover_image_data, self.config.Issue_Identifier_border_crop_percent)
if cropped_border is not None:
hash_list.append(self.calculate_hash(cropped_border))
logger.info("Adding cropped cover to the hashlist")

View File

@ -52,7 +52,10 @@ class IssueSelectionWindow(QtWidgets.QDialog):
uic.loadUi(ui_path / "issueselectionwindow.ui", self)
self.coverWidget = CoverImageWidget(
self.coverImageContainer, CoverImageWidget.AltCoverMode, config.runtime_config.user_cache_dir, talker
self.coverImageContainer,
CoverImageWidget.AltCoverMode,
config.Runtime_Options_config.user_cache_dir,
talker,
)
gridlayout = QtWidgets.QGridLayout(self.coverImageContainer)
gridlayout.addWidget(self.coverWidget)
@ -87,7 +90,7 @@ class IssueSelectionWindow(QtWidgets.QDialog):
self.config = config
self.talker = talker
self.url_fetch_thread = None
self.issue_list: list[GenericMetadata] = []
self.issue_list: dict[str, GenericMetadata] = {}
# Display talker logo and set url
self.lblIssuesSourceName.setText(talker.attribution)
@ -95,7 +98,7 @@ class IssueSelectionWindow(QtWidgets.QDialog):
self.imageIssuesSourceWidget = CoverImageWidget(
self.imageIssuesSourceLogo,
CoverImageWidget.URLMode,
config.runtime_config.user_cache_dir,
config.Runtime_Options_config.user_cache_dir,
talker,
False,
)
@ -143,7 +146,9 @@ class IssueSelectionWindow(QtWidgets.QDialog):
QtWidgets.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.CursorShape.WaitCursor))
try:
self.issue_list = self.talker.fetch_issues_in_series(self.series_id)
self.issue_list = {
x.issue_id: x for x in self.talker.fetch_issues_in_series(self.series_id) if x.issue_id is not None
}
except TalkerError as e:
QtWidgets.QApplication.restoreOverrideCursor()
QtWidgets.QMessageBox.critical(self, f"{e.source} {e.code_name} Error", f"{e}")
@ -153,7 +158,7 @@ class IssueSelectionWindow(QtWidgets.QDialog):
self.twList.setSortingEnabled(False)
for row, issue in enumerate(self.issue_list):
for row, issue in enumerate(self.issue_list.values()):
self.twList.insertRow(row)
item_text = issue.issue or ""
@ -166,11 +171,11 @@ class IssueSelectionWindow(QtWidgets.QDialog):
item_text = ""
if issue.year is not None:
item_text = f"{issue.year:04}"
item_text += f"-{issue.year:04}"
if issue.month is not None:
item_text = f"{issue.month:02}"
item_text += f"-{issue.month:02}"
qtw_item = QtWidgets.QTableWidgetItem(item_text)
qtw_item = QtWidgets.QTableWidgetItem(item_text.strip("-"))
qtw_item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
qtw_item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
self.twList.setItem(row, 1, qtw_item)
@ -208,8 +213,8 @@ class IssueSelectionWindow(QtWidgets.QDialog):
self.issue_id = self.twList.item(curr.row(), 0).data(QtCore.Qt.ItemDataRole.UserRole)
# list selection was changed, update the the issue cover
issue = self.issue_list[curr.row()]
if not all((issue.issue, issue.year, issue.month, issue.cover_image)): # issue.title, issue.description
issue = self.issue_list[self.issue_id]
if not (issue.issue and issue.year and issue.month and issue.cover_image):
issue = self.talker.fetch_comic_data(issue_id=self.issue_id)
self.issue_number = issue.issue or ""
self.coverWidget.set_issue_details(self.issue_id, [issue.cover_image or "", *issue.alternate_images])

View File

@ -91,7 +91,7 @@ def configure_locale() -> None:
def update_publishers(config: settngs.Config[ct_ns]) -> None:
json_file = config[0].runtime_config.user_config_dir / "publishers.json"
json_file = config[0].Runtime_Options_config.user_config_dir / "publishers.json"
if json_file.exists():
try:
comicapi.utils.update_publishers(json.loads(json_file.read_text("utf-8")))
@ -121,6 +121,18 @@ class App:
comicapi.comicarchive.load_archive_plugins()
ctsettings.talkers = comictalker.get_talkers(version, opts.config.user_cache_dir)
def list_plugins(
self, talkers: list[comictalker.ComicTalker], archivers: list[type[comicapi.comicarchive.Archiver]]
) -> None:
print("Metadata Sources: (ID: Name URL)") # noqa: T201
for talker in talkers:
print(f"{talker.id}: {talker.name} {talker.default_api_url}") # noqa: T201
print("\nComic Archive: (Name: extension, exe)") # noqa: T201
for archiver in archivers:
a = archiver()
print(f"{a.name()}: {a.extension()}, {a.exe}") # noqa: T201
def initialize(self) -> argparse.Namespace:
conf, _ = self.initial_arg_parser.parse_known_args()
assert conf is not None
@ -141,7 +153,7 @@ class App:
config_paths.user_config_dir / "settings.json", list(args) or None
)
config = cast(settngs.Config[ct_ns], self.manager.get_namespace(cfg, file=True, cmdline=True))
config[0].runtime_config = config_paths
config[0].Runtime_Options_config = config_paths
config = ctsettings.validate_commandline_settings(config, self.manager)
config = ctsettings.validate_file_settings(config)
@ -170,7 +182,7 @@ class App:
if len(talkers) < 1:
error = error = (
f"Failed to load any talkers, please re-install and check the log located in '{self.config[0].runtime_config.user_log_dir}' for more details",
f"Failed to load any talkers, please re-install and check the log located in '{self.config[0].Runtime_Options_config.user_log_dir}' for more details",
True,
)
@ -183,34 +195,31 @@ class App:
comicapi.utils.load_publishers()
update_publishers(self.config)
# manage the CV API key
# None comparison is used so that the empty string can unset the value
if not error and (
self.config[0].talker_comicvine_comicvine_key is not None # type: ignore[attr-defined]
or self.config[0].talker_comicvine_comicvine_url is not None # type: ignore[attr-defined]
):
settings_path = self.config[0].runtime_config.user_config_dir / "settings.json"
if self.config_load_success:
self.manager.save_file(self.config[0], settings_path)
if self.config[0].Commands_list_plugins:
self.list_plugins(list(talkers.values()), comicapi.comicarchive.archivers)
return
if self.config[0].commands_only_set_cv_key:
if self.config[0].Commands_only_save_config:
if self.config_load_success:
settings_path = self.config[0].Runtime_Options_config.user_config_dir / "settings.json"
if self.config_load_success:
self.manager.save_file(self.config[0], settings_path)
print("Key set") # noqa: T201
return
if not self.config_load_success:
error = (
f"Failed to load settings, check the log located in '{self.config[0].runtime_config.user_log_dir}' for more details",
f"Failed to load settings, check the log located in '{self.config[0].Runtime_Options_config.user_log_dir}' for more details",
True,
)
if not self.config[0].runtime_no_gui:
if not self.config[0].Runtime_Options_no_gui:
try:
from comictaggerlib import gui
return gui.open_tagger_window(talkers, self.config, error)
except ImportError:
self.config[0].runtime_no_gui = True
self.config[0].Runtime_Options_no_gui = True
logger.warning("PyQt5 is not available. ComicTagger is limited to command-line mode.")
# GUI mode is not available or CLI mode was requested

View File

@ -45,7 +45,7 @@ class MatchSelectionWindow(QtWidgets.QDialog):
uic.loadUi(ui_path / "matchselectionwindow.ui", self)
self.altCoverWidget = CoverImageWidget(
self.altCoverContainer, CoverImageWidget.AltCoverMode, config.runtime_config.user_cache_dir, talker
self.altCoverContainer, CoverImageWidget.AltCoverMode, config.Runtime_Options_config.user_cache_dir, talker
)
gridlayout = QtWidgets.QGridLayout(self.altCoverContainer)
gridlayout.addWidget(self.altCoverWidget)

View File

@ -62,32 +62,32 @@ class RenameWindow(QtWidgets.QDialog):
self.rename_list: list[str] = []
self.btnSettings.clicked.connect(self.modify_settings)
platform = "universal" if self.config[0].rename_strict else "auto"
self.renamer = FileRenamer(None, platform=platform, replacements=self.config[0].rename_replacements)
platform = "universal" if self.config[0].File_Rename_strict else "auto"
self.renamer = FileRenamer(None, platform=platform, replacements=self.config[0].File_Rename_replacements)
self.do_preview()
def config_renamer(self, ca: ComicArchive, md: GenericMetadata | None = None) -> str:
self.renamer.set_template(self.config[0].rename_template)
self.renamer.set_issue_zero_padding(self.config[0].rename_issue_number_padding)
self.renamer.set_smart_cleanup(self.config[0].rename_use_smart_string_cleanup)
self.renamer.replacements = self.config[0].rename_replacements
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)
self.renamer.replacements = self.config[0].File_Rename_replacements
new_ext = ca.path.suffix # default
if self.config[0].rename_set_extension_based_on_archive:
if self.config[0].File_Rename_set_extension_based_on_archive:
new_ext = ca.extension()
if md is None:
md = ca.read_metadata(self.data_style)
if md.is_empty:
md = ca.metadata_from_filename(
self.config[0].filename_complicated_parser,
self.config[0].filename_remove_c2c,
self.config[0].filename_remove_fcbd,
self.config[0].filename_remove_publisher,
self.config[0].Filename_Parsing_complicated_parser,
self.config[0].Filename_Parsing_remove_c2c,
self.config[0].Filename_Parsing_remove_fcbd,
self.config[0].Filename_Parsing_remove_publisher,
)
self.renamer.set_metadata(md)
self.renamer.move = self.config[0].rename_move_to_dir
self.renamer.move = self.config[0].File_Rename_move_to_dir
return new_ext
def do_preview(self) -> None:
@ -100,7 +100,7 @@ class RenameWindow(QtWidgets.QDialog):
try:
new_name = self.renamer.determine_name(new_ext)
except ValueError as e:
logger.exception("Invalid format string: %s", self.config[0].rename_template)
logger.exception("Invalid format string: %s", self.config[0].File_Rename_template)
QtWidgets.QMessageBox.critical(
self,
"Invalid format string!",
@ -114,7 +114,7 @@ class RenameWindow(QtWidgets.QDialog):
return
except Exception as e:
logger.exception(
"Formatter failure: %s metadata: %s", self.config[0].rename_template, self.renamer.metadata
"Formatter failure: %s metadata: %s", self.config[0].File_Rename_template, self.renamer.metadata
)
QtWidgets.QMessageBox.critical(
self,
@ -190,7 +190,7 @@ class RenameWindow(QtWidgets.QDialog):
folder = get_rename_dir(
comic[0],
self.config[0].rename_dir if self.config[0].rename_move_to_dir else None,
self.config[0].File_Rename_dir if self.config[0].File_Rename_move_to_dir else None,
)
full_path = folder / comic[1]

View File

@ -116,7 +116,7 @@ class SeriesSelectionWindow(QtWidgets.QDialog):
uic.loadUi(ui_path / "seriesselectionwindow.ui", self)
self.imageWidget = CoverImageWidget(
self.imageContainer, CoverImageWidget.URLMode, config.runtime_config.user_cache_dir, talker
self.imageContainer, CoverImageWidget.URLMode, config.Runtime_Options_config.user_cache_dir, talker
)
gridlayout = QtWidgets.QGridLayout(self.imageContainer)
gridlayout.addWidget(self.imageWidget)
@ -153,7 +153,7 @@ class SeriesSelectionWindow(QtWidgets.QDialog):
self.comic_archive = comic_archive
self.immediate_autoselect = autoselect
self.cover_index_list = cover_index_list
self.series_list: list[ComicSeries] = []
self.series_list: dict[str, ComicSeries] = {}
self.literal = literal
self.ii: IssueIdentifier | None = None
self.iddialog: IDProgressWindow | None = None
@ -161,7 +161,7 @@ class SeriesSelectionWindow(QtWidgets.QDialog):
self.progdialog: QtWidgets.QProgressDialog | None = None
self.search_thread: SearchThread | None = None
self.use_filter = self.config.identifier_always_use_publisher_filter
self.use_filter = self.config.Issue_Identifier_always_use_publisher_filter
# Load to retrieve settings
self.talker = talker
@ -172,7 +172,7 @@ class SeriesSelectionWindow(QtWidgets.QDialog):
self.imageSourceWidget = CoverImageWidget(
self.imageSourceLogo,
CoverImageWidget.URLMode,
config.runtime_config.user_cache_dir,
config.Runtime_Options_config.user_cache_dir,
talker,
False,
)
@ -332,7 +332,7 @@ class SeriesSelectionWindow(QtWidgets.QDialog):
def show_issues(self) -> None:
selector = IssueSelectionWindow(self, self.config, self.talker, self.series_id, self.issue_number)
title = ""
for series in self.series_list:
for series in self.series_list.values():
if series.id == self.series_id:
title = f"{series.name} ({series.start_year:04}) - "
break
@ -349,14 +349,18 @@ class SeriesSelectionWindow(QtWidgets.QDialog):
self.imageWidget.update_content()
def select_by_id(self) -> None:
for r, series in enumerate(self.series_list):
if series.id == self.series_id:
for r in range(self.twList.rows()):
if self.series_id == self.twList.item(r, 0).data(QtCore.Qt.ItemDataRole.UserRole):
self.twList.selectRow(r)
break
def perform_query(self, refresh: bool = False) -> None:
self.search_thread = SearchThread(
self.talker, self.series_name, refresh, self.literal, self.config.identifier_series_match_search_thresh
self.talker,
self.series_name,
refresh,
self.literal,
self.config.Issue_Identifier_series_match_search_thresh,
)
self.search_thread.searchComplete.connect(self.search_complete)
self.search_thread.progressUpdate.connect(self.search_progress_update)
@ -404,16 +408,18 @@ class SeriesSelectionWindow(QtWidgets.QDialog):
)
return
self.series_list = self.search_thread.ct_search_results if self.search_thread is not None else []
tmp_list = self.search_thread.ct_search_results if self.search_thread is not None else []
self.series_list = {x.id: x for x in tmp_list}
# filter the publishers if enabled set
if self.use_filter:
try:
publisher_filter = {s.strip().casefold() for s in self.config.identifier_publisher_filter}
publisher_filter = {s.strip().casefold() for s in self.config.Issue_Identifier_publisher_filter}
# use '' as publisher name if None
self.series_list = list(
self.series_list = dict(
filter(
lambda d: ("" if d.publisher is None else str(d.publisher).casefold()) not in publisher_filter,
self.series_list,
lambda d: ("" if d[1].publisher is None else str(d[1].publisher).casefold())
not in publisher_filter,
self.series_list.items(),
)
)
except Exception:
@ -423,30 +429,32 @@ class SeriesSelectionWindow(QtWidgets.QDialog):
# compare as str in case extra chars ie. '1976?'
# - missing (none) values being converted to 'None' - consistent with prior behaviour in v1.2.3
# sort by start_year if set
if self.config.identifier_sort_series_by_year:
if self.config.Issue_Identifier_sort_series_by_year:
try:
self.series_list = natsort.natsorted(
self.series_list,
key=lambda i: (str(i.start_year), str(i.count_of_issues)),
reverse=True,
self.series_list = dict(
natsort.natsorted(
self.series_list.items(),
key=lambda i: (str(i[1].start_year), str(i[1].count_of_issues)),
reverse=True,
)
)
except Exception:
logger.exception("bad data error sorting results by start_year,count_of_issues")
else:
try:
self.series_list = natsort.natsorted(
self.series_list, key=lambda i: str(i.count_of_issues), reverse=True
self.series_list = dict(
natsort.natsorted(self.series_list.items(), key=lambda i: str(i[1].count_of_issues), reverse=True)
)
except Exception:
logger.exception("bad data error sorting results by count_of_issues")
# move sanitized matches to the front
if self.config.identifier_exact_series_matches_first:
if self.config.Issue_Identifier_exact_series_matches_first:
try:
sanitized = utils.sanitize_title(self.series_name, False).casefold()
sanitized_no_articles = utils.sanitize_title(self.series_name, True).casefold()
deques: list[deque[ComicSeries]] = [deque(), deque(), deque()]
deques: list[deque[tuple[str, ComicSeries]]] = [deque(), deque(), deque()]
def categorize(result: ComicSeries) -> int:
# We don't remove anything on this one so that we only get exact matches
@ -458,10 +466,10 @@ class SeriesSelectionWindow(QtWidgets.QDialog):
return 1
return 2
for comic in self.series_list:
deques[categorize(comic)].append(comic)
for comic in self.series_list.items():
deques[categorize(comic[1])].append(comic)
logger.info("Length: %d, %d, %d", len(deques[0]), len(deques[1]), len(deques[2]))
self.series_list = list(itertools.chain.from_iterable(deques))
self.series_list = dict(itertools.chain.from_iterable(deques))
except Exception:
logger.exception("bad data error filtering exact/near matches")
@ -471,7 +479,7 @@ class SeriesSelectionWindow(QtWidgets.QDialog):
self.twList.setRowCount(0)
for row, series in enumerate(self.series_list):
for row, series in enumerate(self.series_list.values()):
self.twList.insertRow(row)
item_text = series.name
@ -553,16 +561,14 @@ class SeriesSelectionWindow(QtWidgets.QDialog):
self.series_id = self.twList.item(curr.row(), 0).data(QtCore.Qt.ItemDataRole.UserRole)
# list selection was changed, update the info on the series
series = self.series_list[curr.row()]
if not all(
(
series.name,
series.start_year,
series.count_of_issues,
series.publisher,
series.description,
series.image_url,
)
series = self.series_list[self.series_id]
if not (
series.name
and series.start_year
and series.count_of_issues
and series.publisher
and series.description
and series.image_url
):
series = self.talker.fetch_series(self.series_id)
if series.description is None:

View File

@ -188,7 +188,7 @@ class SettingsWindow(QtWidgets.QDialog):
self.leRenameTemplate.setToolTip(f"<pre>{html.escape(template_tooltip)}</pre>")
self.rename_error: Exception | None = None
self.sources: dict = comictaggerlib.ui.talkeruigenerator.generate_source_option_tabs(
self.sources = comictaggerlib.ui.talkeruigenerator.generate_source_option_tabs(
self.tComicTalkers, self.config, self.talkers
)
self.connect_signals()
@ -307,43 +307,45 @@ class SettingsWindow(QtWidgets.QDialog):
self.leRarExePath.setText(getattr(self.config[0], self.config[1]["archiver"].v["rar"].internal_name))
else:
self.leRarExePath.setEnabled(False)
self.sbNameMatchIdentifyThresh.setValue(self.config[0].identifier_series_match_identify_thresh)
self.sbNameMatchSearchThresh.setValue(self.config[0].identifier_series_match_search_thresh)
self.tePublisherFilter.setPlainText("\n".join(self.config[0].identifier_publisher_filter))
self.sbNameMatchIdentifyThresh.setValue(self.config[0].Issue_Identifier_series_match_identify_thresh)
self.sbNameMatchSearchThresh.setValue(self.config[0].Issue_Identifier_series_match_search_thresh)
self.tePublisherFilter.setPlainText("\n".join(self.config[0].Issue_Identifier_publisher_filter))
self.cbxCheckForNewVersion.setChecked(self.config[0].general_check_for_new_version)
self.cbxCheckForNewVersion.setChecked(self.config[0].General_check_for_new_version)
self.cbxComplicatedParser.setChecked(self.config[0].filename_complicated_parser)
self.cbxRemoveC2C.setChecked(self.config[0].filename_remove_c2c)
self.cbxRemoveFCBD.setChecked(self.config[0].filename_remove_fcbd)
self.cbxRemovePublisher.setChecked(self.config[0].filename_remove_publisher)
self.cbxComplicatedParser.setChecked(self.config[0].Filename_Parsing_complicated_parser)
self.cbxRemoveC2C.setChecked(self.config[0].Filename_Parsing_remove_c2c)
self.cbxRemoveFCBD.setChecked(self.config[0].Filename_Parsing_remove_fcbd)
self.cbxRemovePublisher.setChecked(self.config[0].Filename_Parsing_remove_publisher)
self.switch_parser()
self.cbxClearFormBeforePopulating.setChecked(self.config[0].identifier_clear_form_before_populating)
self.cbxUseFilter.setChecked(self.config[0].identifier_always_use_publisher_filter)
self.cbxSortByYear.setChecked(self.config[0].identifier_sort_series_by_year)
self.cbxExactMatches.setChecked(self.config[0].identifier_exact_series_matches_first)
self.cbxClearFormBeforePopulating.setChecked(self.config[0].Issue_Identifier_clear_form_before_populating)
self.cbxUseFilter.setChecked(self.config[0].Issue_Identifier_always_use_publisher_filter)
self.cbxSortByYear.setChecked(self.config[0].Issue_Identifier_sort_series_by_year)
self.cbxExactMatches.setChecked(self.config[0].Issue_Identifier_exact_series_matches_first)
self.cbxAssumeLoneCreditIsPrimary.setChecked(self.config[0].cbl_assume_lone_credit_is_primary)
self.cbxCopyCharactersToTags.setChecked(self.config[0].cbl_copy_characters_to_tags)
self.cbxCopyTeamsToTags.setChecked(self.config[0].cbl_copy_teams_to_tags)
self.cbxCopyLocationsToTags.setChecked(self.config[0].cbl_copy_locations_to_tags)
self.cbxCopyStoryArcsToTags.setChecked(self.config[0].cbl_copy_storyarcs_to_tags)
self.cbxCopyNotesToComments.setChecked(self.config[0].cbl_copy_notes_to_comments)
self.cbxCopyWebLinkToComments.setChecked(self.config[0].cbl_copy_weblink_to_comments)
self.cbxApplyCBLTransformOnCVIMport.setChecked(self.config[0].cbl_apply_transform_on_import)
self.cbxApplyCBLTransformOnBatchOperation.setChecked(self.config[0].cbl_apply_transform_on_bulk_operation)
self.cbxAssumeLoneCreditIsPrimary.setChecked(self.config[0].Comic_Book_Lover_assume_lone_credit_is_primary)
self.cbxCopyCharactersToTags.setChecked(self.config[0].Comic_Book_Lover_copy_characters_to_tags)
self.cbxCopyTeamsToTags.setChecked(self.config[0].Comic_Book_Lover_copy_teams_to_tags)
self.cbxCopyLocationsToTags.setChecked(self.config[0].Comic_Book_Lover_copy_locations_to_tags)
self.cbxCopyStoryArcsToTags.setChecked(self.config[0].Comic_Book_Lover_copy_storyarcs_to_tags)
self.cbxCopyNotesToComments.setChecked(self.config[0].Comic_Book_Lover_copy_notes_to_comments)
self.cbxCopyWebLinkToComments.setChecked(self.config[0].Comic_Book_Lover_copy_weblink_to_comments)
self.cbxApplyCBLTransformOnCVIMport.setChecked(self.config[0].Comic_Book_Lover_apply_transform_on_import)
self.cbxApplyCBLTransformOnBatchOperation.setChecked(
self.config[0].Comic_Book_Lover_apply_transform_on_bulk_operation
)
self.leRenameTemplate.setText(self.config[0].rename_template)
self.leIssueNumPadding.setText(str(self.config[0].rename_issue_number_padding))
self.cbxSmartCleanup.setChecked(self.config[0].rename_use_smart_string_cleanup)
self.cbxChangeExtension.setChecked(self.config[0].rename_set_extension_based_on_archive)
self.cbxMoveFiles.setChecked(self.config[0].rename_move_to_dir)
self.leDirectory.setText(self.config[0].rename_dir)
self.cbxRenameStrict.setChecked(self.config[0].rename_strict)
self.leRenameTemplate.setText(self.config[0].File_Rename_template)
self.leIssueNumPadding.setText(str(self.config[0].File_Rename_issue_number_padding))
self.cbxSmartCleanup.setChecked(self.config[0].File_Rename_use_smart_string_cleanup)
self.cbxChangeExtension.setChecked(self.config[0].File_Rename_set_extension_based_on_archive)
self.cbxMoveFiles.setChecked(self.config[0].File_Rename_move_to_dir)
self.leDirectory.setText(self.config[0].File_Rename_dir)
self.cbxRenameStrict.setChecked(self.config[0].File_Rename_strict)
for table, replacments in zip(
(self.twLiteralReplacements, self.twValueReplacements), self.config[0].rename_replacements
(self.twLiteralReplacements, self.twValueReplacements), self.config[0].File_Rename_replacements
):
table.clearContents()
for i in reversed(range(table.rowCount())):
@ -383,7 +385,7 @@ class SettingsWindow(QtWidgets.QDialog):
self.rename_test()
if self.rename_error is not None:
if isinstance(self.rename_error, ValueError):
logger.exception("Invalid format string: %s", self.config[0].rename_template)
logger.exception("Invalid format string: %s", self.config[0].File_Rename_template)
QtWidgets.QMessageBox.critical(
self,
"Invalid format string!",
@ -397,7 +399,7 @@ class SettingsWindow(QtWidgets.QDialog):
return
else:
logger.exception(
"Formatter failure: %s metadata: %s", self.config[0].rename_template, self.renamer.metadata
"Formatter failure: %s metadata: %s", self.config[0].File_Rename_template, self.renamer.metadata
)
QtWidgets.QMessageBox.critical(
self,
@ -420,48 +422,50 @@ class SettingsWindow(QtWidgets.QDialog):
if not str(self.leIssueNumPadding.text()).isdigit():
self.leIssueNumPadding.setText("0")
self.config[0].general_check_for_new_version = self.cbxCheckForNewVersion.isChecked()
self.config[0].General_check_for_new_version = self.cbxCheckForNewVersion.isChecked()
self.config[0].identifier_series_match_identify_thresh = self.sbNameMatchIdentifyThresh.value()
self.config[0].identifier_series_match_search_thresh = self.sbNameMatchSearchThresh.value()
self.config[0].identifier_publisher_filter = utils.split(self.tePublisherFilter.toPlainText(), "\n")
self.config[0].Issue_Identifier_series_match_identify_thresh = self.sbNameMatchIdentifyThresh.value()
self.config[0].Issue_Identifier_series_match_search_thresh = self.sbNameMatchSearchThresh.value()
self.config[0].Issue_Identifier_publisher_filter = utils.split(self.tePublisherFilter.toPlainText(), "\n")
self.config[0].filename_complicated_parser = self.cbxComplicatedParser.isChecked()
self.config[0].filename_remove_c2c = self.cbxRemoveC2C.isChecked()
self.config[0].filename_remove_fcbd = self.cbxRemoveFCBD.isChecked()
self.config[0].filename_remove_publisher = self.cbxRemovePublisher.isChecked()
self.config[0].Filename_Parsing_complicated_parser = self.cbxComplicatedParser.isChecked()
self.config[0].Filename_Parsing_remove_c2c = self.cbxRemoveC2C.isChecked()
self.config[0].Filename_Parsing_remove_fcbd = self.cbxRemoveFCBD.isChecked()
self.config[0].Filename_Parsing_remove_publisher = self.cbxRemovePublisher.isChecked()
self.config[0].identifier_clear_form_before_populating = self.cbxClearFormBeforePopulating.isChecked()
self.config[0].identifier_always_use_publisher_filter = self.cbxUseFilter.isChecked()
self.config[0].identifier_sort_series_by_year = self.cbxSortByYear.isChecked()
self.config[0].identifier_exact_series_matches_first = self.cbxExactMatches.isChecked()
self.config[0].Issue_Identifier_clear_form_before_populating = self.cbxClearFormBeforePopulating.isChecked()
self.config[0].Issue_Identifier_always_use_publisher_filter = self.cbxUseFilter.isChecked()
self.config[0].Issue_Identifier_sort_series_by_year = self.cbxSortByYear.isChecked()
self.config[0].Issue_Identifier_exact_series_matches_first = self.cbxExactMatches.isChecked()
self.config[0].cbl_assume_lone_credit_is_primary = self.cbxAssumeLoneCreditIsPrimary.isChecked()
self.config[0].cbl_copy_characters_to_tags = self.cbxCopyCharactersToTags.isChecked()
self.config[0].cbl_copy_teams_to_tags = self.cbxCopyTeamsToTags.isChecked()
self.config[0].cbl_copy_locations_to_tags = self.cbxCopyLocationsToTags.isChecked()
self.config[0].cbl_copy_storyarcs_to_tags = self.cbxCopyStoryArcsToTags.isChecked()
self.config[0].cbl_copy_notes_to_comments = self.cbxCopyNotesToComments.isChecked()
self.config[0].cbl_copy_weblink_to_comments = self.cbxCopyWebLinkToComments.isChecked()
self.config[0].cbl_apply_transform_on_import = self.cbxApplyCBLTransformOnCVIMport.isChecked()
self.config[0].cbl_apply_transform_on_bulk_operation = self.cbxApplyCBLTransformOnBatchOperation.isChecked()
self.config[0].Comic_Book_Lover_assume_lone_credit_is_primary = self.cbxAssumeLoneCreditIsPrimary.isChecked()
self.config[0].Comic_Book_Lover_copy_characters_to_tags = self.cbxCopyCharactersToTags.isChecked()
self.config[0].Comic_Book_Lover_copy_teams_to_tags = self.cbxCopyTeamsToTags.isChecked()
self.config[0].Comic_Book_Lover_copy_locations_to_tags = self.cbxCopyLocationsToTags.isChecked()
self.config[0].Comic_Book_Lover_copy_storyarcs_to_tags = self.cbxCopyStoryArcsToTags.isChecked()
self.config[0].Comic_Book_Lover_copy_notes_to_comments = self.cbxCopyNotesToComments.isChecked()
self.config[0].Comic_Book_Lover_copy_weblink_to_comments = self.cbxCopyWebLinkToComments.isChecked()
self.config[0].Comic_Book_Lover_apply_transform_on_import = self.cbxApplyCBLTransformOnCVIMport.isChecked()
self.config.values.Comic_Book_Lover_apply_transform_on_bulk_operation = (
self.cbxApplyCBLTransformOnBatchOperation.isChecked()
)
self.config[0].rename_template = str(self.leRenameTemplate.text())
self.config[0].rename_issue_number_padding = int(self.leIssueNumPadding.text())
self.config[0].rename_use_smart_string_cleanup = self.cbxSmartCleanup.isChecked()
self.config[0].rename_set_extension_based_on_archive = self.cbxChangeExtension.isChecked()
self.config[0].rename_move_to_dir = self.cbxMoveFiles.isChecked()
self.config[0].rename_dir = self.leDirectory.text()
self.config[0].File_Rename_template = str(self.leRenameTemplate.text())
self.config[0].File_Rename_issue_number_padding = int(self.leIssueNumPadding.text())
self.config[0].File_Rename_use_smart_string_cleanup = self.cbxSmartCleanup.isChecked()
self.config[0].File_Rename_set_extension_based_on_archive = self.cbxChangeExtension.isChecked()
self.config[0].File_Rename_move_to_dir = self.cbxMoveFiles.isChecked()
self.config[0].File_Rename_dir = self.leDirectory.text()
self.config[0].rename_strict = self.cbxRenameStrict.isChecked()
self.config[0].rename_replacements = self.get_replacements()
self.config[0].File_Rename_strict = self.cbxRenameStrict.isChecked()
self.config[0].File_Rename_replacements = self.get_replacements()
# Read settings from talker tabs
comictaggerlib.ui.talkeruigenerator.form_settings_to_config(self.sources, self.config)
self.update_talkers_config()
settngs.save_file(self.config, self.config[0].runtime_config.user_config_dir / "settings.json")
settngs.save_file(self.config, self.config[0].Runtime_Options_config.user_config_dir / "settings.json")
self.parent().config = self.config
QtWidgets.QDialog.accept(self)
@ -474,8 +478,8 @@ class SettingsWindow(QtWidgets.QDialog):
self.select_file(self.leRarExePath, "RAR")
def clear_cache(self) -> None:
ImageFetcher(self.config[0].runtime_config.user_cache_dir).clear_cache()
ComicCacher(self.config[0].runtime_config.user_cache_dir, version).clear_cache()
ImageFetcher(self.config[0].Runtime_Options_config.user_cache_dir).clear_cache()
ComicCacher(self.config[0].Runtime_Options_config.user_cache_dir, version).clear_cache()
QtWidgets.QMessageBox.information(self, self.name, "Cache has been cleared.")
def reset_settings(self) -> None:

View File

@ -156,10 +156,10 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.setWindowIcon(QtGui.QIcon(str(graphics_path / "app.png")))
if config[0].runtime_type and isinstance(config[0].runtime_type[0], int):
if config[0].Runtime_Options_type and isinstance(config[0].Runtime_Options_type[0], int):
# respect the command line option tag type
config[0].internal_save_data_style = config[0].runtime_type[0]
config[0].internal_load_data_style = config[0].runtime_type[0]
config[0].internal_save_data_style = config[0].Runtime_Options_type[0]
config[0].internal_load_data_style = config[0].Runtime_Options_type[0]
self.save_data_style = config[0].internal_save_data_style
self.load_data_style = config[0].internal_load_data_style
@ -212,6 +212,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
# hook up the callbacks
self.cbLoadDataStyle.currentIndexChanged.connect(self.set_load_data_style)
self.cbSaveDataStyle.currentIndexChanged.connect(self.set_save_data_style)
self.cbx_sources.currentIndexChanged.connect(self.set_source)
self.btnEditCredit.clicked.connect(self.edit_credit)
self.btnAddCredit.clicked.connect(self.add_credit)
self.btnRemoveCredit.clicked.connect(self.remove_credit)
@ -245,7 +246,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
if len(file_list) != 0:
self.fileSelectionList.add_path_list(file_list)
if self.config[0].dialog_show_disclaimer:
if self.config[0].Dialog_Flags_show_disclaimer:
checked = OptionalMessageDialog.msg(
self,
"Welcome!",
@ -264,15 +265,15 @@ class TaggerWindow(QtWidgets.QMainWindow):
Have fun!
""",
)
self.config[0].dialog_show_disclaimer = not checked
self.config[0].Dialog_Flags_show_disclaimer = not checked
if self.config[0].general_check_for_new_version:
if self.config[0].General_check_for_new_version:
self.check_latest_version_online()
def current_talker(self) -> ComicTalker:
if self.config[0].talker_source in self.talkers:
return self.talkers[self.config[0].talker_source]
logger.error("Could not find the '%s' talker", self.config[0].talker_source)
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:
@ -285,7 +286,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
def setup_logger(self) -> ApplicationLogWindow:
try:
current_logs = (self.config[0].runtime_config.user_log_dir / "ComicTagger.log").read_text("utf-8")
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()
@ -618,10 +619,10 @@ class TaggerWindow(QtWidgets.QMainWindow):
def actual_load_current_archive(self) -> None:
if self.metadata.is_empty and self.comic_archive is not None:
self.metadata = self.comic_archive.metadata_from_filename(
self.config[0].filename_complicated_parser,
self.config[0].filename_remove_c2c,
self.config[0].filename_remove_fcbd,
self.config[0].filename_remove_publisher,
self.config[0].Filename_Parsing_complicated_parser,
self.config[0].Filename_Parsing_remove_c2c,
self.config[0].Filename_Parsing_remove_fcbd,
self.config[0].Filename_Parsing_remove_publisher,
)
if len(self.metadata.pages) == 0 and self.comic_archive is not None:
self.metadata.set_default_page_list(self.comic_archive.get_number_of_pages())
@ -967,10 +968,10 @@ class TaggerWindow(QtWidgets.QMainWindow):
# copy the form onto metadata object
self.form_to_metadata()
new_metadata = self.comic_archive.metadata_from_filename(
self.config[0].filename_complicated_parser,
self.config[0].filename_remove_c2c,
self.config[0].filename_remove_fcbd,
self.config[0].filename_remove_publisher,
self.config[0].Filename_Parsing_complicated_parser,
self.config[0].Filename_Parsing_remove_c2c,
self.config[0].Filename_Parsing_remove_fcbd,
self.config[0].Filename_Parsing_remove_publisher,
split_words,
)
if new_metadata is not None:
@ -1079,10 +1080,10 @@ class TaggerWindow(QtWidgets.QMainWindow):
else:
QtWidgets.QApplication.restoreOverrideCursor()
if new_metadata is not None:
if self.config[0].cbl_apply_transform_on_import:
if self.config[0].Comic_Book_Lover_apply_transform_on_import:
new_metadata = CBLTransformer(new_metadata, self.config[0]).apply()
if self.config[0].identifier_clear_form_before_populating:
if self.config[0].Issue_Identifier_clear_form_before_populating:
self.clear_form()
notes = (
@ -1093,7 +1094,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
new_metadata.replace(
notes=utils.combine_notes(self.metadata.notes, notes, "Tagged with ComicTagger"),
description=cleanup_html(
new_metadata.description, self.config[0].talker_remove_html_tables
new_metadata.description, self.config[0].Sources_remove_html_tables
),
)
)
@ -1155,6 +1156,9 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.update_style_tweaks()
self.update_menus()
def set_source(self, s: int) -> None:
self.config[0].Sources_source = self.cbx_sources.itemData(s)
def update_credit_colors(self) -> None:
# !!!ATB qt5 porting TODO
inactive_color = QtGui.QColor(255, 170, 150)
@ -1372,6 +1376,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
settingswin.setModal(True)
settingswin.exec()
settingswin.result()
self.adjust_source_combo()
def set_app_position(self) -> None:
if self.config[0].internal_window_width != 0:
@ -1382,6 +1387,9 @@ class TaggerWindow(QtWidgets.QMainWindow):
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_load_style_combo(self) -> None:
# select the current style
if self.load_data_style == MetaDataStyle.CBI:
@ -1407,6 +1415,11 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.cbSaveDataStyle.addItem("ComicRack", MetaDataStyle.CIX)
self.adjust_save_style_combo()
# 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)):
@ -1636,7 +1649,10 @@ class TaggerWindow(QtWidgets.QMainWindow):
if ca.has_metadata(src_style) and ca.is_writable():
md = ca.read_metadata(src_style)
if dest_style == MetaDataStyle.CBI and self.config[0].cbl_apply_transform_on_bulk_operation:
if (
dest_style == MetaDataStyle.CBI
and self.config[0].Comic_Book_Lover_apply_transform_on_bulk_operation
):
md = CBLTransformer(md, self.config[0]).apply()
if not ca.write_metadata(md, dest_style):
@ -1674,7 +1690,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
logger.exception("Save aborted.")
if not ct_md.is_empty:
if self.config[0].cbl_apply_transform_on_import:
if self.config[0].Comic_Book_Lover_apply_transform_on_import:
ct_md = CBLTransformer(ct_md, self.config[0]).apply()
QtWidgets.QApplication.restoreOverrideCursor()
@ -1704,10 +1720,10 @@ class TaggerWindow(QtWidgets.QMainWindow):
logger.error("Failed to load metadata for %s: %s", ca.path, e)
if md.is_empty:
md = ca.metadata_from_filename(
self.config[0].filename_complicated_parser,
self.config[0].filename_remove_c2c,
self.config[0].filename_remove_fcbd,
self.config[0].filename_remove_publisher,
self.config[0].Filename_Parsing_complicated_parser,
self.config[0].Filename_Parsing_remove_c2c,
self.config[0].Filename_Parsing_remove_fcbd,
self.config[0].Filename_Parsing_remove_publisher,
dlg.split_words,
)
if dlg.ignore_leading_digits_in_filename and md.series is not None:
@ -1793,7 +1809,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
)
md.overlay(ct_md.replace(notes=utils.combine_notes(md.notes, notes, "Tagged with ComicTagger")))
if self.config[0].identifier_auto_imprint:
if self.config[0].Issue_Identifier_auto_imprint:
md.fix_publisher()
if not ca.write_metadata(md, self.save_data_style):
@ -1979,7 +1995,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.config[0].internal_sort_column,
self.config[0].internal_sort_direction,
) = self.fileSelectionList.get_sorting()
settngs.save_file(self.config, self.config[0].runtime_config.user_config_dir / "settings.json")
settngs.save_file(self.config, self.config[0].Runtime_Options_config.user_config_dir / "settings.json")
event.accept()
else:
@ -2106,7 +2122,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.version_check_complete(version_checker.get_latest_version(self.config[0].internal_install_id))
def version_check_complete(self, new_version: tuple[str, str]) -> None:
if new_version[0] not in (self.version, self.config[0].dialog_dont_notify_about_this_version):
if new_version[0] not in (self.version, self.config[0].Dialog_Flags_dont_notify_about_this_version):
website = "https://github.com/comictagger/comictagger"
checked = OptionalMessageDialog.msg(
self,
@ -2117,7 +2133,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
"Don't tell me about this version again",
)
if checked:
self.config[0].dialog_dont_notify_about_this_version = new_version[0]
self.config[0].Dialog_Flags_dont_notify_about_this_version = new_version[0]
def on_incoming_socket_connection(self) -> None:
# Accept connection from other instance.

View File

@ -59,26 +59,36 @@
<property name="formAlignment">
<set>Qt::AlignHCenter|Qt::AlignTop</set>
</property>
<item row="0" column="0">
<item row="1" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Read Style</string>
</property>
</widget>
</item>
<item row="0" column="1">
<item row="1" column="1">
<widget class="QComboBox" name="cbLoadDataStyle"/>
</item>
<item row="1" column="0">
<item row="2" column="0">
<widget class="QLabel" name="saveStyleLabel">
<property name="text">
<string>Modify Style</string>
</property>
</widget>
</item>
<item row="1" column="1">
<item row="2" column="1">
<widget class="QComboBox" name="cbSaveDataStyle"/>
</item>
<item row="0" column="0">
<widget class="QLabel" name="lbl_md_source">
<property name="text">
<string>Metadata Source</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QComboBox" name="cbx_sources"/>
</item>
</layout>
</item>
<item>

View File

@ -2,12 +2,12 @@ from __future__ import annotations
import logging
from functools import partial
from typing import Any, NamedTuple
from typing import Any, NamedTuple, cast
import settngs
from PyQt5 import QtCore, QtGui, QtWidgets
from comictaggerlib.ctsettings import ct_ns
from comictaggerlib.ctsettings import ct_ns, group_for_plugin
from comictaggerlib.graphics import graphics_path
from comictalker.comictalker import ComicTalker
@ -16,9 +16,15 @@ logger = logging.getLogger(__name__)
class TalkerTab(NamedTuple):
tab: QtWidgets.QWidget
# dict[option.dest] = QWidget
widgets: dict[str, QtWidgets.QWidget]
class Sources(NamedTuple):
cbx_sources: QtWidgets.QComboBox
tabs: list[tuple[ComicTalker, TalkerTab]]
class PasswordEdit(QtWidgets.QLineEdit):
"""
Password LineEdit with icons to show/hide password entries.
@ -38,11 +44,11 @@ class PasswordEdit(QtWidgets.QLineEdit):
# Add the password hide/shown toggle at the end of the edit box.
self.togglepasswordAction = self.addAction(self.visibleIcon, QtWidgets.QLineEdit.TrailingPosition)
self.togglepasswordAction.setToolTip("Show password")
self.togglepasswordAction.triggered.connect(self.on_toggle_password_Action)
self.togglepasswordAction.triggered.connect(self.on_toggle_password_action)
self.password_shown = False
def on_toggle_password_Action(self) -> None:
def on_toggle_password_action(self) -> None:
if not self.password_shown:
self.setEchoMode(QtWidgets.QLineEdit.Normal)
self.password_shown = True
@ -56,14 +62,16 @@ class PasswordEdit(QtWidgets.QLineEdit):
def generate_api_widgets(
talker_id: str,
sources: dict[str, QtWidgets.QWidget],
config: settngs.Config[ct_ns],
talker: ComicTalker,
widgets: TalkerTab,
key_option: settngs.Setting,
url_option: settngs.Setting,
layout: QtWidgets.QGridLayout,
talkers: dict[str, ComicTalker],
) -> None:
# *args enforces keyword arguments and allows position arguments to be ignored
def call_check_api(*args: Any, le_url: QtWidgets.QLineEdit, le_key: QtWidgets.QLineEdit, talker_id: str) -> None:
def call_check_api(
*args: Any, le_url: QtWidgets.QLineEdit, le_key: QtWidgets.QLineEdit, talker: ComicTalker
) -> None:
url = ""
key = ""
if le_key is not None:
@ -71,46 +79,43 @@ def generate_api_widgets(
if le_url is not None:
url = le_url.text().strip()
check_text, check_bool = talkers[talker_id].check_api_key(url, key)
check_text, check_bool = talker.check_api_key(url, key)
if check_bool:
QtWidgets.QMessageBox.information(None, "API Test Success", check_text)
else:
QtWidgets.QMessageBox.warning(None, "API Test Failed", check_text)
# get the actual config objects in case they have overwritten the default
talker_key = config[1][f"talker_{talker_id}"][1][f"{talker_id}_key"]
talker_url = config[1][f"talker_{talker_id}"][1][f"{talker_id}_url"]
btn_test_row = None
le_key = None
le_url = None
# only file settings are saved
if talker_key.file:
# record the current row so we know where to add the button
if key_option.file:
# record the current row, so we know where to add the button
btn_test_row = layout.rowCount()
le_key = generate_password_textbox(talker_key, layout)
le_key = generate_password_textbox(key_option, layout)
# To enable setting and getting
sources["tabs"][talker_id].widgets[f"talker_{talker_id}_{talker_id}_key"] = le_key
widgets.widgets[key_option.dest] = le_key
# only file settings are saved
if talker_url.file:
# record the current row so we know where to add the button
if url_option.file:
# record the current row, so we know where to add the button
# We overwrite so that the default will be next to the url text box
btn_test_row = layout.rowCount()
le_url = generate_textbox(talker_url, layout)
value, _ = settngs.get_option(config[0], talker_url)
if not value:
le_url.setText(talkers[talker_id].default_api_url)
le_url = generate_textbox(url_option, layout)
# We insert the default url here so that people don't think it's unset
le_url.setText(talker.default_api_url)
# To enable setting and getting
sources["tabs"][talker_id].widgets[f"talker_{talker_id}_{talker_id}_url"] = le_url
widgets.widgets[url_option.dest] = le_url
# The button row was recorded so we add it
if btn_test_row is not None:
btn = QtWidgets.QPushButton("Test API")
layout.addWidget(btn, btn_test_row, 2)
# partial is used as connect will pass in event information
btn.clicked.connect(partial(call_check_api, le_url=le_url, le_key=le_key, talker_id=talker_id))
btn.clicked.connect(partial(call_check_api, le_url=le_url, le_key=le_key, talker=talker))
def generate_checkbox(option: settngs.Setting, layout: QtWidgets.QGridLayout) -> QtWidgets.QCheckBox:
@ -171,31 +176,39 @@ def generate_password_textbox(option: settngs.Setting, layout: QtWidgets.QGridLa
return widget
def settings_to_talker_form(sources: dict[str, QtWidgets.QWidget], config: settngs.Config[ct_ns]) -> None:
def settings_to_talker_form(sources: Sources, config: settngs.Config[ct_ns]) -> None:
# Set the active talker via id in sources combo box
sources["cbx_select_talker"].setCurrentIndex(sources["cbx_select_talker"].findData(config[0].talker_source))
sources[0].setCurrentIndex(sources[0].findData(config[0].Sources_source))
for talker in sources["tabs"].items():
for name, widget in talker[1].widgets.items():
value = getattr(config[0], name)
value_type = type(value)
# Iterate over the tabs, the talker is included in the tab so no extra lookup is needed
for talker, tab in sources.tabs:
# dest is guaranteed to be unique within a talker
# and refer to the correct item in config.definitions.v['group name']
for dest, widget in tab.widgets.items():
value, default = settngs.get_option(config.values, config.definitions[group_for_plugin(talker)].v[dest])
try:
if value_type is str and value:
if isinstance(value, str) and value and isinstance(widget, QtWidgets.QLineEdit) and not default:
widget.setText(value)
if value_type is int or value_type is float:
if isinstance(value, (float, int)) and isinstance(
widget, (QtWidgets.QSpinBox, QtWidgets.QDoubleSpinBox)
):
widget.setValue(value)
if value_type is bool:
if isinstance(value, bool) and isinstance(widget, QtWidgets.QCheckBox):
widget.setChecked(value)
except Exception:
logger.debug("Failed to set value of %s", name)
logger.debug("Failed to set value of %s for %s(%s)", dest, talker.name, talker.id)
def form_settings_to_config(sources: dict[str, QtWidgets.QWidget], config: settngs.Config[ct_ns]) -> None:
# Source combo box value
config[0].talker_source = sources["cbx_select_talker"].currentData()
def form_settings_to_config(sources: Sources, config: settngs.Config) -> settngs.Config[ct_ns]:
# Update the currently selected talker
config.values.Sources_source = sources.cbx_sources.currentData()
cfg = settngs.normalize_config(config, True, True)
for tab in sources["tabs"].items():
for name, widget in tab[1].widgets.items():
# Iterate over the tabs, the talker is included in the tab so no extra lookup is needed
for talker, tab in sources.tabs:
talker_options = cfg.values[group_for_plugin(talker)]
# dest is guaranteed to be unique within a talker and refer to the correct item in config.values['group name']
for dest, widget in tab.widgets.items():
widget_value = None
if isinstance(widget, (QtWidgets.QSpinBox, QtWidgets.QDoubleSpinBox)):
widget_value = widget.value()
@ -204,83 +217,88 @@ def form_settings_to_config(sources: dict[str, QtWidgets.QWidget], config: settn
elif isinstance(widget, QtWidgets.QCheckBox):
widget_value = widget.isChecked()
setattr(config[0], name, widget_value)
talker_options[dest] = widget_value
return cast(settngs.Config[ct_ns], settngs.get_namespace(cfg, True, True))
def generate_source_option_tabs(
comic_talker_tab: QtWidgets.QWidget,
config: settngs.Config[ct_ns],
talkers: dict[str, ComicTalker],
) -> dict[str, QtWidgets.QWidget]:
) -> Sources:
"""
Generate GUI tabs and settings for talkers
"""
# Store all widgets as to allow easier access to their values vs. using findChildren etc. on the tab widget
sources: dict = {"tabs": {}}
# Tab comes with a QVBoxLayout
comic_talker_tab_layout = comic_talker_tab.layout()
talker_layout = QtWidgets.QGridLayout()
lbl_select_talker = QtWidgets.QLabel("Metadata Source:")
cbx_select_talker = QtWidgets.QComboBox()
line = QtWidgets.QFrame()
line.setFrameShape(QtWidgets.QFrame.HLine)
line.setFrameShadow(QtWidgets.QFrame.Sunken)
talker_tabs = QtWidgets.QTabWidget()
# Store all widgets as to allow easier access to their values vs. using findChildren etc. on the tab widget
sources: Sources = Sources(QtWidgets.QComboBox(), [])
talker_layout.addWidget(lbl_select_talker, 0, 0, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Maximum)
talker_layout.addWidget(cbx_select_talker, 0, 1, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Maximum)
talker_layout.addWidget(sources[0], 0, 1, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Maximum)
talker_layout.addWidget(line, 1, 0, 1, -1)
talker_layout.addWidget(talker_tabs, 2, 0, 1, -1)
comic_talker_tab_layout.addLayout(talker_layout)
# Add combobox to sources for getting and setting talker
sources["cbx_select_talker"] = cbx_select_talker
# Add source sub tabs to Comic Sources tab
for talker_id, talker_obj in talkers.items():
for t_id, talker in talkers.items():
# Add source to general tab dropdown list
cbx_select_talker.addItem(talker_obj.name, talker_id)
sources.cbx_sources.addItem(talker.name, t_id)
tab = TalkerTab(tab=QtWidgets.QWidget(), widgets={})
tab_name = talker_id
sources["tabs"][tab_name] = TalkerTab(tab=QtWidgets.QWidget(), widgets={})
layout_grid = QtWidgets.QGridLayout()
for option in config[1][f"talker_{talker_id}"][1].values():
url_option: settngs.Setting | None = None
key_option: settngs.Setting | None = None
for option in config.definitions[group_for_plugin(talker)].v.values():
if not option.file:
continue
if option.dest in (f"{talker_id}_url", f"{talker_id}_key"):
continue
current_widget = None
if option._guess_type() is bool:
elif option.dest == f"{t_id}_key":
key_option = option
elif option.dest == f"{t_id}_url":
url_option = option
elif option._guess_type() is bool:
current_widget = generate_checkbox(option, layout_grid)
sources["tabs"][tab_name].widgets[option.internal_name] = current_widget
tab.widgets[option.dest] = current_widget
elif option._guess_type() is int:
current_widget = generate_spinbox(option, layout_grid)
sources["tabs"][tab_name].widgets[option.internal_name] = current_widget
tab.widgets[option.dest] = current_widget
elif option._guess_type() is float:
current_widget = generate_doublespinbox(option, layout_grid)
sources["tabs"][tab_name].widgets[option.internal_name] = current_widget
tab.widgets[option.dest] = current_widget
elif option._guess_type() is str:
current_widget = generate_textbox(option, layout_grid)
sources["tabs"][tab_name].widgets[option.internal_name] = current_widget
tab.widgets[option.dest] = current_widget
else:
logger.debug(f"Unsupported talker option found. Name: {option.internal_name} Type: {option.type}")
# The key and url options are always defined.
# If they aren't something has gone wrong with the talker, remove it
if key_option is None or url_option is None:
del talkers[t_id]
continue
# Add talker URL and API key fields
generate_api_widgets(talker_id, sources, config, layout_grid, talkers)
generate_api_widgets(talker, tab, key_option, url_option, layout_grid)
# Add vertical spacer
vspacer = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding)
layout_grid.addItem(vspacer, layout_grid.rowCount() + 1, 0)
# Display the new widgets
sources["tabs"][tab_name].tab.setLayout(layout_grid)
tab.tab.setLayout(layout_grid)
# Add new sub tab to Comic Source tab
talker_tabs.addTab(sources["tabs"][tab_name].tab, talker_obj.name)
talker_tabs.addTab(tab.tab, talker.name)
sources.tabs.append((talker, tab))
return sources

View File

@ -16,19 +16,28 @@
from __future__ import annotations
import datetime
import json
import logging
import os
import pathlib
import sqlite3
from typing import Any, cast
from typing import Any
from comicapi import utils
from comicapi.genericmetadata import ComicSeries, Credit, GenericMetadata, TagOrigin
from typing_extensions import NamedTuple
logger = logging.getLogger(__name__)
class Series(NamedTuple):
id: str
data: bytes
class Issue(NamedTuple):
id: str
series_id: str
data: bytes
class ComicCacher:
def __init__(self, cache_folder: pathlib.Path, version: str) -> None:
self.cache_folder = cache_folder
@ -74,70 +83,43 @@ class ComicCacher:
# create tables
with con:
cur = con.cursor()
# source,name,id,start_year,publisher,image,description,count_of_issues
cur.execute(
"CREATE TABLE SeriesSearchCache("
+ "timestamp DATE DEFAULT (datetime('now','localtime')),"
+ "id TEXT NOT NULL,"
+ "source TEXT NOT NULL,"
+ "search_term TEXT,"
+ "PRIMARY KEY (id, source, search_term))"
"""CREATE TABLE SeriesSearchCache(
timestamp DATE DEFAULT (datetime('now','localtime')),
id TEXT NOT NULL,
source TEXT NOT NULL,
search_term TEXT,
PRIMARY KEY (id, source, search_term))"""
)
cur.execute("CREATE TABLE Source(" + "id TEXT NOT NULL," + "name TEXT NOT NULL," + "PRIMARY KEY (id))")
cur.execute("CREATE TABLE Source(id TEXT NOT NULL, name TEXT NOT NULL, PRIMARY KEY (id))")
cur.execute(
"CREATE TABLE Series("
+ "timestamp DATE DEFAULT (datetime('now','localtime')), "
+ "id TEXT NOT NULL,"
+ "source TEXT NOT NULL,"
+ "name TEXT,"
+ "publisher TEXT,"
+ "count_of_issues INT,"
+ "count_of_volumes INT,"
+ "start_year INT,"
+ "image_url TEXT,"
+ "aliases TEXT," # Newline separated
+ "description TEXT,"
+ "genres TEXT," # Newline separated. For filtering etc.
+ "format TEXT,"
+ "PRIMARY KEY (id, source))"
"""CREATE TABLE Series(
timestamp DATE DEFAULT (datetime('now','localtime')),
id TEXT NOT NULL,
source TEXT NOT NULL,
data BLOB,
complete BOOL,
PRIMARY KEY (id, source))"""
)
cur.execute(
"CREATE TABLE Issues("
+ "timestamp DATE DEFAULT (datetime('now','localtime')), "
+ "id TEXT NOT NULL,"
+ "source TEXT NOT NULL,"
+ "series_id TEXT,"
+ "name TEXT,"
+ "issue_number TEXT,"
+ "image_url TEXT,"
+ "thumb_url TEXT,"
+ "cover_date TEXT,"
+ "site_detail_url TEXT,"
+ "description TEXT,"
+ "aliases TEXT," # Newline separated
+ "alt_image_urls TEXT," # Newline separated URLs
+ "characters TEXT," # Newline separated
+ "locations TEXT," # Newline separated
+ "credits TEXT," # JSON: "{"name": "Bob Shakespeare", "role": "Writer"}"
+ "teams TEXT," # Newline separated
+ "story_arcs TEXT," # Newline separated
+ "genres TEXT," # Newline separated
+ "tags TEXT," # Newline separated
+ "critical_rating FLOAT,"
+ "manga TEXT," # Yes/YesAndRightToLeft/No
+ "maturity_rating TEXT,"
+ "language TEXT,"
+ "country TEXT,"
+ "volume TEXT,"
+ "complete BOOL," # Is the data complete? Includes characters, locations, credits.
+ "PRIMARY KEY (id, source))"
"""CREATE TABLE Issues(
timestamp DATE DEFAULT (datetime('now','localtime')),
id TEXT NOT NULL,
source TEXT NOT NULL,
series_id TEXT,
data BLOB,
complete BOOL,
PRIMARY KEY (id, source))"""
)
def add_search_results(self, source: TagOrigin, search_term: str, series_list: list[ComicSeries]) -> None:
self.add_source(source)
def expire_stale_records(self, cur: sqlite3.Cursor, table: str) -> None:
# purge stale series info
a_week_ago = datetime.datetime.today() - datetime.timedelta(days=7)
cur.execute("DELETE FROM Series WHERE timestamp < ?", [str(a_week_ago)])
def add_search_results(self, source: str, search_term: str, series_list: list[Series], complete: bool) -> None:
with sqlite3.connect(self.db_file) as con:
con.row_factory = sqlite3.Row
con.text_factory = str
@ -146,153 +128,80 @@ class ComicCacher:
# remove all previous entries with this search term
cur.execute(
"DELETE FROM SeriesSearchCache WHERE search_term = ? AND source = ?",
[search_term.casefold(), source.id],
[search_term.casefold(), source],
)
# now add in new results
for record in series_list:
for series in series_list:
cur.execute(
"INSERT INTO SeriesSearchCache (source, search_term, id) VALUES(?, ?, ?)",
(source.id, search_term.casefold(), record.id),
(source, search_term.casefold(), series.id),
)
data = {
"id": record.id,
"source": source.id,
"name": record.name,
"publisher": record.publisher,
"count_of_issues": record.count_of_issues,
"count_of_volumes": record.count_of_volumes,
"start_year": record.start_year,
"image_url": record.image_url,
"description": record.description,
"genres": "\n".join(record.genres),
"format": record.format,
"timestamp": datetime.datetime.now(),
"aliases": "\n".join(record.aliases),
"id": series.id,
"source": source,
"data": series.data,
"complete": complete,
}
self.upsert(cur, "series", data)
def add_series_info(self, source: TagOrigin, series: ComicSeries) -> None:
self.add_source(source)
def add_series_info(self, source: str, series: Series, complete: bool) -> None:
with sqlite3.connect(self.db_file) as con:
con.row_factory = sqlite3.Row
cur = con.cursor()
timestamp = datetime.datetime.now()
data = {
"id": series.id,
"source": source.id,
"name": series.name,
"publisher": series.publisher,
"count_of_issues": series.count_of_issues,
"count_of_volumes": series.count_of_volumes,
"start_year": series.start_year,
"image_url": series.image_url,
"description": series.description,
"genres": "\n".join(series.genres),
"format": series.format,
"timestamp": timestamp,
"aliases": "\n".join(series.aliases),
"source": source,
"data": series.data,
"complete": complete,
}
self.upsert(cur, "series", data)
def add_series_issues_info(self, source: TagOrigin, issues: list[GenericMetadata], complete: bool) -> None:
self.add_source(source)
def add_issues_info(self, source: str, issues: list[Issue], complete: bool) -> None:
with sqlite3.connect(self.db_file) as con:
con.row_factory = sqlite3.Row
cur = con.cursor()
timestamp = datetime.datetime.now()
# add in issues
for issue in issues:
data = {
"id": issue.issue_id,
"id": issue.id,
"series_id": issue.series_id,
"source": source.id,
"name": issue.title,
"issue_number": issue.issue,
"volume": issue.volume,
"site_detail_url": issue.web_link,
"cover_date": f"{issue.year}-{issue.month}-{issue.day}",
"image_url": issue.cover_image,
"description": issue.description,
"timestamp": timestamp,
"aliases": "\n".join(issue.title_aliases),
"alt_image_urls": "\n".join(issue.alternate_images),
"characters": "\n".join(issue.characters),
"locations": "\n".join(issue.locations),
"teams": "\n".join(issue.teams),
"story_arcs": "\n".join(issue.story_arcs),
"genres": "\n".join(issue.genres),
"tags": "\n".join(issue.tags),
"critical_rating": issue.critical_rating,
"manga": issue.manga,
"maturity_rating": issue.maturity_rating,
"language": issue.language,
"country": issue.country,
"credits": json.dumps(issue.credits),
"data": issue.data,
"source": source,
"complete": complete,
}
self.upsert(cur, "issues", data)
def add_source(self, source: TagOrigin) -> None:
with sqlite3.connect(self.db_file) as con:
con.row_factory = sqlite3.Row
cur = con.cursor()
con.text_factory = str
self.upsert(
cur,
"source",
{
"id": source.id,
"name": source.name,
},
)
def get_search_results(self, source: TagOrigin, search_term: str) -> list[ComicSeries]:
def get_search_results(self, source: str, search_term: str, expire_stale: bool = True) -> list[tuple[Series, bool]]:
results = []
with sqlite3.connect(self.db_file) as con:
con.row_factory = sqlite3.Row
con.text_factory = str
cur = con.cursor()
if expire_stale:
self.expire_stale_records(cur, "SeriesSearchCache")
self.expire_stale_records(cur, "Series")
cur.execute(
"SELECT * FROM SeriesSearchCache INNER JOIN Series on"
+ " SeriesSearchCache.id=Series.id AND SeriesSearchCache.source=Series.source"
+ " WHERE search_term=? AND SeriesSearchCache.source=?",
[search_term.casefold(), source.id],
"""SELECT * FROM SeriesSearchCache INNER JOIN Series on
SeriesSearchCache.id=Series.id AND SeriesSearchCache.source=Series.source
WHERE search_term=? AND SeriesSearchCache.source=?""",
[search_term.casefold(), source],
)
rows = cur.fetchall()
# now process the results
for record in rows:
result = ComicSeries(
id=record["id"],
name=record["name"],
publisher=record["publisher"],
count_of_issues=record["count_of_issues"],
count_of_volumes=record["count_of_volumes"],
start_year=record["start_year"],
image_url=record["image_url"],
aliases=utils.split(record["aliases"], "\n"),
description=record["description"],
genres=utils.split(record["genres"], "\n"),
format=record["format"],
)
results.append(result)
for record in rows:
result = Series(id=record["id"], data=record["data"])
results.append((result, record["complete"]))
return results
def get_series_info(self, series_id: str, source: TagOrigin, expire_stale: bool = True) -> ComicSeries | None:
result: ComicSeries | None = None
def get_series_info(self, series_id: str, source: str, expire_stale: bool = True) -> tuple[Series, bool] | None:
result: Series | None = None
with sqlite3.connect(self.db_file) as con:
con.row_factory = sqlite3.Row
@ -300,170 +209,64 @@ class ComicCacher:
con.text_factory = str
if expire_stale:
# purge stale series info
a_week_ago = datetime.datetime.today() - datetime.timedelta(days=7)
cur.execute("DELETE FROM Series WHERE timestamp < ?", [str(a_week_ago)])
self.expire_stale_records(cur, "Series")
# fetch
cur.execute("SELECT * FROM Series WHERE id=? AND source=?", [series_id, source.id])
cur.execute("SELECT * FROM Series WHERE id=? AND source=?", [series_id, source])
row = cur.fetchone()
if row is None:
return result
return None
# since ID is primary key, there is only one row
result = ComicSeries(
id=row["id"],
name=row["name"],
publisher=row["publisher"],
count_of_issues=row["count_of_issues"],
count_of_volumes=row["count_of_volumes"],
start_year=row["start_year"],
image_url=row["image_url"],
aliases=utils.split(row["aliases"], "\n"),
description=row["description"],
genres=utils.split(row["genres"], "\n"),
format=row["format"],
)
result = Series(id=row["id"], data=row["data"])
return result
def get_series_issues_info(self, series_id: str, source: TagOrigin) -> list[tuple[GenericMetadata, bool]]:
# get_series_info should only fail if someone is doing something weird
series = self.get_series_info(series_id, source, False) or ComicSeries(
id=series_id,
name="",
description="",
genres=[],
image_url="",
publisher="",
start_year=None,
aliases=[],
count_of_issues=None,
count_of_volumes=None,
format=None,
)
return (result, row["complete"])
def get_series_issues_info(
self, series_id: str, source: str, expire_stale: bool = True
) -> list[tuple[Issue, bool]]:
with sqlite3.connect(self.db_file) as con:
con.row_factory = sqlite3.Row
cur = con.cursor()
con.text_factory = str
# purge stale issue info - probably issue data won't change
# much....
a_week_ago = datetime.datetime.today() - datetime.timedelta(days=7)
cur.execute("DELETE FROM Issues WHERE timestamp < ?", [str(a_week_ago)])
if expire_stale:
self.expire_stale_records(cur, "Issues")
# fetch
results: list[tuple[GenericMetadata, bool]] = []
results: list[tuple[Issue, bool]] = []
cur.execute("SELECT * FROM Issues WHERE series_id=? AND source=?", [series_id, source.id])
cur.execute("SELECT * FROM Issues WHERE series_id=? AND source=?", [series_id, source])
rows = cur.fetchall()
# now process the results
for row in rows:
record = self.map_row_metadata(row, series, source)
record = (Issue(id=row["id"], series_id=row["series_id"], data=row["data"]), row["complete"])
results.append(record)
return results
def get_issue_info(self, issue_id: int, source: TagOrigin) -> tuple[GenericMetadata, bool] | None:
def get_issue_info(self, issue_id: int, source: str, expire_stale: bool = True) -> tuple[Issue, bool] | None:
with sqlite3.connect(self.db_file) as con:
con.row_factory = sqlite3.Row
cur = con.cursor()
con.text_factory = str
# purge stale issue info - probably issue data won't change
# much....
a_week_ago = datetime.datetime.today() - datetime.timedelta(days=7)
cur.execute("DELETE FROM Issues WHERE timestamp < ?", [str(a_week_ago)])
if expire_stale:
self.expire_stale_records(cur, "Issues")
cur.execute("SELECT * FROM Issues WHERE id=? AND source=?", [issue_id, source.id])
cur.execute("SELECT * FROM Issues WHERE id=? AND source=?", [issue_id, source])
row = cur.fetchone()
record = None
if row:
# get_series_info should only fail if someone is doing something weird
series = self.get_series_info(row["id"], source, False) or ComicSeries(
id=row["id"],
name="",
description="",
genres=[],
image_url="",
publisher="",
start_year=None,
aliases=[],
count_of_issues=None,
count_of_volumes=None,
format=None,
)
record = self.map_row_metadata(row, series, source)
record = (Issue(id=row["id"], series_id=row["series_id"], data=row["data"]), row["complete"])
return record
def get_source(self, source_id: str) -> TagOrigin:
con = sqlite3.connect(self.db_file)
with sqlite3.connect(self.db_file) as con:
con.row_factory = sqlite3.Row
cur = con.cursor()
con.text_factory = str
cur.execute("SELECT * FROM Source WHERE id=?", [source_id])
row = cur.fetchone()
return TagOrigin(row["id"], row["name"])
def map_row_metadata(
self, row: sqlite3.Row, series: ComicSeries, source: TagOrigin
) -> tuple[GenericMetadata, bool]:
day, month, year = utils.parse_date_str(row["cover_date"])
credits = []
try:
for credit in json.loads(row["credits"]):
credits.append(cast(Credit, credit))
except Exception:
logger.exception("credits failed")
return (
GenericMetadata(
tag_origin=source,
alternate_images=utils.split(row["alt_image_urls"], "\n"),
characters=utils.split(row["characters"], "\n"),
country=row["country"],
cover_image=row["image_url"],
credits=credits,
critical_rating=row["critical_rating"],
day=day,
description=row["description"],
genres=utils.split(row["genres"], "\n"),
issue=row["issue_number"],
issue_count=series.count_of_issues,
issue_id=row["id"],
language=row["language"],
locations=utils.split(row["locations"], "\n"),
manga=row["manga"],
maturity_rating=row["maturity_rating"],
month=month,
publisher=series.publisher,
series=series.name,
series_aliases=series.aliases,
series_id=series.id,
story_arcs=utils.split(row["story_arcs"], "\n"),
tags=set(utils.split(row["tags"], "\n")),
teams=utils.split(row["teams"], "\n"),
title=row["name"],
title_aliases=utils.split(row["aliases"], "\n"),
volume=row["volume"],
volume_count=series.count_of_volumes,
web_link=row["site_detail_url"],
year=year,
),
row["complete"],
)
def upsert(self, cur: sqlite3.Cursor, tablename: str, data: dict[str, Any]) -> None:
"""This does an insert if the given PK doesn't exist, and an
update it if does

View File

@ -19,7 +19,7 @@ from typing import Any, Callable
import settngs
from comicapi.genericmetadata import ComicSeries, GenericMetadata, TagOrigin
from comicapi.genericmetadata import ComicSeries, GenericMetadata
from comictalker.talker_utils import fix_url
logger = logging.getLogger(__name__)
@ -107,7 +107,6 @@ class ComicTalker:
name: str = "Example"
id: str = "example"
origin: TagOrigin = TagOrigin(id, name)
website: str = "https://example.com"
logo_url: str = f"{website}/logo.png"
attribution: str = f"Metadata provided by <a href='{website}'>{name}</a>"

View File

@ -33,7 +33,7 @@ from comicapi import utils
from comicapi.genericmetadata import ComicSeries, GenericMetadata, TagOrigin
from comicapi.issuestring import IssueString
from comictalker import talker_utils
from comictalker.comiccacher import ComicCacher
from comictalker.comiccacher import ComicCacher, Issue, Series
from comictalker.comictalker import ComicTalker, TalkerDataError, TalkerNetworkError
logger = logging.getLogger(__name__)
@ -159,7 +159,6 @@ default_limiter = Limiter(RequestRate(1, 5))
class ComicVineTalker(ComicTalker):
name: str = "Comic Vine"
id: str = "comicvine"
origin: TagOrigin = TagOrigin(id, name)
website: str = "https://comicvine.gamespot.com"
logo_url: str = f"{website}/a/bundles/comicvinesite/images/logo.png"
attribution: str = f"Metadata provided by <a href='{website}'>{name}</a>"
@ -244,10 +243,10 @@ class ComicVineTalker(ComicTalker):
# For literal searches always retrieve from online
cvc = ComicCacher(self.cache_folder, self.version)
if not refresh_cache and not literal:
cached_search_results = cvc.get_search_results(self.origin, series_name)
cached_search_results = cvc.get_search_results(self.id, series_name)
if len(cached_search_results) > 0:
return cached_search_results
return self._format_search_results([json.loads(x[0].data) for x in cached_search_results])
params = { # CV uses volume to mean series
"api_key": self.api_key,
@ -317,7 +316,12 @@ class ComicVineTalker(ComicTalker):
# Cache these search results, even if it's literal we cache the results
# The most it will cause is extra processing time
cvc.add_search_results(self.origin, series_name, formatted_search_results)
cvc.add_search_results(
self.id,
series_name,
[Series(id=str(x["id"]), data=json.dumps(x).encode("utf-8")) for x in search_results],
False,
)
return formatted_search_results
@ -333,7 +337,7 @@ class ComicVineTalker(ComicTalker):
return comic_data
def fetch_series(self, series_id: str) -> ComicSeries:
return self._fetch_series_data(int(series_id))
return self._fetch_series_data(int(series_id))[0]
def fetch_issues_in_series(self, series_id: str) -> list[GenericMetadata]:
return [x[0] for x in self._fetch_issues_in_series(series_id)]
@ -378,7 +382,7 @@ class ComicVineTalker(ComicTalker):
current_result_count += cv_response["number_of_page_results"]
formatted_filtered_issues_result = [
self.map_comic_issue_to_metadata(x, self._fetch_series_data(x["volume"]["id"]))
self._map_comic_issue_to_metadata(x, self._fetch_series_data(x["volume"]["id"])[0])
for x in filtered_issues_result
]
@ -443,49 +447,52 @@ class ComicVineTalker(ComicTalker):
def _format_search_results(self, search_results: list[CVSeries]) -> list[ComicSeries]:
formatted_results = []
for record in search_results:
# Flatten publisher to name only
if record.get("publisher") is None:
pub_name = ""
else:
pub_name = record["publisher"].get("name", "")
if record.get("image") is None:
image_url = ""
else:
image_url = record["image"].get("super_url", "")
start_year = utils.xlate_int(record.get("start_year", ""))
aliases = record.get("aliases") or ""
formatted_results.append(
ComicSeries(
aliases=utils.split(aliases, "\n"),
count_of_issues=record.get("count_of_issues", 0),
count_of_volumes=None,
description=record.get("description", ""),
id=str(record["id"]),
image_url=image_url,
name=record["name"],
publisher=pub_name,
start_year=start_year,
genres=[],
format=None,
)
)
formatted_results.append(self._format_series(record))
return formatted_results
def _format_series(self, record) -> ComicSeries:
# Flatten publisher to name only
if record.get("publisher") is None:
pub_name = ""
else:
pub_name = record["publisher"].get("name", "")
if record.get("image") is None:
image_url = ""
else:
image_url = record["image"].get("super_url", "")
start_year = utils.xlate_int(record.get("start_year", ""))
aliases = record.get("aliases") or ""
return ComicSeries(
aliases=utils.split(aliases, "\n"),
count_of_issues=record.get("count_of_issues", 0),
count_of_volumes=None,
description=record.get("description", ""),
id=str(record["id"]),
image_url=image_url,
name=record["name"],
publisher=pub_name,
start_year=start_year,
genres=[],
format=None,
)
def _fetch_issues_in_series(self, series_id: str) -> list[tuple[GenericMetadata, bool]]:
# before we search online, look in our cache, since we might already have this info
cvc = ComicCacher(self.cache_folder, self.version)
cached_series_issues_result = cvc.get_series_issues_info(series_id, self.origin)
cached_series_issues_result = cvc.get_series_issues_info(series_id, self.id)
series = self._fetch_series_data(int(series_id))
series = self._fetch_series_data(int(series_id))[0]
if len(cached_series_issues_result) == series.count_of_issues:
# Remove internal "complete" bool
return cached_series_issues_result
return [
(self._map_comic_issue_to_metadata(json.loads(x[0].data), series), x[1])
for x in cached_series_issues_result
]
params = { # CV uses volume to mean series
"api_key": self.api_key,
@ -514,20 +521,27 @@ class ComicVineTalker(ComicTalker):
current_result_count += cv_response["number_of_page_results"]
# Format to expected output
formatted_series_issues_result = [
self.map_comic_issue_to_metadata(x, self._fetch_series_data(x["volume"]["id"]))
self._map_comic_issue_to_metadata(x, self._fetch_series_data(x["volume"]["id"])[0])
for x in series_issues_result
]
cvc.add_series_issues_info(self.origin, formatted_series_issues_result, False)
cvc.add_issues_info(
self.id,
[
Issue(id=str(x["id"]), series_id=series_id, data=json.dumps(x).encode("utf-8"))
for x in series_issues_result
],
False,
)
return [(x, False) for x in formatted_series_issues_result]
def _fetch_series_data(self, series_id: int) -> ComicSeries:
def _fetch_series_data(self, series_id: int) -> tuple[ComicSeries, bool]:
# before we search online, look in our cache, since we might already have this info
cvc = ComicCacher(self.cache_folder, self.version)
cached_series_result = cvc.get_series_info(str(series_id), self.origin)
cached_series = cvc.get_series_info(str(series_id), self.id)
if cached_series_result is not None:
return cached_series_result
if cached_series is not None:
return (self._format_series(json.loads(cached_series[0].data)), cached_series[1])
series_url = urljoin(self.api_url, f"volume/{CVTypeID.Volume}-{series_id}") # CV uses volume to mean series
@ -538,12 +552,13 @@ class ComicVineTalker(ComicTalker):
cv_response: CVResult[CVSeries] = self._get_cv_content(series_url, params)
series_results = cv_response["results"]
formatted_series_results = self._format_search_results([series_results])
if series_results:
cvc.add_series_info(self.origin, formatted_series_results[0])
cvc.add_series_info(
self.id, Series(id=str(series_results["id"]), data=json.dumps(series_results).encode("utf-8")), True
)
return formatted_series_results[0]
return self._format_series(series_results), True
def _fetch_issue_data(self, series_id: int, issue_number: str) -> GenericMetadata:
issues_list_results = self._fetch_issues_in_series(str(series_id))
@ -568,10 +583,12 @@ class ComicVineTalker(ComicTalker):
def _fetch_issue_data_by_issue_id(self, issue_id: str) -> GenericMetadata:
# before we search online, look in our cache, since we might already have this info
cvc = ComicCacher(self.cache_folder, self.version)
cached_issues_result = cvc.get_issue_info(int(issue_id), self.origin)
cached_issue = cvc.get_issue_info(int(issue_id), self.id)
if cached_issues_result and cached_issues_result[1]:
return cached_issues_result[0]
if cached_issue and cached_issue[1]:
return self._map_comic_issue_to_metadata(
json.loads(cached_issue[0].data), self._fetch_series_data(int(cached_issue[0].series_id))[0]
)
issue_url = urljoin(self.api_url, f"issue/{CVTypeID.Issue}-{issue_id}")
params = {"api_key": self.api_key, "format": "json"}
@ -579,19 +596,26 @@ class ComicVineTalker(ComicTalker):
issue_results = cv_response["results"]
# Format to expected output
cv_issues = self.map_comic_issue_to_metadata(
issue_results, self._fetch_series_data(int(issue_results["volume"]["id"]))
cvc.add_issues_info(
self.id,
[
Issue(
id=str(issue_results["id"]),
series_id=str(issue_results["volume"]["id"]),
data=json.dumps(issue_results).encode("utf-8"),
)
],
True,
)
cvc.add_series_issues_info(self.origin, [cv_issues], True)
# Now, map the GenericMetadata data to generic metadata
return cv_issues
return self._map_comic_issue_to_metadata(
issue_results, self._fetch_series_data(int(issue_results["volume"]["id"]))[0]
)
def map_comic_issue_to_metadata(self, issue: CVIssue, series: ComicSeries) -> GenericMetadata:
def _map_comic_issue_to_metadata(self, issue: CVIssue, series: ComicSeries) -> GenericMetadata:
md = GenericMetadata(
tag_origin=self.origin,
tag_origin=TagOrigin(self.id, self.name),
issue_id=utils.xlate(issue.get("id")),
series_id=series.id,
title_aliases=utils.split(issue.get("aliases"), "\n"),
@ -638,7 +662,6 @@ class ComicVineTalker(ComicTalker):
if self.use_series_start_as_volume:
md.volume = series.start_year
series = self._fetch_series_data(issue["volume"]["id"])
if issue.get("cover_date"):
md.day, md.month, md.year = utils.parse_date_str(issue.get("cover_date"))
elif series.start_year:

View File

@ -41,10 +41,10 @@ install_requires =
pathvalidate
pillow>=9.1.0,<10
pycountry
pyrate-limiter
pyrate-limiter>=2.6,<3
rapidfuzz>=2.12.0
requests==2.*
settngs==0.7.1
settngs==0.7.2
text2digits
typing-extensions>=4.3.0
wordninja

View File

@ -4,7 +4,7 @@ import comicapi.genericmetadata
from comicapi import utils
search_results = [
comicapi.genericmetadata.ComicSeries(
dict(
count_of_issues=1,
count_of_volumes=1,
description="this is a description",
@ -17,7 +17,7 @@ search_results = [
genres=[],
format=None,
),
comicapi.genericmetadata.ComicSeries(
dict(
count_of_issues=1,
count_of_volumes=1,
description="this is a description",

View File

@ -87,6 +87,38 @@ names = [
},
(False, False),
),
(
"action comics 1024.cbz",
"issue number is current year (digits == 4)",
{
"issue": "1024",
"series": "action comics",
"title": "",
"publisher": "",
"volume": "",
"year": "",
"remainder": "",
"issue_count": "",
"alternate": "",
},
(False, False),
),
(
"Action Comics 1001 (2018).cbz",
"issue number is current year (digits == 4)",
{
"issue": "1001",
"series": "Action Comics",
"title": "",
"publisher": "",
"volume": "",
"year": "2018",
"remainder": "",
"issue_count": "",
"alternate": "",
},
(False, False),
),
(
"january jones #2.cbz",
"month in series",

View File

@ -60,7 +60,7 @@ def test_save_cbi(tmp_comic):
@pytest.mark.xfail(not (comicapi.archivers.rar.rar_support and shutil.which("rar")), reason="rar support")
def test_save_cix_rar(tmp_path):
def test_save_cix_rar(tmp_path, md_saved):
cbr_path = datadir / "fake_cbr.cbr"
shutil.copy(cbr_path, tmp_path)
@ -69,7 +69,7 @@ def test_save_cix_rar(tmp_path):
assert tmp_comic.write_cix(comicapi.genericmetadata.md_test)
md = tmp_comic.read_cix()
assert md.replace(pages=[]) == comicapi.genericmetadata.md_test.replace(pages=[])
assert md.replace(pages=[], page_count=0) == md_saved.replace(pages=[], page_count=0)
@pytest.mark.xfail(not (comicapi.archivers.rar.rar_support and shutil.which("rar")), reason="rar support")
@ -95,12 +95,12 @@ def test_save_cbi_rar(tmp_path, md_saved):
manga=None,
page_count=None,
maturity_rating=None,
story_arc=None,
series_group=None,
story_arcs=[],
series_groups=[],
scan_info=None,
characters=None,
teams=None,
locations=None,
characters=[],
teams=[],
locations=[],
)

View File

@ -1,26 +1,37 @@
from __future__ import annotations
import json
import pytest
import comictalker.comiccacher
from comicapi.genericmetadata import TagOrigin
from testing.comicdata import search_results
def test_create_cache(config, mock_version):
config, definitions = config
comictalker.comiccacher.ComicCacher(config.runtime_config.user_cache_dir, mock_version[0])
assert config.runtime_config.user_cache_dir.exists()
comictalker.comiccacher.ComicCacher(config.Runtime_Options_config.user_cache_dir, mock_version[0])
assert config.Runtime_Options_config.user_cache_dir.exists()
def test_search_results(comic_cache):
comic_cache.add_search_results(TagOrigin("test", "test"), "test search", search_results)
assert search_results == comic_cache.get_search_results(TagOrigin("test", "test"), "test search")
comic_cache.add_search_results(
"test",
"test search",
[comictalker.comiccacher.Series(id=x["id"], data=json.dumps(x)) for x in search_results],
True,
)
cached_results = [json.loads(x[0].data) for x in comic_cache.get_search_results("test", "test search")]
assert search_results == cached_results
@pytest.mark.parametrize("series_info", search_results)
def test_series_info(comic_cache, series_info):
comic_cache.add_series_info(series=series_info, source=TagOrigin("test", "test"))
comic_cache.add_series_info(
series=comictalker.comiccacher.Series(id=series_info["id"], data=json.dumps(series_info)),
source="test",
complete=True,
)
vi = series_info.copy()
cache_result = comic_cache.get_series_info(series_id=series_info.id, source=TagOrigin("test", "test"))
cache_result = json.loads(comic_cache.get_series_info(series_id=series_info["id"], source="test")[0].data)
assert vi == cache_result

View File

@ -1,5 +1,7 @@
from __future__ import annotations
import json
import pytest
import comicapi.genericmetadata
@ -7,27 +9,34 @@ import testing.comicvine
def test_search_for_series(comicvine_api, comic_cache):
results = comicvine_api.search_for_series("cory doctorows futuristic tales of the here and now")
cache_issues = comic_cache.get_search_results(
comicvine_api.origin, "cory doctorows futuristic tales of the here and now"
)
assert results == cache_issues
results = comicvine_api.search_for_series("cory doctorows futuristic tales of the here and now")[0]
cache_series = comic_cache.get_search_results(
comicvine_api.id, "cory doctorows futuristic tales of the here and now"
)[0][0]
series_results = comicvine_api._format_series(json.loads(cache_series.data))
assert results == series_results
def test_fetch_series_data(comicvine_api, comic_cache):
result = comicvine_api._fetch_series_data(23437)
# del result["description"]
# del result["image_url"]
cache_result = comic_cache.get_series_info(23437, comicvine_api.origin)
# del cache_result["description"]
# del cache_result["image_url"]
assert result == cache_result
def test_fetch_series(comicvine_api, comic_cache):
result = comicvine_api.fetch_series(23437)
cache_series = comic_cache.get_series_info(23437, comicvine_api.id)[0]
series_result = comicvine_api._format_series(json.loads(cache_series.data))
assert result == series_result
def test_fetch_issues_in_series(comicvine_api, comic_cache):
results = comicvine_api.fetch_issues_in_series(23437)
cache_issues = comic_cache.get_series_issues_info(23437, comicvine_api.origin)
assert results[0] == cache_issues[0][0]
cache_issues = comic_cache.get_series_issues_info(23437, comicvine_api.id)
issues_results = [
comicvine_api._map_comic_issue_to_metadata(
json.loads(x[0].data),
comicvine_api._format_series(
json.loads(comic_cache.get_series_info(x[0].series_id, comicvine_api.id)[0].data)
),
)
for x in cache_issues
]
assert results == issues_results
def test_fetch_issue_data_by_issue_id(comicvine_api):

View File

@ -117,7 +117,7 @@ def comicvine_api(monkeypatch, cbz, comic_cache, mock_version, config) -> comict
cv = comictalker.talkers.comicvine.ComicVineTalker(
version=mock_version[0],
cache_folder=config[0].runtime_config.user_cache_dir,
cache_folder=config[0].Runtime_Options_config.user_cache_dir,
)
manager = settngs.Manager()
manager.add_persistent_group("comicvine", cv.register_settings)
@ -174,14 +174,14 @@ def config(tmp_path):
app.register_settings()
defaults = app.parse_settings(comictaggerlib.ctsettings.ComicTaggerPaths(tmp_path / "config"), "")
defaults[0].runtime_config.user_data_dir.mkdir(parents=True, exist_ok=True)
defaults[0].runtime_config.user_config_dir.mkdir(parents=True, exist_ok=True)
defaults[0].runtime_config.user_cache_dir.mkdir(parents=True, exist_ok=True)
defaults[0].runtime_config.user_state_dir.mkdir(parents=True, exist_ok=True)
defaults[0].runtime_config.user_log_dir.mkdir(parents=True, exist_ok=True)
defaults[0].Runtime_Options_config.user_data_dir.mkdir(parents=True, exist_ok=True)
defaults[0].Runtime_Options_config.user_config_dir.mkdir(parents=True, exist_ok=True)
defaults[0].Runtime_Options_config.user_cache_dir.mkdir(parents=True, exist_ok=True)
defaults[0].Runtime_Options_config.user_state_dir.mkdir(parents=True, exist_ok=True)
defaults[0].Runtime_Options_config.user_log_dir.mkdir(parents=True, exist_ok=True)
yield defaults
@pytest.fixture
def comic_cache(config, mock_version) -> Generator[comictalker.comiccacher.ComicCacher, Any, None]:
yield comictalker.comiccacher.ComicCacher(config[0].runtime_config.user_cache_dir, mock_version[0])
yield comictalker.comiccacher.ComicCacher(config[0].Runtime_Options_config.user_cache_dir, mock_version[0])

View File

@ -218,3 +218,18 @@ urls = [
@pytest.mark.parametrize("value, result", urls)
def test_fix_url(value, result):
assert comictalker.talker_utils.fix_url(value) == result
split = [
(("1,2,,3", ","), ["1", "2", "3"]),
(("1 ,2,,3", ","), ["1", "2", "3"]),
(("1 ,2,,3 ", ","), ["1", "2", "3"]),
(("\n1 \n2\n\n3 ", ","), ["1 \n2\n\n3"]),
(("\n1 \n2\n\n3 ", "\n"), ["1", "2", "3"]),
((None, ","), []),
]
@pytest.mark.parametrize("value, result", split)
def test_split(value, result):
assert comicapi.utils.split(*value) == result