Compare commits

..

59 Commits

Author SHA1 Message Date
00200334fb Add filter to SeriesSelectionWindow and IssueSelectionWindow fixes #476 2023-07-01 18:57:33 -07:00
cde980b470 Add LICENSE file 2023-07-01 18:13:38 -07:00
f90f373d20 Merge branch 'mizaki-rate_limit_cv' into develop 2023-07-01 18:04:24 -07:00
c246b96845 Merge branch 'mizaki-vol_to_issue' into develop 2023-07-01 18:02:57 -07:00
053afaa75e Merge branch 'mizaki-phash' into develop 2023-07-01 18:01:26 -07:00
3848aaeda3 Merge branch 'mizaki-issue_count_sort' into develop 2023-07-01 17:56:55 -07:00
16b13a6fe0 Format year and count of issues to 4 digits and do a None check 2023-06-28 01:08:04 +01:00
3f180612d3 Return int instead of hex and revert hamming_distance etc. 2023-06-27 22:44:08 +01:00
37cc66cbae Use requests.status_codes.codes.TOO_MANY_REQUESTS 2023-06-27 17:48:38 +01:00
81b15a5877 Fixes sorting by year and issue count. Removed superfluous if for publisher. Fixes #475 2023-06-27 00:21:28 +01:00
14a4055040 Add Perceptual Hash computation to imagehasher mirroring https://github.com/JohannesBuchner/imagehash but in pure python 2023-06-26 01:54:26 +01:00
2e01672e68 Fix #485
As mentioned in the comment in comictaggerlib/main.py:186
The default value should be None not the empty string.
We also check if the given value is the default or the empty string and
 the setting is unset so the default value is not saved in the settings
 file.
The default_api_url is shown in the GUI Settings Window it is not
 currently show in the cli help.
2023-06-23 17:48:18 -07:00
4a7aae4045 Add tests for fix_url 2023-06-23 17:10:40 -07:00
2187ddece8 Move volume from ComicSeries to ComicIssue 2023-06-23 22:38:15 +01:00
fba5518d06 Create two module limiters and assign class limiter var depending. Add to welcome message limits of default CV API key. 2023-06-23 21:25:02 +01:00
31cf687e2f Reduce startup time 2023-06-22 20:11:40 -07:00
526069dabf Use _guess_type from settngs for more robust type checking 2023-06-22 18:28:43 -07:00
635cb037f1 Merge branch 'mizaki-fix_add_fields' into develop 2023-06-22 17:51:26 -07:00
861584df3a Move rate limit check from defunc API status code 107 to HTTP code 429. Set a limit of 10 request every 10 seconds except for the default API key which is 1,2 (to be finisalised). Remove wait on rate limit option. 2023-06-22 23:50:32 +01:00
a53fda9fec Update linux packages in GitHub Actions 2023-06-21 19:47:41 -07:00
af5a0e50e0 Remove wait on CV rate limit in autotag 2023-06-21 22:32:06 +01:00
7a91acb60c Add pyrate-limiter and apply CV suggested rate limit 2023-06-20 22:28:29 +01:00
3a287504ae Fix setting issue and alternate_number on GenericMetadata
IssueString.as_string always returns a string this is a problem for
  GenericMetadata. When the overlay function is used it checks
  specifically for the value None this allows the -m option to unset
  attributes however the issue attribute would get set to the empty
  string when loading ComicRack tags regardless of if there was a value
  stored in the file. Fixes #465 and #480
2023-06-15 20:26:38 -07:00
82a22d25ea Merge branch 'mizaki-auto_ident_message' into develop 2023-06-11 21:44:05 -07:00
783e10a9a1 Generate a namespace object for typing settngs 2023-06-09 16:20:00 -07:00
e8f13b1f9e fix quoting 2023-06-09 02:08:38 +01:00
4b415b376f Fix tests 2023-06-08 01:26:03 +01:00
122bdf7eb1 Change auto-identfy message to point users to the auto-tag assume 1 option 2023-06-08 01:18:46 +01:00
2afb604ab3 Fix issue_count and add maturity rating 2023-06-08 00:52:24 +01:00
a912c7392b Merge branch 'mizaki-additional_comic_fields' into develop 2023-06-03 10:37:44 -07:00
3b92993ef6 Remove country name code 2023-06-03 00:11:40 +01:00
c3892082f5 Change data to metadata 2023-06-02 00:37:58 +01:00
92e2cb42e8 Replace instances of Comic Vine to use the talker's name 2023-06-01 22:05:14 +01:00
b8065e0f10 Fix #470 re-add notes when using --clear-metadata 2023-05-30 21:36:33 -07:00
a395e5541f Remove invalid comments 2023-05-25 15:00:53 +01:00
d191750231 Remove attempted validation of language and country plus minor changes 2023-05-25 01:32:52 +01:00
e72347656b Add format (1-shot, limited series, etc.) 2023-05-23 00:27:58 +01:00
8e2411a086 Add country functions to utils and try to convert a country name to ISO country name 2023-05-23 00:02:56 +01:00
97e64fa918 Add maturity_rating, language and country to ComicIssue and pass to metadata. 2023-05-18 02:02:21 +01:00
661d758315 Merge branch 'mizaki-talker_parse_key' into develop 2023-05-16 17:33:24 -07:00
364d870fe0 Merge branch 'mizaki-hide_api_token' into develop 2023-05-16 17:30:46 -07:00
2da64fd52d Remove password class from function 2023-05-16 15:20:45 +01:00
057725c5da Create generate_password_textbox 2023-05-16 00:25:12 +01:00
5996bd3588 Add show/hide icon to key field 2023-05-15 23:46:16 +01:00
fdf407898e Bump MacOS version for GitHub Actions 2023-05-15 10:59:23 -06:00
70d544b7bd Add attrib at the end of the CLI file run 2023-05-15 16:46:31 +01:00
c583f63c8c Attribution for metadata provider on command line 2023-05-14 23:39:23 +01:00
d65a120eb5 Add issue_count 2023-05-14 00:50:37 +01:00
60f47546c2 Hide the API key field as a password and add a show/show button 2023-05-13 23:12:29 +01:00
0b77078a93 Retrieve all fields instead of by (many) names 2023-05-12 23:46:34 +01:00
2598fc546a Use new xlate_int and xlate_float 2023-05-12 22:47:36 +01:00
ddf4407b77 Merge branch 'develop' into additional_comic_fields 2023-05-12 22:41:38 +01:00
6cf259191e Add volume and count_of_volumes to ComicSeries 2023-05-12 21:48:45 +01:00
30f1db1c73 Update requirements and Linux build dependencies 2023-04-26 14:46:18 -07:00
4218e3558b Add url 2023-03-05 18:58:06 +00:00
271bfac834 Do not fail when talker key is missing 2023-03-03 00:07:49 +00:00
9e86b5e331 Fix tests 2023-03-02 00:23:56 +00:00
c9638ba0d9 Format manga and rating 2023-03-02 00:10:52 +00:00
73738010b8 Add additional fields to ComicIssue and add a genre field to ComicSeries to allow for filtering of search results from the cache. 2023-02-15 16:48:07 +00:00
53 changed files with 1298 additions and 571 deletions

View File

@ -48,7 +48,7 @@ jobs:
strategy:
matrix:
python-version: [3.9]
os: [ubuntu-latest, macos-10.15, windows-latest]
os: [ubuntu-latest, macos-11, windows-latest]
steps:
- uses: actions/checkout@v3
@ -73,7 +73,7 @@ jobs:
- name: Install linux dependencies
run: |
sudo apt-get install pkg-config libicu-dev libqt5gui5 libfuse2
sudo apt-get update && sudo apt-get upgrade && sudo apt-get install pkg-config libicu-dev libqt5gui5 libfuse2
# export PKG_CONFIG_PATH="/usr/local/opt/icu4c/lib/pkgconfig";
# export PATH="/usr/local/opt/icu4c/bin:/usr/local/opt/icu4c/sbin:$PATH"
if: runner.os == 'Linux'

View File

@ -15,7 +15,7 @@ jobs:
strategy:
matrix:
python-version: [3.9]
os: [ubuntu-latest, macos-10.15, windows-latest]
os: [ubuntu-latest, macos-11, windows-latest]
steps:
- uses: actions/checkout@v3
@ -40,7 +40,7 @@ jobs:
- name: Install linux dependencies
run: |
sudo apt-get install pkg-config libicu-dev libqt5gui5
sudo apt-get update && sudo apt-get upgrade && sudo apt-get install pkg-config libicu-dev libqt5gui5 libfuse2
# export PKG_CONFIG_PATH="/usr/local/opt/icu4c/lib/pkgconfig";
# export PATH="/usr/local/opt/icu4c/bin:/usr/local/opt/icu4c/sbin:$PATH"
if: runner.os == 'Linux'

View File

@ -41,6 +41,6 @@ repos:
rev: v1.2.0
hooks:
- id: mypy
additional_dependencies: [types-setuptools, types-requests]
additional_dependencies: [types-setuptools, types-requests, settngs>=0.7.1]
ci:
skip: [mypy]

202
LICENSE Normal file
View File

@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@ -0,0 +1,19 @@
from __future__ import annotations
import pathlib
import settngs
import comictaggerlib.main
def generate() -> str:
app = comictaggerlib.main.App()
app.register_settings()
return settngs.generate_ns(app.manager.definitions)
if __name__ == "__main__":
src = generate()
pathlib.Path("./comictaggerlib/ctsettings/settngs_namespace.py").write_text(src)
print(src, end="")

View File

@ -2,8 +2,6 @@ from __future__ import annotations
from comicapi.archivers.archiver import Archiver
from comicapi.archivers.folder import FolderArchiver
from comicapi.archivers.rar import RarArchiver
from comicapi.archivers.sevenzip import SevenZipArchiver
from comicapi.archivers.zip import ZipArchiver
@ -12,4 +10,4 @@ class UnknownArchiver(Archiver):
return "Unknown"
__all__ = ["Archiver", "UnknownArchiver", "FolderArchiver", "RarArchiver", "ZipArchiver", "SevenZipArchiver"]
__all__ = ["Archiver", "UnknownArchiver", "FolderArchiver", "ZipArchiver"]

View File

@ -22,8 +22,6 @@ import shutil
import sys
from typing import cast
import wordninja
from comicapi import filenamelexer, filenameparser, utils
from comicapi.archivers import Archiver, UnknownArchiver, ZipArchiver
from comicapi.comet import CoMet
@ -31,28 +29,17 @@ from comicapi.comicbookinfo import ComicBookInfo
from comicapi.comicinfoxml import ComicInfoXml
from comicapi.genericmetadata import GenericMetadata, PageType
if sys.version_info < (3, 10):
from importlib_metadata import entry_points
else:
from importlib.metadata import entry_points
try:
from PIL import Image
pil_available = True
except ImportError:
pil_available = False
logger = logging.getLogger(__name__)
if not pil_available:
logger.error("PIL unavalable")
archivers: list[type[Archiver]] = []
def load_archive_plugins() -> None:
if not archivers:
if sys.version_info < (3, 10):
from importlib_metadata import entry_points
else:
from importlib.metadata import entry_points
builtin: list[type[Archiver]] = []
for arch in entry_points(group="comicapi.archiver"):
try:
@ -77,6 +64,7 @@ class MetaDataStyle:
class ComicArchive:
logo_data = b""
pil_available = True
def __init__(self, path: pathlib.Path | str, default_image_path: pathlib.Path | str | None = None) -> None:
self.cbi_md: GenericMetadata | None = None
@ -517,7 +505,13 @@ class ComicArchive:
if calc_page_sizes:
for index, p in enumerate(md.pages):
idx = int(p["Image"])
if pil_available:
if self.pil_available:
try:
from PIL import Image
self.pil_available = True
except ImportError:
self.pil_available = False
if "ImageSize" not in p or "ImageHeight" not in p or "ImageWidth" not in p:
data = self.get_page(idx)
if data:
@ -552,6 +546,8 @@ class ComicArchive:
filename = self.path.name
if split_words:
import wordninja
filename = " ".join(wordninja.split(self.path.stem)) + self.path.suffix
if complicated_parser:

View File

@ -22,7 +22,6 @@ from xml.etree.ElementTree import ElementTree
from comicapi import utils
from comicapi.genericmetadata import GenericMetadata, ImageMetadata
from comicapi.issuestring import IssueString
logger = logging.getLogger(__name__)
@ -189,11 +188,11 @@ class ComicInfoXml:
md.series = utils.xlate(get("Series"))
md.title = utils.xlate(get("Title"))
md.issue = IssueString(utils.xlate(get("Number"))).as_string()
md.issue = utils.xlate(get("Number"))
md.issue_count = utils.xlate_int(get("Count"))
md.volume = utils.xlate_int(get("Volume"))
md.alternate_series = utils.xlate(get("AlternateSeries"))
md.alternate_number = IssueString(utils.xlate(get("AlternateNumber"))).as_string()
md.alternate_number = utils.xlate(get("AlternateNumber"))
md.alternate_count = utils.xlate_int(get("AlternateCount"))
md.comments = utils.xlate(get("Summary"))
md.notes = utils.xlate(get("Notes"))

View File

@ -25,10 +25,6 @@ from collections.abc import Iterable, Mapping
from shutil import which # noqa: F401
from typing import Any
import natsort
import pycountry
import rapidfuzz.fuzz
import comicapi.data
try:
@ -43,6 +39,8 @@ logger = logging.getLogger(__name__)
def _custom_key(tup):
import natsort
lst = []
for x in natsort.os_sort_keygen()(tup):
ret = x
@ -54,6 +52,8 @@ def _custom_key(tup):
def os_sorted(lst: Iterable) -> Iterable:
import natsort
key = _custom_key
if icu_available or platform.system() == "Windows":
key = natsort.os_sort_keygen()
@ -115,6 +115,8 @@ def xlate_int(data: Any) -> int | None:
def xlate_float(data: Any) -> float | None:
if isinstance(data, str):
data = data.strip()
if data is None or data == "":
return None
i: str | int | float
@ -131,7 +133,7 @@ def xlate_float(data: Any) -> float | None:
def xlate(data: Any) -> str | None:
if data is None or data == "":
if data is None or isinstance(data, str) and data.strip() == "":
return None
return str(data)
@ -196,6 +198,8 @@ def sanitize_title(text: str, basic: bool = False) -> str:
def titles_match(search_title: str, record_title: str, threshold: int = 90) -> bool:
import rapidfuzz.fuzz
sanitized_search = sanitize_title(search_title)
sanitized_record = sanitize_title(record_title)
ratio = int(rapidfuzz.fuzz.ratio(sanitized_search, sanitized_record))
@ -219,26 +223,41 @@ def unique_file(file_name: pathlib.Path) -> pathlib.Path:
counter += 1
languages: dict[str | None, str | None] = defaultdict(lambda: None)
_languages: dict[str | None, str | None] = defaultdict(lambda: None)
countries: dict[str | None, str | None] = defaultdict(lambda: None)
_countries: dict[str | None, str | None] = defaultdict(lambda: None)
for c in pycountry.countries:
if "alpha_2" in c._fields:
countries[c.alpha_2] = c.name
for lng in pycountry.languages:
if "alpha_2" in lng._fields:
languages[lng.alpha_2] = lng.name
def countries() -> dict[str | None, str | None]:
if not _countries:
import pycountry
for c in pycountry.countries:
if "alpha_2" in c._fields:
_countries[c.alpha_2] = c.name
return _countries
def languages() -> dict[str | None, str | None]:
if not _languages:
import pycountry
for lng in pycountry.languages:
if "alpha_2" in lng._fields:
_languages[lng.alpha_2] = lng.name
return _languages
def get_language_from_iso(iso: str | None) -> str | None:
return languages[iso]
return languages()[iso]
def get_language_iso(string: str | None) -> str | None:
if string is None:
return None
import pycountry
# Return current string if all else fails
lang = string.casefold()
try:
@ -248,6 +267,10 @@ def get_language_iso(string: str | None) -> str | None:
return lang
def get_country_from_iso(iso: str | None) -> str | None:
return countries()[iso]
def get_publisher(publisher: str) -> tuple[str, str]:
imprint = ""
@ -256,7 +279,7 @@ def get_publisher(publisher: str) -> tuple[str, str]:
if ok:
break
return (imprint, publisher)
return imprint, publisher
def update_publishers(new_publishers: Mapping[str, Mapping[str, str]]) -> None:
@ -285,11 +308,11 @@ class ImprintDict(dict): # type: ignore
def __getitem__(self, k: str) -> tuple[str, str, bool]:
item = super().__getitem__(k.casefold())
if k.casefold() == self.publisher.casefold():
return ("", self.publisher, True)
return "", self.publisher, True
if item is None:
return ("", k, False)
return "", k, False
else:
return (item, self.publisher, True)
return item, self.publisher, True
def copy(self) -> ImprintDict:
return ImprintDict(self.publisher, super().copy())

View File

@ -19,12 +19,12 @@ import logging
import os
from typing import Callable
import settngs
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from comicapi.comicarchive import MetaDataStyle
from comicapi.genericmetadata import GenericMetadata
from comictaggerlib.coverimagewidget import CoverImageWidget
from comictaggerlib.ctsettings import ct_ns
from comictaggerlib.resulttypes import IssueResult, MultipleMatch
from comictaggerlib.ui import ui_path
from comictaggerlib.ui.qtutils import reduce_widget_font_size
@ -40,7 +40,7 @@ class AutoTagMatchWindow(QtWidgets.QDialog):
match_set_list: list[MultipleMatch],
style: int,
fetch_func: Callable[[IssueResult], GenericMetadata],
config: settngs.Namespace,
config: ct_ns,
talker: ComicTalker,
) -> None:
super().__init__(parent)

View File

@ -17,16 +17,16 @@ from __future__ import annotations
import logging
import settngs
from PyQt5 import QtCore, QtWidgets, uic
from comictaggerlib.ctsettings import ct_ns
from comictaggerlib.ui import ui_path
logger = logging.getLogger(__name__)
class AutoTagStartWindow(QtWidgets.QDialog):
def __init__(self, parent: QtWidgets.QWidget, config: settngs.Namespace, msg: str) -> None:
def __init__(self, parent: QtWidgets.QWidget, config: ct_ns, msg: str) -> None:
super().__init__(parent)
uic.loadUi(ui_path / "autotagstartwindow.ui", self)
@ -48,7 +48,6 @@ class AutoTagStartWindow(QtWidgets.QDialog):
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.cbxWaitForRateLimit.setChecked(self.config.autotag_wait_and_retry_on_rate_limit)
self.cbxAutoImprint.setChecked(self.config.identifier_auto_imprint)
nlmt_tip = """<html>The <b>Name Match Ratio Threshold: Auto-Identify</b> is for eliminating automatic
@ -73,7 +72,6 @@ class AutoTagStartWindow(QtWidgets.QDialog):
self.assume_issue_one = False
self.ignore_leading_digits_in_filename = False
self.remove_after_success = False
self.wait_and_retry_on_rate_limit = False
self.search_string = ""
self.name_length_match_tolerance = self.config.identifier_series_match_search_thresh
self.split_words = self.cbxSplitWords.isChecked()
@ -91,7 +89,6 @@ class AutoTagStartWindow(QtWidgets.QDialog):
self.ignore_leading_digits_in_filename = self.cbxIgnoreLeadingDigitsInFilename.isChecked()
self.remove_after_success = self.cbxRemoveAfterSuccess.isChecked()
self.name_length_match_tolerance = self.sbNameMatchSearchThresh.value()
self.wait_and_retry_on_rate_limit = self.cbxWaitForRateLimit.isChecked()
self.split_words = self.cbxSplitWords.isChecked()
# persist some settings
@ -100,7 +97,6 @@ class AutoTagStartWindow(QtWidgets.QDialog):
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.autotag_wait_and_retry_on_rate_limit = self.wait_and_retry_on_rate_limit
if self.cbxSpecifySearchString.isChecked():
self.search_string = self.leSearchString.text()

View File

@ -17,15 +17,14 @@ from __future__ import annotations
import logging
import settngs
from comicapi.genericmetadata import CreditMetadata, GenericMetadata
from comictaggerlib.ctsettings import ct_ns
logger = logging.getLogger(__name__)
class CBLTransformer:
def __init__(self, metadata: GenericMetadata, config: settngs.Namespace) -> None:
def __init__(self, metadata: GenericMetadata, config: ct_ns) -> None:
self.metadata = metadata
self.config = config

View File

@ -23,13 +23,12 @@ import sys
from datetime import datetime
from pprint import pprint
import settngs
from comicapi import utils
from comicapi.comicarchive import ComicArchive, MetaDataStyle
from comicapi.genericmetadata import GenericMetadata
from comictaggerlib import ctversion
from comictaggerlib.cbltransformer import CBLTransformer
from comictaggerlib.ctsettings import ct_ns
from comictaggerlib.filerenamer import FileRenamer, get_rename_dir
from comictaggerlib.graphics import graphics_path
from comictaggerlib.issueidentifier import IssueIdentifier
@ -40,7 +39,7 @@ logger = logging.getLogger(__name__)
class CLI:
def __init__(self, config: settngs.Namespace, talkers: dict[str, ComicTalker]) -> None:
def __init__(self, config: ct_ns, talkers: dict[str, ComicTalker]) -> None:
self.config = config
self.talkers = talkers
self.batch_mode = False
@ -118,7 +117,7 @@ class CLI:
md = ct_md
else:
notes = (
f"Tagged with ComicTagger {ctversion.version} using info from Comic Vine on"
f"Tagged with ComicTagger {ctversion.version} using info from {self.current_talker().name} on"
f" {datetime.now():%Y-%m-%d %H:%M:%S}. [Issue ID {ct_md.issue_id}]"
)
md.overlay(ct_md.replace(notes=utils.combine_notes(md.notes, notes, "Tagged with ComicTagger")))
@ -171,19 +170,21 @@ class CLI:
self.display_match_set_for_choice(label, match_set)
def run(self) -> None:
if len(self.config.runtime_file_list) < 1:
if len(self.config.runtime_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_file_list) > 1
self.batch_mode = len(self.config.runtime_files) > 1
for f in self.config.runtime_file_list:
for f in self.config.runtime_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}")
def create_local_metadata(self, ca: ComicArchive) -> GenericMetadata:
md = GenericMetadata()
md.set_default_page_list(ca.get_number_of_pages())
@ -316,7 +317,7 @@ class CLI:
md = GenericMetadata()
logger.error("Failed to load metadata for %s: %s", ca.path, e)
if self.config.apply_transform_on_bulk_operation_ndetadata_style == MetaDataStyle.CBI:
if self.config.cbl_apply_transform_on_bulk_operation == MetaDataStyle.CBI:
md = CBLTransformer(md, self.config).apply()
if not ca.write_metadata(md, metadata_style):
@ -340,7 +341,7 @@ class CLI:
md = self.create_local_metadata(ca)
if md.issue is None or md.issue == "":
if self.config.runtime_assume_issue_one:
if self.config.autotag_assume_1_if_no_issue_num:
md.issue = "1"
# now, search online
@ -428,13 +429,13 @@ class CLI:
return
if self.config.identifier_clear_metadata_on_import:
md = ct_md
else:
notes = (
f"Tagged with ComicTagger {ctversion.version} using info from Comic Vine on"
f" {datetime.now():%Y-%m-%d %H:%M:%S}. [Issue ID {ct_md.issue_id}]"
)
md.overlay(ct_md.replace(notes=utils.combine_notes(md.notes, notes, "Tagged with ComicTagger")))
md = GenericMetadata()
notes = (
f"Tagged with ComicTagger {ctversion.version} using info from {self.current_talker().name} on"
f" {datetime.now():%Y-%m-%d %H:%M:%S}. [Issue ID {ct_md.issue_id}]"
)
md.overlay(ct_md.replace(notes=utils.combine_notes(md.notes, notes, "Tagged with ComicTagger")))
if self.config.identifier_auto_imprint:
md.fix_publisher()
@ -486,6 +487,7 @@ class CLI:
return
except Exception:
logger.exception("Formatter failure: %s metadata: %s", self.config.rename_template, renamer.metadata)
return
folder = get_rename_dir(ca, self.config.rename_dir if self.config.rename_move_to_dir else None)

View File

@ -1,6 +1,6 @@
"""A PyQt5 widget to display cover images
Display cover images from either a local archive, or from Comic Vine.
Display cover images from either a local archive, or from comic source metadata.
TODO: This should be re-factored using subclasses!
"""
#

View File

@ -7,6 +7,7 @@ from comictaggerlib.ctsettings.commandline import (
)
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.settngs_namespace import settngs_namespace as ct_ns
from comictaggerlib.ctsettings.types import ComicTaggerPaths
from comictalker import ComicTalker
@ -21,4 +22,5 @@ __all__ = [
"validate_file_settings",
"validate_plugin_settings",
"ComicTaggerPaths",
"ct_ns",
]

View File

@ -25,14 +25,20 @@ import settngs
from comicapi import utils
from comicapi.genericmetadata import GenericMetadata
from comictaggerlib import ctversion
from comictaggerlib.ctsettings.types import ComicTaggerPaths, metadata_type, parse_metadata_from_string
from comictaggerlib.ctsettings.settngs_namespace import settngs_namespace as ct_ns
from comictaggerlib.ctsettings.types import (
ComicTaggerPaths,
metadata_type,
metadata_type_single,
parse_metadata_from_string,
)
logger = logging.getLogger(__name__)
def initial_commandline_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(add_help=False)
# Ensure this stays up to date with register_settings
# Ensure this stays up to date with register_runtime
parser.add_argument(
"--config",
help="Config directory defaults to ~/.ComicTagger\non Linux/Mac and %%APPDATA%% on Windows\n",
@ -43,7 +49,7 @@ def initial_commandline_parser() -> argparse.ArgumentParser:
return parser
def register_settings(parser: settngs.Manager) -> None:
def register_runtime(parser: settngs.Manager) -> None:
parser.add_setting(
"--config",
help="Config directory defaults to ~/.Config/ComicTagger\non Linux, ~/Library/Application Support/ComicTagger on Mac and %%APPDATA%%\\ComicTagger on Windows\n",
@ -83,7 +89,7 @@ def register_settings(parser: settngs.Manager) -> None:
parser.add_setting(
"--id",
dest="issue_id",
type=int,
type=str,
help="""Use the issue ID when searching online.\nOverrides all other metadata.\n\n""",
file=False,
)
@ -177,6 +183,7 @@ def register_settings(parser: settngs.Manager) -> None:
help="""Apply metadata to already tagged archives (relevant for -s or -c).""",
file=False,
)
parser.add_setting("--no-gui", action="store_true", help="Do not open the GUI, force the commandline", file=False)
parser.add_setting("files", nargs="*", file=False)
@ -200,7 +207,7 @@ def register_commands(parser: settngs.Manager) -> None:
parser.add_setting(
"-c",
"--copy",
type=metadata_type,
type=metadata_type_single,
metavar="{CR,CBL,COMET}",
help="Copy the specified source tag block to\ndestination style specified via -t\n(potentially lossy operation).\n\n",
file=False,
@ -236,12 +243,10 @@ def register_commands(parser: settngs.Manager) -> None:
def register_commandline_settings(parser: settngs.Manager) -> None:
parser.add_group("commands", register_commands, True)
parser.add_persistent_group("runtime", register_settings)
parser.add_persistent_group("runtime", register_runtime)
def validate_commandline_settings(
config: settngs.Config[settngs.Namespace], parser: settngs.Manager
) -> settngs.Config[settngs.Namespace]:
def validate_commandline_settings(config: settngs.Config[ct_ns], parser: settngs.Manager) -> settngs.Config[ct_ns]:
if config[0].commands_version:
parser.exit(
status=1,
@ -258,6 +263,7 @@ def validate_commandline_settings(
config[0].commands_rename,
config[0].commands_export_to_zip,
config[0].commands_only_set_cv_key,
config[0].runtime_no_gui,
]
)
@ -282,14 +288,9 @@ def validate_commandline_settings(
if config[0].commands_copy:
if not config[0].runtime_type:
parser.exit(message="Please specify the type to copy to with -t\n", status=1)
if len(config[0].commands_copy) > 1:
parser.exit(message="Please specify only one type to copy to with -c\n", status=1)
config[0].commands_copy = config[0].commands_copy[0]
if config[0].runtime_recursive:
config[0].runtime_file_list = utils.get_recursive_filelist(config[0].runtime_files)
else:
config[0].runtime_file_list = config[0].runtime_files
config[0].runtime_files = utils.get_recursive_filelist(config[0].runtime_files)
# take a crack at finding rar exe if it's not in the path
if not utils.which("rar"):

View File

@ -5,7 +5,7 @@ import uuid
import settngs
from comictaggerlib.ctsettings.types import AppendAction
from comictaggerlib.ctsettings.settngs_namespace import settngs_namespace as ct_ns
from comictaggerlib.defaults import DEFAULT_REPLACEMENTS, Replacement, Replacements
@ -43,8 +43,9 @@ def identifier(parser: settngs.Manager) -> None:
parser.add_setting(
"--publisher-filter",
default=["Panini Comics", "Abril", "Planeta DeAgostini", "Editorial Televisa", "Dino Comics"],
action=AppendAction,
help="When enabled filters the listed publishers from all search results",
action="extend",
nargs="+",
help="When enabled, filters the listed publishers from all search results. Ending a publisher with a '-' removes a publisher from this list",
)
parser.add_setting("--series-match-search-thresh", default=90, type=int)
parser.add_setting(
@ -206,18 +207,24 @@ def autotag(parser: settngs.Manager) -> None:
help="When searching ignore leading numbers in the filename",
)
parser.add_setting("remove_archive_after_successful_match", default=False, cmdline=False)
parser.add_setting(
"-w",
"--wait-on-rate-limit",
dest="wait_and_retry_on_rate_limit",
action=argparse.BooleanOptionalAction,
default=True,
help="When encountering a Comic Vine rate limit\nerror, wait and retry query.\n\n",
)
def validate_file_settings(config: settngs.Config[settngs.Namespace]) -> settngs.Config[settngs.Namespace]:
config[0].identifier_publisher_filter = [x.strip() for x in config[0].identifier_publisher_filter if x.strip()]
def validate_file_settings(config: settngs.Config[ct_ns]) -> settngs.Config[ct_ns]:
new_filter = []
remove = []
for x in config[0].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
remove.append(x.strip("-"))
else:
if x not in new_filter:
new_filter.append(x)
for x in remove: # remove publishers
if x in new_filter:
new_filter.remove(x)
config[0].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]],

View File

@ -2,11 +2,13 @@ from __future__ import annotations
import logging
import os
from typing import cast
import settngs
import comicapi.comicarchive
import comictaggerlib.ctsettings
from comictaggerlib.ctsettings.settngs_namespace import settngs_namespace as ct_ns
logger = logging.getLogger("comictagger")
@ -27,15 +29,15 @@ def register_talker_settings(manager: settngs.Manager) -> None:
for talker_id, talker in comictaggerlib.ctsettings.talkers.items():
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",
default="",
display_name="API Key",
help=f"API Key for {talker.name} (default: {talker.default_api_key})",
)
manager.add_setting(
f"--{talker_id}-url",
default="",
display_name="URL",
help=f"URL for {talker.name} (default: {talker.default_api_url})",
)
@ -47,10 +49,10 @@ def register_talker_settings(manager: settngs.Manager) -> None:
logger.exception("Failed to register settings for %s", talker_id)
def validate_archive_settings(config: settngs.Config[settngs.Namespace]) -> settngs.Config[settngs.Namespace]:
def validate_archive_settings(config: settngs.Config[ct_ns]) -> settngs.Config[ct_ns]:
if "archiver" not in config[1]:
return config
cfg = settngs.normalize_config(config, file=True, cmdline=True, defaults=False)
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]:
@ -62,7 +64,7 @@ def validate_archive_settings(config: settngs.Config[settngs.Namespace]) -> sett
return config
def validate_talker_settings(config: settngs.Config[settngs.Namespace]) -> settngs.Config[settngs.Namespace]:
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()):
@ -73,10 +75,10 @@ def validate_talker_settings(config: settngs.Config[settngs.Namespace]) -> settn
del comictaggerlib.ctsettings.talkers[talker_id]
logger.exception("Failed to initialize talker settings: %s", e)
return settngs.get_namespace(cfg)
return cast(settngs.Config[ct_ns], settngs.get_namespace(cfg, file=True, cmdline=True))
def validate_plugin_settings(config: settngs.Config[settngs.Namespace]) -> settngs.Config[settngs.Namespace]:
def validate_plugin_settings(config: settngs.Config[ct_ns]) -> settngs.Config[ct_ns]:
config = validate_archive_settings(config)
config = validate_talker_settings(config)
return config

View File

@ -0,0 +1,104 @@
from __future__ import annotations
import settngs
import comicapi.genericmetadata
import comictaggerlib.ctsettings.types
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
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
internal_install_id: str
internal_save_data_style: int
internal_load_data_style: int
internal_last_opened_folder: str
internal_window_width: int
internal_window_height: int
internal_window_x: int
internal_window_y: int
internal_form_width: int
internal_list_width: int
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
dialog_show_disclaimer: bool
dialog_dont_notify_about_this_version: str
dialog_ask_about_usage_stats: bool
filename_complicated_parser: bool
filename_remove_c2c: bool
filename_remove_fcbd: bool
filename_remove_publisher: bool
talker_source: str
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
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
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

View File

@ -2,8 +2,6 @@ from __future__ import annotations
import argparse
import pathlib
from collections.abc import Sequence
from typing import Any, Callable
from appdirs import AppDirs
@ -59,6 +57,13 @@ class ComicTaggerPaths(AppDirs):
return pathlib.Path(super().site_config_dir)
def metadata_type_single(types: str) -> int:
result = metadata_type(types)
if len(result) > 1:
raise argparse.ArgumentTypeError(f"invalid choice: {result} (only one metadata style allowed)")
return result[0]
def metadata_type(types: str) -> list[int]:
result = []
types = types.casefold()
@ -71,71 +76,6 @@ def metadata_type(types: str) -> list[int]:
return result
def _copy_items(items: Sequence[Any] | None) -> Sequence[Any]:
if items is None:
return []
# The copy module is used only in the 'append' and 'append_const'
# actions, and it is needed only when the default value isn't a list.
# Delay its import for speeding up the common case.
if type(items) is list:
return items[:]
import copy
return copy.copy(items)
class AppendAction(argparse.Action):
def __init__(
self,
option_strings: list[str],
dest: str,
nargs: str | None = None,
const: Any = None,
default: Any = None,
type: Callable[[str], Any] | None = None, # noqa: A002
choices: list[Any] | None = None,
required: bool = False,
help: str | None = None, # noqa: A002
metavar: str | None = None,
):
self.called = False
if nargs == 0:
raise ValueError(
"nargs for append actions must be != 0; if arg "
"strings are not supplying the value to append, "
"the append const action may be more appropriate"
)
if const is not None and nargs != argparse.OPTIONAL:
raise ValueError("nargs must be %r to supply const" % argparse.OPTIONAL)
super().__init__(
option_strings=option_strings,
dest=dest,
nargs=nargs,
const=const,
default=default,
type=type,
choices=choices,
required=required,
help=help,
metavar=metavar,
)
def __call__(
self,
parser: argparse.ArgumentParser,
namespace: argparse.Namespace,
values: str | Sequence[Any] | None,
option_string: str | None = None,
) -> None:
if values:
if not self.called:
setattr(namespace, self.dest, [])
items = getattr(namespace, self.dest, None)
items = _copy_items(items)
items.append(values) # type: ignore
setattr(namespace, self.dest, items)
def parse_metadata_from_string(mdstr: str) -> GenericMetadata:
"""The metadata string is a comma separated list of name-value pairs
The names match the attributes of the internal metadata struct (for now)

View File

@ -209,15 +209,13 @@ class FileRenamer:
md = self.metadata
# padding for issue
md.issue = IssueString(md.issue).as_string(pad=self.issue_zero_padding)
template = self.template
new_name = ""
fmt = MetadataFormatter(self.smart_cleanup, platform=self.platform, replacements=self.replacements)
md_dict = vars(md)
md_dict["issue"] = IssueString(md.issue).as_string(pad=self.issue_zero_padding)
for role in ["writer", "penciller", "inker", "colorist", "letterer", "cover artist", "editor"]:
md_dict[role] = md.get_primary_credit(role)
@ -238,8 +236,6 @@ class FileRenamer:
new_name += ext
new_basename += ext
# remove padding
md.issue = IssueString(md.issue).as_string()
if self.move:
return new_name.strip()
return new_basename.strip()

View File

@ -20,11 +20,11 @@ import os
import platform
from typing import Callable, cast
import settngs
from PyQt5 import QtCore, QtWidgets, uic
from comicapi import utils
from comicapi.comicarchive import ComicArchive
from comictaggerlib.ctsettings import ct_ns
from comictaggerlib.graphics import graphics_path
from comictaggerlib.optionalmsgdialog import OptionalMessageDialog
from comictaggerlib.settingswindow import linuxRarHelp, macRarHelp, windowsRarHelp
@ -57,7 +57,7 @@ class FileSelectionList(QtWidgets.QWidget):
dataColNum = fileColNum
def __init__(
self, parent: QtWidgets.QWidget, config: settngs.Namespace, dirty_flag_verification: Callable[[str, str], bool]
self, parent: QtWidgets.QWidget, config: ct_ns, dirty_flag_verification: Callable[[str, str], bool]
) -> None:
super().__init__(parent)

View File

@ -0,0 +1,102 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg
version="1.1"
id="Capa_1"
x="0px"
y="0px"
viewBox="0 0 469.333 469.333"
style="enable-background:new 0 0 469.333 469.333;"
xml:space="preserve"
sodipodi:docname="eye.svg"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs45" /><sodipodi:namedview
id="namedview43"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
showgrid="false"
inkscape:zoom="2.1882117"
inkscape:cx="234.6665"
inkscape:cy="234.6665"
inkscape:window-width="2560"
inkscape:window-height="1361"
inkscape:window-x="0"
inkscape:window-y="42"
inkscape:window-maximized="1"
inkscape:current-layer="Capa_1" />
<g
id="g10"
style="fill:#333333">
<g
id="g8"
style="fill:#333333">
<g
id="g6"
style="fill:#333333">
<path
d="M234.667,170.667c-35.307,0-64,28.693-64,64s28.693,64,64,64s64-28.693,64-64S269.973,170.667,234.667,170.667z"
id="path2"
style="fill:#333333" />
<path
d="M234.667,74.667C128,74.667,36.907,141.013,0,234.667c36.907,93.653,128,160,234.667,160 c106.773,0,197.76-66.347,234.667-160C432.427,141.013,341.44,74.667,234.667,74.667z M234.667,341.333 c-58.88,0-106.667-47.787-106.667-106.667S175.787,128,234.667,128s106.667,47.787,106.667,106.667 S293.547,341.333,234.667,341.333z"
id="path4"
style="fill:#333333" />
</g>
</g>
</g>
<g
id="g12">
</g>
<g
id="g14">
</g>
<g
id="g16">
</g>
<g
id="g18">
</g>
<g
id="g20">
</g>
<g
id="g22">
</g>
<g
id="g24">
</g>
<g
id="g26">
</g>
<g
id="g28">
</g>
<g
id="g30">
</g>
<g
id="g32">
</g>
<g
id="g34">
</g>
<g
id="g36">
</g>
<g
id="g38">
</g>
<g
id="g40">
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -0,0 +1,106 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg
version="1.1"
id="Capa_1"
x="0px"
y="0px"
viewBox="0 0 469.44 469.44"
style="enable-background:new 0 0 469.44 469.44;"
xml:space="preserve"
sodipodi:docname="hidden.svg"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs47" /><sodipodi:namedview
id="namedview45"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
showgrid="false"
inkscape:zoom="2.187713"
inkscape:cx="234.72"
inkscape:cy="234.72"
inkscape:window-width="2560"
inkscape:window-height="1361"
inkscape:window-x="0"
inkscape:window-y="42"
inkscape:window-maximized="1"
inkscape:current-layer="Capa_1" />
<g
id="g12"
style="fill:#333333">
<g
id="g10"
style="fill:#333333">
<g
id="g8"
style="fill:#333333">
<path
d="M231.147,160.373l67.2,67.2l0.32-3.52c0-35.307-28.693-64-64-64L231.147,160.373z"
id="path2"
style="fill:#333333" />
<path
d="M234.667,117.387c58.88,0,106.667,47.787,106.667,106.667c0,13.76-2.773,26.88-7.573,38.933l62.4,62.4 c32.213-26.88,57.6-61.653,73.28-101.333c-37.013-93.653-128-160-234.773-160c-29.867,0-58.453,5.333-85.013,14.933l46.08,45.973 C207.787,120.267,220.907,117.387,234.667,117.387z"
id="path4"
style="fill:#333333" />
<path
d="M21.333,59.253l48.64,48.64l9.707,9.707C44.48,145.12,16.64,181.707,0,224.053c36.907,93.653,128,160,234.667,160 c33.067,0,64.64-6.4,93.547-18.027l9.067,9.067l62.187,62.293l27.2-27.093L48.533,32.053L21.333,59.253z M139.307,177.12 l32.96,32.96c-0.96,4.587-1.6,9.173-1.6,13.973c0,35.307,28.693,64,64,64c4.8,0,9.387-0.64,13.867-1.6l32.96,32.96 c-14.187,7.04-29.973,11.307-46.827,11.307C175.787,330.72,128,282.933,128,224.053C128,207.2,132.267,191.413,139.307,177.12z"
id="path6"
style="fill:#333333" />
</g>
</g>
</g>
<g
id="g14">
</g>
<g
id="g16">
</g>
<g
id="g18">
</g>
<g
id="g20">
</g>
<g
id="g22">
</g>
<g
id="g24">
</g>
<g
id="g26">
</g>
<g
id="g28">
</g>
<g
id="g30">
</g>
<g
id="g32">
</g>
<g
id="g34">
</g>
<g
id="g36">
</g>
<g
id="g38">
</g>
<g
id="g40">
</g>
<g
id="g42">
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -9,6 +9,7 @@ import types
import settngs
from comictaggerlib.ctsettings import ct_ns
from comictaggerlib.graphics import graphics_path
from comictalker.comictalker import ComicTalker
@ -83,7 +84,7 @@ except ImportError:
def open_tagger_window(
talkers: dict[str, ComicTalker], config: settngs.Config[settngs.Namespace], error: tuple[str, bool] | None
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 = []

View File

@ -22,18 +22,15 @@ import pathlib
import shutil
import sqlite3 as lite
import tempfile
from typing import TYPE_CHECKING
import requests
from comictaggerlib import ctversion
try:
if TYPE_CHECKING:
from PyQt5 import QtCore, QtNetwork
qt_available = True
except ImportError:
qt_available = False
logger = logging.getLogger(__name__)
@ -47,6 +44,7 @@ def fetch_complete(url: str, image_data: bytes | QtCore.QByteArray) -> None:
class ImageFetcher:
image_fetch_complete = fetch_complete
qt_available = True
def __init__(self, cache_folder: pathlib.Path) -> None:
self.db_file = cache_folder / "image_url_cache.db"
@ -55,10 +53,17 @@ class ImageFetcher:
self.user_data = None
self.fetched_url = ""
if self.qt_available:
try:
from PyQt5 import QtNetwork
self.qt_available = True
except ImportError:
self.qt_available = False
if not os.path.exists(self.db_file):
self.create_image_db()
if qt_available:
if self.qt_available:
self.nam = QtNetwork.QNetworkAccessManager()
def clear_cache(self) -> None:
@ -79,7 +84,7 @@ class ImageFetcher:
# first look in the DB
image_data = self.get_image_from_cache(url)
# Async for retrieving covers seems to work well
if blocking or not qt_available:
if blocking or not self.qt_available:
if not image_data:
try:
image_data = requests.get(url, headers={"user-agent": "comictagger/" + ctversion.version}).content
@ -91,7 +96,9 @@ class ImageFetcher:
ImageFetcher.image_fetch_complete(url, image_data)
return image_data
if qt_available:
if self.qt_available:
from PyQt5 import QtCore, QtNetwork
# if we found it, just emit the signal asap
if image_data:
ImageFetcher.image_fetch_complete(url, QtCore.QByteArray(image_data))

View File

@ -17,7 +17,9 @@ from __future__ import annotations
import io
import logging
import math
from functools import reduce
from statistics import median
from typing import TypeVar
try:
@ -90,82 +92,80 @@ class ImageHasher:
return result
"""
def dct_average_hash(self) -> None:
def p_hash(self) -> int:
"""
Pure python version of Perceptual Hash computation of https://github.com/JohannesBuchner/imagehash/tree/master
Implementation follows http://www.hackerfactor.com/blog/index.php?/archives/432-Looks-Like-It.html
"""
# Algorithm source: http://syntaxcandy.blogspot.com/2012/08/perceptual-hash.html
1. Reduce size. Like Average Hash, pHash starts with a small image.
However, the image is larger than 8x8; 32x32 is a good size. This
is really done to simplify the DCT computation and not because it
is needed to reduce the high frequencies.
def generate_dct2(block, axis=0):
def dct1(block):
"""Perform 1D Discrete Cosine Transform (DCT) on a given block."""
N = len(block)
dct_block = [0.0] * N
2. Reduce color. The image is reduced to a grayscale just to further
simplify the number of computations.
for k in range(N):
sum_val = 0.0
for n in range(N):
cos_val = math.cos(math.pi * k * (2 * n + 1) / (2 * N))
sum_val += block[n] * cos_val
dct_block[k] = sum_val
3. Compute the DCT. The DCT separates the image into a collection of
frequencies and scalars. While JPEG uses an 8x8 DCT, this algorithm
uses a 32x32 DCT.
return dct_block
4. Reduce the DCT. This is the magic step. While the DCT is 32x32,
just keep the top-left 8x8. Those represent the lowest frequencies in
the picture.
"""Perform 2D Discrete Cosine Transform (DCT) on a given block along the specified axis."""
rows = len(block)
cols = len(block[0])
dct_block = [[0.0] * cols for _ in range(rows)]
5. Compute the average value. Like the Average Hash, compute the mean DCT
value (using only the 8x8 DCT low-frequency values and excluding the first
term since the DC coefficient can be significantly different from the other
values and will throw off the average). Thanks to David Starkweather for the
added information about pHash. He wrote: "the dct hash is based on the low 2D
DCT coefficients starting at the second from lowest, leaving out the first DC
term. This excludes completely flat image information (i.e. solid colors) from
being included in the hash description."
if axis == 0:
# Apply 1D DCT on each row
for i in range(rows):
dct_block[i] = dct1(block[i])
elif axis == 1:
# Apply 1D DCT on each column
for j in range(cols):
column = [block[i][j] for i in range(rows)]
dct_column = dct1(column)
for i in range(rows):
dct_block[i][j] = dct_column[i]
else:
raise ValueError("Invalid axis value. Must be either 0 or 1.")
6. Further reduce the DCT. This is the magic step. Set the 64 hash bits to 0 or
1 depending on whether each of the 64 DCT values is above or below the average
value. The result doesn't tell us the actual low frequencies; it just tells us
the very-rough relative scale of the frequencies to the mean. The result will not
vary as long as the overall structure of the image remains the same; this can
survive gamma and color histogram adjustments without a problem.
return dct_block
7. Construct the hash. Set the 64 bits into a 64-bit integer. The order does not
matter, just as long as you are consistent.
def convert_image_to_ndarray(image):
width, height = image.size
pixels2 = []
for y in range(height):
row = []
for x in range(width):
pixel = image.getpixel((x, y))
row.append(pixel)
pixels2.append(row)
import numpy
import scipy.fftpack
numpy.set_printoptions(threshold=10000, linewidth=200, precision=2, suppress=True)
return pixels2
# Step 1,2
im = self.image.resize((32, 32), Image.ANTIALIAS).convert("L")
in_data = numpy.asarray(im)
highfreq_factor = 4
img_size = 8 * highfreq_factor
# Step 3
dct = scipy.fftpack.dct(in_data.astype(float))
try:
image = self.image.convert("L").resize((img_size, img_size), Image.Resampling.LANCZOS)
except Exception:
logger.exception("p_hash error converting to greyscale and resizing")
return 0
# Step 4
# Just skip the top and left rows when slicing, as suggested somewhere else...
lofreq_dct = dct[1:9, 1:9].flatten()
# Step 5
avg = (lofreq_dct.sum()) / (lofreq_dct.size)
median = numpy.median(lofreq_dct)
thresh = avg
# Step 6
def compare_value_to_thresh(i):
return (1 if i > thresh else 0)
bitlist = map(compare_value_to_thresh, lofreq_dct)
#Step 7
def set_bit(x, (idx, val)):
return (x | (val << idx))
result = reduce(set_bit, enumerate(bitlist), long(0))
pixels = convert_image_to_ndarray(image)
dct = generate_dct2(generate_dct2(pixels, axis=0), axis=1)
dctlowfreq = [row[:8] for row in dct[:8]]
med = median([item for sublist in dctlowfreq for item in sublist])
# Convert to a bit string
diff = "".join(str(int(item > med)) for row in dctlowfreq for item in row)
result = int(diff, 2)
return result
"""
# accepts 2 hashes (longs or hex strings) and returns the hamming distance

View File

@ -20,13 +20,13 @@ import logging
import sys
from typing import Any, Callable
import settngs
from typing_extensions import NotRequired, TypedDict
from comicapi import utils
from comicapi.comicarchive import ComicArchive
from comicapi.genericmetadata import GenericMetadata
from comicapi.issuestring import IssueString
from comictaggerlib.ctsettings import ct_ns
from comictaggerlib.imagefetcher import ImageFetcher, ImageFetcherException
from comictaggerlib.imagehasher import ImageHasher
from comictaggerlib.resulttypes import IssueResult
@ -72,7 +72,7 @@ class IssueIdentifier:
result_one_good_match = 4
result_multiple_good_matches = 5
def __init__(self, comic_archive: ComicArchive, config: settngs.Namespace, talker: ComicTalker) -> None:
def __init__(self, comic_archive: ComicArchive, config: ct_ns, talker: ComicTalker) -> None:
self.config = config
self.talker = talker
self.comic_archive: ComicArchive = comic_archive
@ -134,7 +134,7 @@ class IssueIdentifier:
def calculate_hash(self, image_data: bytes) -> int:
if self.image_hasher == 3:
return -1 # ImageHasher(data=image_data).dct_average_hash()
return ImageHasher(data=image_data).p_hash()
if self.image_hasher == 2:
return -1 # ImageHasher(data=image_data).average_hash2()
@ -296,7 +296,7 @@ class IssueIdentifier:
primary_img_url, blocking=True
)
except ImageFetcherException as e:
self.log_msg("Network issue while fetching cover image from Comic Vine. Aborting...")
self.log_msg(f"Network issue while fetching cover image from {self.talker.name}. Aborting...")
raise IssueIdentifierNetworkError from e
if self.cancel:
@ -318,7 +318,7 @@ class IssueIdentifier:
alt_url, blocking=True
)
except ImageFetcherException as e:
self.log_msg("Network issue while fetching alt. cover image from Comic Vine. Aborting...")
self.log_msg(f"Network issue while fetching alt. cover image from {self.talker.name}. Aborting...")
raise IssueIdentifierNetworkError from e
if self.cancel:
@ -397,7 +397,7 @@ class IssueIdentifier:
self.log_msg("Not enough info for a search!")
return []
self.log_msg("Going to search for:")
self.log_msg(f"Using {self.talker.name} to search for:")
self.log_msg("\tSeries: " + keys["series"])
self.log_msg("\tIssue: " + keys["issue_number"])
if keys["issue_count"] is not None:

View File

@ -17,11 +17,11 @@ from __future__ import annotations
import logging
import settngs
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from comicapi.issuestring import IssueString
from comictaggerlib.coverimagewidget import CoverImageWidget
from comictaggerlib.ctsettings import ct_ns
from comictaggerlib.ui import ui_path
from comictaggerlib.ui.qtutils import reduce_widget_font_size
from comictalker.comictalker import ComicTalker, TalkerError
@ -42,7 +42,7 @@ class IssueSelectionWindow(QtWidgets.QDialog):
def __init__(
self,
parent: QtWidgets.QWidget,
config: settngs.Namespace,
config: ct_ns,
talker: ComicTalker,
series_id: str,
issue_number: str,
@ -115,6 +115,17 @@ class IssueSelectionWindow(QtWidgets.QDialog):
self.twList.selectRow(r)
break
self.leFilter.textChanged.connect(self.filter)
def filter(self, text: str) -> None:
rows = set(range(self.twList.rowCount()))
for r in rows:
self.twList.showRow(r)
if text.strip():
shown_rows = {x.row() for x in self.twList.findItems(text, QtCore.Qt.MatchFlag.MatchContains)}
for r in rows - shown_rows:
self.twList.hideRow(r)
def perform_query(self) -> None:
QtWidgets.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.CursorShape.WaitCursor))

View File

@ -24,12 +24,15 @@ import os
import signal
import subprocess
import sys
from typing import cast
import settngs
import comicapi
import comicapi.comicarchive
import comicapi.utils
import comictalker
from comictaggerlib import cli, ctsettings
from comictaggerlib.ctsettings import ct_ns
from comictaggerlib.ctversion import version
from comictaggerlib.log import setup_logging
@ -40,14 +43,6 @@ else:
logger = logging.getLogger("comictagger")
try:
from comictaggerlib import gui
qt_available = gui.qt_available
except Exception:
logger.exception("Qt unavailable")
qt_available = False
logger.setLevel(logging.DEBUG)
@ -95,7 +90,7 @@ def configure_locale() -> None:
sys.stdin.reconfigure(encoding=sys.getdefaultencoding()) # type: ignore[attr-defined]
def update_publishers(config: settngs.Config[settngs.Namespace]) -> None:
def update_publishers(config: settngs.Config[ct_ns]) -> None:
json_file = config[0].runtime_config.user_config_dir / "publishers.json"
if json_file.exists():
try:
@ -108,7 +103,7 @@ class App:
"""docstring for App"""
def __init__(self) -> None:
self.config: settngs.Config[settngs.Namespace]
self.config: settngs.Config[ct_ns]
self.initial_arg_parser = ctsettings.initial_commandline_parser()
self.config_load_success = False
@ -141,9 +136,11 @@ class App:
ctsettings.register_file_settings(self.manager)
ctsettings.register_plugin_settings(self.manager)
def parse_settings(self, config_paths: ctsettings.ComicTaggerPaths) -> settngs.Config[settngs.Namespace]:
cfg, self.config_load_success = self.manager.parse_config(config_paths.user_config_dir / "settings.json")
config = self.manager.get_namespace(cfg)
def parse_settings(self, config_paths: ctsettings.ComicTaggerPaths, *args: str) -> settngs.Config[ct_ns]:
cfg, self.config_load_success = self.manager.parse_config(
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 = ctsettings.validate_commandline_settings(config, self.manager)
config = ctsettings.validate_file_settings(config)
@ -185,15 +182,11 @@ class App:
comicapi.utils.load_publishers()
update_publishers(self.config)
if not qt_available and not self.config[0].runtime_no_gui:
self.config[0].runtime_no_gui = True
logger.warning("PyQt5 is not available. ComicTagger is limited to command-line mode.")
# 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
or self.config[0].talker_comicvine_comicvine_url is not None
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:
@ -210,16 +203,23 @@ class App:
True,
)
if self.config[0].runtime_no_gui:
if error and error[1]:
print(f"A fatal error occurred please check the log for more information: {error[0]}") # noqa: T201
raise SystemExit(1)
if not self.config[0].runtime_no_gui:
try:
cli.CLI(self.config[0], talkers).run()
except Exception:
logger.exception("CLI mode failed")
else:
gui.open_tagger_window(talkers, self.config, error)
from comictaggerlib import gui
return gui.open_tagger_window(talkers, self.config, error)
except ImportError:
self.config[0].runtime_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
if error and error[1]:
print(f"A fatal error occurred please check the log for more information: {error[0]}") # noqa: T201
raise SystemExit(1)
try:
cli.CLI(self.config[0], talkers).run()
except Exception:
logger.exception("CLI mode failed")
def main() -> None:

View File

@ -18,11 +18,11 @@ from __future__ import annotations
import logging
import os
import settngs
from PyQt5 import QtCore, QtWidgets, uic
from comicapi.comicarchive import ComicArchive
from comictaggerlib.coverimagewidget import CoverImageWidget
from comictaggerlib.ctsettings import ct_ns
from comictaggerlib.resulttypes import IssueResult
from comictaggerlib.ui import ui_path
from comictaggerlib.ui.qtutils import reduce_widget_font_size
@ -37,7 +37,7 @@ class MatchSelectionWindow(QtWidgets.QDialog):
parent: QtWidgets.QWidget,
matches: list[IssueResult],
comic_archive: ComicArchive,
config: settngs.Namespace,
config: ct_ns,
talker: ComicTalker,
) -> None:
super().__init__(parent)

View File

@ -23,6 +23,7 @@ from PyQt5 import QtCore, QtWidgets, uic
from comicapi import utils
from comicapi.comicarchive import ComicArchive, MetaDataStyle
from comicapi.genericmetadata import GenericMetadata
from comictaggerlib.ctsettings import ct_ns
from comictaggerlib.filerenamer import FileRenamer, get_rename_dir
from comictaggerlib.settingswindow import SettingsWindow
from comictaggerlib.ui import ui_path
@ -38,7 +39,7 @@ class RenameWindow(QtWidgets.QDialog):
parent: QtWidgets.QWidget,
comic_archive_list: list[ComicArchive],
data_style: int,
config: settngs.Config[settngs.Namespace],
config: settngs.Config[ct_ns],
talkers: dict[str, ComicTalker],
) -> None:
super().__init__(parent)
@ -124,6 +125,7 @@ class RenameWindow(QtWidgets.QDialog):
"<a href='https://github.com/comictagger/comictagger'>"
"https://github.com/comictagger/comictagger</a>",
)
return
row = self.twList.rowCount()
self.twList.insertRow(row)

View File

@ -19,7 +19,6 @@ import itertools
import logging
from collections import deque
import settngs
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from PyQt5.QtCore import pyqtSignal
@ -27,6 +26,7 @@ from comicapi import utils
from comicapi.comicarchive import ComicArchive
from comicapi.genericmetadata import GenericMetadata
from comictaggerlib.coverimagewidget import CoverImageWidget
from comictaggerlib.ctsettings import ct_ns
from comictaggerlib.issueidentifier import IssueIdentifier
from comictaggerlib.issueselectionwindow import IssueSelectionWindow
from comictaggerlib.matchselectionwindow import MatchSelectionWindow
@ -106,7 +106,7 @@ class SeriesSelectionWindow(QtWidgets.QDialog):
issue_count: int | None,
cover_index_list: list[int],
comic_archive: ComicArchive | None,
config: settngs.Namespace,
config: ct_ns,
talker: ComicTalker,
autoselect: bool = False,
literal: bool = False,
@ -187,6 +187,17 @@ class SeriesSelectionWindow(QtWidgets.QDialog):
self.update_buttons()
self.twList.selectRow(0)
self.leFilter.textChanged.connect(self.filter)
def filter(self, text: str) -> None:
rows = set(range(self.twList.rowCount()))
for r in rows:
self.twList.showRow(r)
if text.strip():
shown_rows = {x.row() for x in self.twList.findItems(text, QtCore.Qt.MatchFlag.MatchContains)}
for r in rows - shown_rows:
self.twList.hideRow(r)
def update_buttons(self) -> None:
enabled = bool(self.ct_search_results)
@ -464,18 +475,21 @@ class SeriesSelectionWindow(QtWidgets.QDialog):
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
self.twList.setItem(row, 0, item)
item_text = str(record.start_year)
item = QtWidgets.QTableWidgetItem(item_text)
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
self.twList.setItem(row, 1, item)
if record.start_year is not None:
item_text = f"{record.start_year:04}"
item = QtWidgets.QTableWidgetItem(item_text)
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
item.setData(QtCore.Qt.ItemDataRole.DisplayRole, record.start_year)
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
self.twList.setItem(row, 1, item)
item_text = str(record.count_of_issues)
item = QtWidgets.QTableWidgetItem(item_text)
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
item.setData(QtCore.Qt.ItemDataRole.DisplayRole, record.count_of_issues)
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
self.twList.setItem(row, 2, item)
if record.count_of_issues is not None:
item_text = f"{record.count_of_issues:04}"
item = QtWidgets.QTableWidgetItem(item_text)
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
item.setData(QtCore.Qt.ItemDataRole.DisplayRole, record.count_of_issues)
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
self.twList.setItem(row, 2, item)
if record.publisher is not None:
item_text = record.publisher

View File

@ -20,7 +20,7 @@ import logging
import os
import pathlib
import platform
from typing import Any
from typing import Any, cast
import settngs
from PyQt5 import QtCore, QtGui, QtWidgets, uic
@ -29,6 +29,7 @@ import comictaggerlib.ui.talkeruigenerator
from comicapi import utils
from comicapi.genericmetadata import md_test
from comictaggerlib import ctsettings
from comictaggerlib.ctsettings import ct_ns
from comictaggerlib.ctversion import version
from comictaggerlib.filerenamer import FileRenamer, Replacement, Replacements
from comictaggerlib.imagefetcher import ImageFetcher
@ -133,7 +134,7 @@ Spider-Geddon #1 - New Players; Check In
class SettingsWindow(QtWidgets.QDialog):
def __init__(
self, parent: QtWidgets.QWidget, config: settngs.Config[settngs.Namespace], talkers: dict[str, ComicTalker]
self, parent: QtWidgets.QWidget, config: settngs.Config[ct_ns], talkers: dict[str, ComicTalker]
) -> None:
super().__init__(parent)
@ -413,7 +414,7 @@ class SettingsWindow(QtWidgets.QDialog):
setattr(self.config[0], self.config[1]["archiver"].v["rar"].internal_name, str(self.leRarExePath.text()))
# make sure rar program is now in the path for the rar class
if self.config[0].archiver_rar:
if self.config[0].archiver_rar: # type: ignore[attr-defined]
utils.add_to_path(os.path.dirname(str(self.leRarExePath.text())))
if not str(self.leIssueNumPadding.text()).isdigit():
@ -480,7 +481,7 @@ class SettingsWindow(QtWidgets.QDialog):
QtWidgets.QMessageBox.information(self, self.name, "Cache has been cleared.")
def reset_settings(self) -> None:
self.config = settngs.get_namespace(settngs.defaults(self.config[1]))
self.config = cast(settngs.Config[ct_ns], settngs.get_namespace(settngs.defaults(self.config[1])))
self.settings_to_form()
QtWidgets.QMessageBox.information(self, self.name, self.name + " have been returned to default values.")

View File

@ -48,6 +48,7 @@ from comictaggerlib.autotagstartwindow import AutoTagStartWindow
from comictaggerlib.cbltransformer import CBLTransformer
from comictaggerlib.coverimagewidget import CoverImageWidget
from comictaggerlib.crediteditorwindow import CreditEditorWindow
from comictaggerlib.ctsettings import ct_ns
from comictaggerlib.exportwindow import ExportConflictOpts, ExportWindow
from comictaggerlib.fileselectionlist import FileInfo, FileSelectionList
from comictaggerlib.graphics import graphics_path
@ -79,7 +80,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
def __init__(
self,
file_list: list[str],
config: settngs.Config[settngs.Namespace],
config: settngs.Config[ct_ns],
talkers: dict[str, ComicTalker],
parent: QtWidgets.QWidget | None = None,
) -> None:
@ -256,6 +257,10 @@ class TaggerWindow(QtWidgets.QMainWindow):
Also, be aware that writing tags to comic archives will change their file hashes,
which has implications with respect to other software packages. It's best to
use ComicTagger on local copies of your comics.<br><br>
COMIC VINE NOTE: Using the default API key will serverly limit search and tagging
times. A personal API key will allow for a <b>5 times increase</b> in online search speed. See the
<a href='https://github.com/comictagger/comictagger/wiki/UserGuide#comic-vine'>Wiki page</a>
for more information.<br><br>
Have fun!
""",
)
@ -896,8 +901,8 @@ class TaggerWindow(QtWidgets.QMainWindow):
# copy the data from the form into the metadata
md = GenericMetadata()
md.is_empty = False
md.alternate_number = IssueString(self.leAltIssueNum.text()).as_string()
md.issue = IssueString(self.leIssueNum.text()).as_string()
md.alternate_number = utils.xlate(IssueString(self.leAltIssueNum.text()).as_string())
md.issue = utils.xlate(IssueString(self.leIssueNum.text()).as_string())
md.issue_count = utils.xlate_int(self.leIssueCount.text())
md.volume = utils.xlate_int(self.leVolumeNum.text())
md.volume_count = utils.xlate_int(self.leVolumeCount.text())
@ -906,30 +911,30 @@ class TaggerWindow(QtWidgets.QMainWindow):
md.day = utils.xlate_int(self.lePubDay.text())
md.alternate_count = utils.xlate_int(self.leAltIssueCount.text())
md.series = self.leSeries.text()
md.title = self.leTitle.text()
md.publisher = self.lePublisher.text()
md.genre = self.leGenre.text()
md.imprint = self.leImprint.text()
md.comments = self.teComments.toPlainText()
md.notes = self.teNotes.toPlainText()
md.series = utils.xlate(self.leSeries.text())
md.title = utils.xlate(self.leTitle.text())
md.publisher = utils.xlate(self.lePublisher.text())
md.genre = utils.xlate(self.leGenre.text())
md.imprint = utils.xlate(self.leImprint.text())
md.comments = utils.xlate(self.teComments.toPlainText())
md.notes = utils.xlate(self.teNotes.toPlainText())
md.maturity_rating = self.cbMaturityRating.currentText()
md.critical_rating = utils.xlate_float(self.dsbCriticalRating.cleanText())
if md.critical_rating == 0.0:
md.critical_rating = None
md.story_arc = self.leStoryArc.text()
md.scan_info = self.leScanInfo.text()
md.series_group = self.leSeriesGroup.text()
md.alternate_series = self.leAltSeries.text()
md.web_link = self.leWebLink.text()
md.characters = self.teCharacters.toPlainText()
md.teams = self.teTeams.toPlainText()
md.locations = self.teLocations.toPlainText()
md.story_arc = utils.xlate(self.leStoryArc.text())
md.scan_info = utils.xlate(self.leScanInfo.text())
md.series_group = utils.xlate(self.leSeriesGroup.text())
md.alternate_series = utils.xlate(self.leAltSeries.text())
md.web_link = utils.xlate(self.leWebLink.text())
md.characters = utils.xlate(self.teCharacters.toPlainText())
md.teams = utils.xlate(self.teTeams.toPlainText())
md.locations = utils.xlate(self.teLocations.toPlainText())
md.format = self.cbFormat.currentText()
md.country = self.cbCountry.currentText()
md.format = utils.xlate(self.cbFormat.currentText())
md.country = utils.xlate(self.cbCountry.currentText())
md.language = utils.xlate(self.cbLanguage.itemData(self.cbLanguage.currentIndex()))
@ -1017,7 +1022,9 @@ class TaggerWindow(QtWidgets.QMainWindow):
# Only need this check is the source has issue level data.
if autoselect and issue_number == "":
QtWidgets.QMessageBox.information(
self, "Automatic Identify Search", "Can't auto-identify without an issue number (yet!)"
self,
"Automatic Identify Search",
"Can't auto-identify without an issue number. The auto-tag function has the 'If no issue number, assume \"1\"' option if desired.",
)
return
@ -1395,13 +1402,13 @@ class TaggerWindow(QtWidgets.QMainWindow):
# Add the entries to the country combobox
self.cbCountry.addItem("", "")
for f in natsort.humansorted(utils.countries.items(), operator.itemgetter(1)):
for f in natsort.humansorted(utils.countries().items(), operator.itemgetter(1)):
self.cbCountry.addItem(f[1], f[0])
# Add the entries to the language combobox
self.cbLanguage.addItem("", "")
for f in natsort.humansorted(utils.languages.items(), operator.itemgetter(1)):
for f in natsort.humansorted(utils.languages().items(), operator.itemgetter(1)):
self.cbLanguage.addItem(f[1], f[0])
# Add the entries to the manga combobox
@ -1780,7 +1787,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
md = ct_md
else:
notes = (
f"Tagged with ComicTagger {ctversion.version} using info from Comic Vine on"
f"Tagged with ComicTagger {ctversion.version} using info from {self.current_talker().name} on"
f" {datetime.now():%Y-%m-%d %H:%M:%S}. [Issue ID {ct_md.issue_id}]"
)
md.overlay(ct_md.replace(notes=utils.combine_notes(md.notes, notes, "Tagged with ComicTagger")))

View File

@ -10,7 +10,7 @@
<x>0</x>
<y>0</y>
<width>519</width>
<height>440</height>
<height>448</height>
</rect>
</property>
<property name="sizePolicy">
@ -26,84 +26,19 @@
<bool>false</bool>
</property>
<layout class="QGridLayout" name="gridLayout_2">
<item row="0" column="0">
<widget class="QLabel" name="label">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
<item row="4" column="0">
<widget class="QDialogButtonBox" name="buttonBox">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="text">
<string/>
</property>
<property name="wordWrap">
<bool>true</bool>
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
</property>
</widget>
</item>
<item row="1" column="0">
<layout class="QGridLayout" name="gridLayout">
<item row="6" column="0">
<widget class="QCheckBox" name="cbxAutoImprint">
<property name="toolTip">
<string>Checks the publisher against a list of imprints.</string>
</property>
<property name="text">
<string>Auto Imprint</string>
</property>
</widget>
</item>
<item row="9" column="0">
<widget class="QCheckBox" name="cbxSpecifySearchString">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Specify series search string for all selected archives:</string>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QCheckBox" name="cbxIgnoreLeadingDigitsInFilename">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Ignore leading (sequence) numbers in filename</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QCheckBox" name="cbxSaveOnLowConfidence">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Save on low confidence match</string>
</property>
</widget>
</item>
<item row="10" column="0">
<widget class="QLineEdit" name="leSearchString">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
</item>
<item row="11" column="0">
<widget class="QLabel" name="label_3">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
@ -116,7 +51,7 @@
</property>
</widget>
</item>
<item row="8" column="0">
<item row="7" column="0">
<widget class="QCheckBox" name="cbxSplitWords">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
@ -129,19 +64,6 @@
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QCheckBox" name="cbxDontUseYear">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Don't use publication year in identification process</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QCheckBox" name="cbxAssumeIssueOne">
<property name="sizePolicy">
@ -155,7 +77,7 @@
</property>
</widget>
</item>
<item row="7" column="0">
<item row="6" column="0">
<widget class="QCheckBox" name="cbxRemoveMetadata">
<property name="toolTip">
<string>Removes existing metadata before applying retrieved metadata</string>
@ -165,7 +87,66 @@
</property>
</widget>
</item>
<item row="9" column="0">
<widget class="QLineEdit" name="leSearchString">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QCheckBox" name="cbxDontUseYear">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Don't use publication year in identification process</string>
</property>
</widget>
</item>
<item row="5" column="0">
<widget class="QCheckBox" name="cbxAutoImprint">
<property name="toolTip">
<string>Checks the publisher against a list of imprints.</string>
</property>
<property name="text">
<string>Auto Imprint</string>
</property>
</widget>
</item>
<item row="8" column="0">
<widget class="QCheckBox" name="cbxSpecifySearchString">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Specify series search string for all selected archives:</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QCheckBox" name="cbxSaveOnLowConfidence">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Save on low confidence match</string>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QCheckBox" name="cbxRemoveAfterSuccess">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
@ -179,13 +160,19 @@
</widget>
</item>
<item row="3" column="0">
<widget class="QCheckBox" name="cbxWaitForRateLimit">
<widget class="QCheckBox" name="cbxIgnoreLeadingDigitsInFilename">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Wait and retry when Comic Vine rate limit is exceeded (experimental)</string>
<string>Ignore leading (sequence) numbers in filename</string>
</property>
</widget>
</item>
<item row="12" column="0">
<item row="11" column="0">
<widget class="QSpinBox" name="sbNameMatchSearchThresh">
<property name="maximumSize">
<size>
@ -225,13 +212,19 @@
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QDialogButtonBox" name="buttonBox">
<property name="orientation">
<enum>Qt::Horizontal</enum>
<item row="0" column="0">
<widget class="QLabel" name="label">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
<property name="text">
<string/>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>

View File

@ -7,7 +7,7 @@
<x>0</x>
<y>0</y>
<width>872</width>
<height>670</height>
<height>673</height>
</rect>
</property>
<property name="windowTitle">
@ -16,6 +16,13 @@
<layout class="QGridLayout" name="gridLayout_2">
<item row="0" column="0">
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QLineEdit" name="leFilter">
<property name="placeholderText">
<string>Filter</string>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<property name="spacing">

View File

@ -110,6 +110,13 @@
</item>
<item>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QLineEdit" name="leFilter">
<property name="placeholderText">
<string>Filter</string>
</property>
</widget>
</item>
<item>
<widget class="QSplitter" name="splitter">
<property name="orientation">

View File

@ -276,7 +276,7 @@
<item row="2" column="0">
<widget class="QCheckBox" name="cbxClearFormBeforePopulating">
<property name="text">
<string>Clear Form Before Importing Comic Vine data</string>
<string>Clear form before importing comic metadata</string>
</property>
</widget>
</item>

View File

@ -1,13 +1,14 @@
from __future__ import annotations
import argparse
import logging
from functools import partial
from typing import Any, NamedTuple
import settngs
from PyQt5 import QtCore, QtWidgets
from PyQt5 import QtCore, QtGui, QtWidgets
from comictaggerlib.ctsettings import ct_ns
from comictaggerlib.graphics import graphics_path
from comictalker.comictalker import ComicTalker
logger = logging.getLogger(__name__)
@ -18,10 +19,46 @@ class TalkerTab(NamedTuple):
widgets: dict[str, QtWidgets.QWidget]
class PasswordEdit(QtWidgets.QLineEdit):
"""
Password LineEdit with icons to show/hide password entries.
Taken from https://github.com/pythonguis/python-qtwidgets/tree/master/qtwidgets
Based on this example https://kushaldas.in/posts/creating-password-input-widget-in-pyqt.html by Kushal Das.
"""
def __init__(self, show_visibility: bool = True, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self.visibleIcon = QtGui.QIcon(str(graphics_path / "eye.svg"))
self.hiddenIcon = QtGui.QIcon(str(graphics_path / "hidden.svg"))
self.setEchoMode(QtWidgets.QLineEdit.Password)
if show_visibility:
# 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.password_shown = False
def on_toggle_password_Action(self) -> None:
if not self.password_shown:
self.setEchoMode(QtWidgets.QLineEdit.Normal)
self.password_shown = True
self.togglepasswordAction.setIcon(self.hiddenIcon)
self.togglepasswordAction.setToolTip("Hide password")
else:
self.setEchoMode(QtWidgets.QLineEdit.Password)
self.password_shown = False
self.togglepasswordAction.setIcon(self.visibleIcon)
self.togglepasswordAction.setToolTip("Show password")
def generate_api_widgets(
talker_id: str,
sources: dict[str, QtWidgets.QWidget],
config: settngs.Config[settngs.Namespace],
config: settngs.Config[ct_ns],
layout: QtWidgets.QGridLayout,
talkers: dict[str, ComicTalker],
) -> None:
@ -51,7 +88,8 @@ def generate_api_widgets(
if talker_key.file:
# record the current row so we know where to add the button
btn_test_row = layout.rowCount()
le_key = generate_textbox(talker_key, layout)
le_key = generate_password_textbox(talker_key, layout)
# To enable setting and getting
sources["tabs"][talker_id].widgets[f"talker_{talker_id}_{talker_id}_key"] = le_key
@ -61,6 +99,9 @@ def generate_api_widgets(
# 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)
# To enable setting and getting
sources["tabs"][talker_id].widgets[f"talker_{talker_id}_{talker_id}_url"] = le_url
@ -118,7 +159,19 @@ def generate_textbox(option: settngs.Setting, layout: QtWidgets.QGridLayout) ->
return widget
def settings_to_talker_form(sources: dict[str, QtWidgets.QWidget], config: settngs.Config[settngs.Namespace]) -> None:
def generate_password_textbox(option: settngs.Setting, layout: QtWidgets.QGridLayout) -> QtWidgets.QLineEdit:
row = layout.rowCount()
lbl = QtWidgets.QLabel(option.display_name)
lbl.setToolTip(option.help)
layout.addWidget(lbl, row, 0)
widget = PasswordEdit()
widget.setToolTip(option.help)
layout.addWidget(widget, row, 1)
return widget
def settings_to_talker_form(sources: dict[str, QtWidgets.QWidget], 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))
@ -127,7 +180,7 @@ def settings_to_talker_form(sources: dict[str, QtWidgets.QWidget], config: settn
value = getattr(config[0], name)
value_type = type(value)
try:
if value_type is str:
if value_type is str and value:
widget.setText(value)
if value_type is int or value_type is float:
widget.setValue(value)
@ -137,7 +190,7 @@ def settings_to_talker_form(sources: dict[str, QtWidgets.QWidget], config: settn
logger.debug("Failed to set value of %s", name)
def form_settings_to_config(sources: dict[str, QtWidgets.QWidget], config: settngs.Config[settngs.Namespace]) -> None:
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()
@ -156,7 +209,7 @@ def form_settings_to_config(sources: dict[str, QtWidgets.QWidget], config: settn
def generate_source_option_tabs(
comic_talker_tab: QtWidgets.QWidget,
config: settngs.Config[settngs.Namespace],
config: settngs.Config[ct_ns],
talkers: dict[str, ComicTalker],
) -> dict[str, QtWidgets.QWidget]:
"""
@ -202,22 +255,17 @@ def generate_source_option_tabs(
if option.dest in (f"{talker_id}_url", f"{talker_id}_key"):
continue
current_widget = None
if option.action is not None and (
option.action is argparse.BooleanOptionalAction
or option.type is bool
or option.action == "store_true"
or option.action == "store_false"
):
if option._guess_type() is bool:
current_widget = generate_checkbox(option, layout_grid)
sources["tabs"][tab_name].widgets[option.internal_name] = current_widget
elif option.type is int:
elif option._guess_type() is int:
current_widget = generate_spinbox(option, layout_grid)
sources["tabs"][tab_name].widgets[option.internal_name] = current_widget
elif option.type is float:
elif option._guess_type() is float:
current_widget = generate_doublespinbox(option, layout_grid)
sources["tabs"][tab_name].widgets[option.internal_name] = current_widget
# option.type of None should be string
elif (option.type is None and option.action is None) or option.type is str:
elif option._guess_type() is str:
current_widget = generate_textbox(option, layout_grid)
sources["tabs"][tab_name].widgets[option.internal_name] = current_widget
else:

View File

@ -40,9 +40,9 @@ class VersionChecker:
headers={"user-agent": "comictagger/" + ctversion.version},
).json()
except Exception:
return ("", "")
return "", ""
new_version = release["tag_name"]
if new_version is None or new_version == "":
return ("", "")
return (new_version.strip(), release["name"])
return "", ""
return new_version.strip(), release["name"]

View File

@ -1,8 +1,8 @@
"""A python class to manage caching of data from Comic Vine"""
"""A python class to manage caching of metadata from comic sources"""
#
# Copyright 2012-2014 ComicTagger Authors
#
# Licensed under the Apache License, Version 2.0 (the "License;
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
@ -88,10 +88,13 @@ class ComicCacher:
+ "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,"
+ "timestamp DATE DEFAULT (datetime('now','localtime')), "
+ "source_name TEXT NOT NULL,"
+ "PRIMARY KEY (id, source_name))"
@ -117,6 +120,14 @@ class ComicCacher:
+ "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_name))"
)
@ -147,9 +158,12 @@ class ComicCacher:
"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),
}
@ -177,10 +191,13 @@ class ComicCacher:
name=record[5],
publisher=record[6],
count_of_issues=record[7],
start_year=record[8],
image_url=record[9],
aliases=record[10].strip().splitlines(),
description=record[11],
count_of_volumes=record[8],
start_year=record[9],
image_url=record[10],
aliases=record[11].strip().splitlines(),
description=record[12],
genres=record[13].strip().splitlines(),
format=record[14],
)
results.append(result)
@ -201,9 +218,12 @@ class ComicCacher:
"name": series_record.name,
"publisher": series_record.publisher,
"count_of_issues": series_record.count_of_issues,
"count_of_volumes": series_record.count_of_volumes,
"start_year": series_record.start_year,
"image_url": series_record.image_url,
"description": series_record.description,
"genres": "\n".join(series_record.genres),
"format": series_record.format,
"timestamp": timestamp,
"aliases": "\n".join(series_record.aliases),
}
@ -226,6 +246,7 @@ class ComicCacher:
"source_name": source_name,
"name": issue.name,
"issue_number": issue.issue_number,
"volume": issue.volume,
"site_detail_url": issue.site_detail_url,
"cover_date": issue.cover_date,
"image_url": issue.image_url,
@ -237,6 +258,13 @@ class ComicCacher:
"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([dataclasses.asdict(x) for x in issue.credits]),
"complete": issue.complete,
}
@ -269,10 +297,13 @@ class ComicCacher:
name=row[1],
publisher=row[2],
count_of_issues=row[3],
start_year=row[4],
image_url=row[5],
aliases=row[6].strip().splitlines(),
description=row[7],
count_of_volumes=row[4],
start_year=row[5],
image_url=row[6],
aliases=row[7].strip().splitlines(),
description=row[8],
genres=row[9].strip().splitlines(),
format=row[10],
)
return result
@ -283,11 +314,14 @@ class ComicCacher:
id=series_id,
name="",
description="",
genres=[],
image_url="",
publisher="",
start_year=None,
aliases=[],
count_of_issues=None,
count_of_volumes=None,
format=None,
)
con = lite.connect(self.db_file)
with con:
@ -302,40 +336,42 @@ class ComicCacher:
# fetch
results: list[ComicIssue] = []
cur.execute(
(
"SELECT source_name,id,name,issue_number,site_detail_url,cover_date,image_url,thumb_url,description,aliases,alt_image_urls,characters,locations,credits,teams,story_arcs,complete"
" FROM Issues WHERE series_id=? AND source_name=?"
),
[series_id, source_name],
)
cur.execute("SELECT * FROM Issues WHERE series_id=? AND source_name=?", [series_id, source_name])
rows = cur.fetchall()
# now process the results
for row in rows:
credits = []
try:
for credit in json.loads(row[13]):
for credit in json.loads(row[15]):
credits.append(Credit(**credit))
except Exception:
logger.exception("credits failed")
record = ComicIssue(
id=row[1],
id=row[0],
name=row[2],
issue_number=row[3],
site_detail_url=row[4],
cover_date=row[5],
image_url=row[6],
volume=row[25],
site_detail_url=row[7],
cover_date=row[6],
image_url=row[4],
description=row[8],
series=series,
aliases=row[9].strip().splitlines(),
alt_image_urls=row[10].strip().splitlines(),
characters=row[11].strip().splitlines(),
locations=row[12].strip().splitlines(),
aliases=row[11].strip().splitlines(),
alt_image_urls=row[12].strip().splitlines(),
characters=row[13].strip().splitlines(),
locations=row[14].strip().splitlines(),
credits=credits,
teams=row[14].strip().splitlines(),
story_arcs=row[15].strip().splitlines(),
complete=bool(row[16]),
teams=row[16].strip().splitlines(),
story_arcs=row[17].strip().splitlines(),
genres=row[18].strip().splitlines(),
tags=row[19].strip().splitlines(),
critical_rating=row[20],
manga=row[21],
maturity_rating=row[22],
language=row[23],
country=row[24],
complete=bool(row[26]),
)
results.append(record)
@ -353,54 +389,59 @@ class ComicCacher:
a_week_ago = datetime.datetime.today() - datetime.timedelta(days=7)
cur.execute("DELETE FROM Issues WHERE timestamp < ?", [str(a_week_ago)])
cur.execute(
(
"SELECT source_name,id,name,issue_number,site_detail_url,cover_date,image_url,description,aliases,series_id,alt_image_urls,characters,locations,credits,teams,story_arcs,complete"
" FROM Issues WHERE id=? AND source_name=?"
),
[issue_id, source_name],
)
cur.execute("SELECT * FROM Issues WHERE id=? AND source_name=?", [issue_id, source_name])
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[10], source_name, False) or ComicSeries(
id=row[10],
series = self.get_series_info(row[1], source_name, False) or ComicSeries(
id=row[1],
name="",
description="",
genres=[],
image_url="",
publisher="",
start_year=None,
aliases=[],
count_of_issues=None,
count_of_volumes=None,
format=None,
)
# now process the results
credits = []
try:
for credit in json.loads(row[13]):
for credit in json.loads(row[15]):
credits.append(Credit(**credit))
except Exception:
logger.exception("credits failed")
record = ComicIssue(
id=row[1],
id=row[0],
name=row[2],
issue_number=row[3],
site_detail_url=row[4],
cover_date=row[5],
image_url=row[6],
description=row[7],
volume=row[25],
site_detail_url=row[7],
cover_date=row[6],
image_url=row[4],
description=row[8],
series=series,
aliases=row[8].strip().splitlines(),
alt_image_urls=row[10].strip().splitlines(),
characters=row[11].strip().splitlines(),
locations=row[12].strip().splitlines(),
aliases=row[11].strip().splitlines(),
alt_image_urls=row[12].strip().splitlines(),
characters=row[13].strip().splitlines(),
locations=row[14].strip().splitlines(),
credits=credits,
teams=row[14].strip().splitlines(),
story_arcs=row[15].strip().splitlines(),
complete=bool(row[16]),
teams=row[16].strip().splitlines(),
story_arcs=row[17].strip().splitlines(),
genres=row[18].strip().splitlines(),
tags=row[19].strip().splitlines(),
critical_rating=row[20],
manga=row[21],
maturity_rating=row[22],
language=row[23],
country=row[24],
complete=bool(row[26]),
)
return record

View File

@ -130,17 +130,17 @@ class ComicTalker:
settings is a dictionary of settings defined in register_settings.
It is only guaranteed that the settings defined in register_settings will be present.
"""
if settings[f"{self.id}_key"]:
if settings.get(f"{self.id}_key") is not None:
self.api_key = settings[f"{self.id}_key"]
if settings[f"{self.id}_url"]:
if settings.get(f"{self.id}_url") is not None:
self.api_url = fix_url(settings[f"{self.id}_url"])
settings[f"{self.id}_url"] = self.api_url
if self.api_key == "":
if self.api_key in ("", self.default_api_key):
self.api_key = self.default_api_key
if self.api_url == "":
settings[f"{self.id}_key"] = None
if self.api_url in ("", self.default_api_url):
self.api_url = self.default_api_url
settings[f"{self.id}_url"] = None
return settings
def check_api_key(self, url: str, key: str) -> tuple[str, bool]:

View File

@ -14,12 +14,15 @@ class Credit:
class ComicSeries:
aliases: list[str]
count_of_issues: int | None
count_of_volumes: int | None
description: str
id: str
image_url: str
name: str
publisher: str
start_year: int | None
genres: list[str]
format: str | None
def copy(self) -> ComicSeries:
return copy.deepcopy(self)
@ -33,7 +36,15 @@ class ComicIssue:
id: str
image_url: str
issue_number: str
volume: str | None
critical_rating: float
maturity_rating: str
manga: str
genres: list[str]
tags: list[str]
name: str
language: str
country: str
site_detail_url: str
series: ComicSeries
alt_image_urls: list[str]

View File

@ -18,8 +18,6 @@ import posixpath
import re
from urllib.parse import urlsplit
from bs4 import BeautifulSoup
from comicapi import utils
from comicapi.genericmetadata import GenericMetadata
from comicapi.issuestring import IssueString
@ -29,9 +27,14 @@ logger = logging.getLogger(__name__)
def fix_url(url: str) -> str:
if not url:
return ""
tmp_url = urlsplit(url)
new_path = posixpath.normpath(tmp_url.path)
if new_path in (".", "/"):
new_path = ""
# joinurl only works properly if there is a trailing slash
tmp_url = tmp_url._replace(path=posixpath.normpath(tmp_url.path) + "/")
tmp_url = tmp_url._replace(path=new_path + "/")
return tmp_url.geturl()
@ -43,7 +46,16 @@ def map_comic_issue_to_metadata(
metadata.is_empty = False
metadata.series = utils.xlate(issue_results.series.name)
metadata.issue = IssueString(issue_results.issue_number).as_string()
metadata.issue = utils.xlate(IssueString(issue_results.issue_number).as_string())
# Rely on comic talker to validate this number
metadata.issue_count = utils.xlate_int(issue_results.series.count_of_issues)
if issue_results.series.format:
metadata.format = issue_results.series.format
metadata.volume = utils.xlate_int(issue_results.volume)
metadata.volume_count = utils.xlate_int(issue_results.series.count_of_volumes)
if issue_results.name:
metadata.title = utils.xlate(issue_results.name)
@ -81,6 +93,26 @@ def map_comic_issue_to_metadata(
metadata.locations = ", ".join(issue_results.locations)
if issue_results.story_arcs:
metadata.story_arc = ", ".join(issue_results.story_arcs)
if issue_results.genres:
metadata.genre = ", ".join(issue_results.genres)
if issue_results.tags:
metadata.tags = set(issue_results.tags)
if issue_results.manga:
metadata.manga = issue_results.manga
if issue_results.critical_rating:
metadata.critical_rating = utils.xlate_float(issue_results.critical_rating)
if issue_results.maturity_rating:
metadata.maturity_rating = issue_results.maturity_rating
if issue_results.language:
metadata.language = issue_results.language
if issue_results.country:
metadata.country = issue_results.country
return metadata
@ -103,6 +135,8 @@ def cleanup_html(string: str, remove_html_tables: bool = False) -> str:
"""Cleans HTML code from any text. Will remove any HTML tables with remove_html_tables"""
if string is None:
return ""
from bs4 import BeautifulSoup
# find any tables
soup = BeautifulSoup(string, "html.parser")
tables = soup.findAll("table")

View File

@ -26,6 +26,7 @@ from urllib.parse import urljoin
import requests
import settngs
from pyrate_limiter import Limiter, RequestRate
from typing_extensions import Required, TypedDict
import comictalker.talker_utils as talker_utils
@ -150,7 +151,10 @@ class CVResult(TypedDict, Generic[T]):
version: str
CV_STATUS_RATELIMIT = 107
# https://comicvine.gamespot.com/forums/api-developers-2334/api-rate-limiting-1746419/
# "Space out your requests so AT LEAST one second passes between each and you can make requests all day."
custom_limiter = Limiter(RequestRate(10, 10))
default_limiter = Limiter(RequestRate(1, 5))
class ComicVineTalker(ComicTalker):
@ -162,15 +166,12 @@ class ComicVineTalker(ComicTalker):
def __init__(self, version: str, cache_folder: pathlib.Path):
super().__init__(version, cache_folder)
self.limiter = default_limiter
# Default settings
self.default_api_url = self.api_url = f"{self.website}/api/"
self.default_api_key = self.api_key = "27431e6787042105bd3e47e169a624521f89f3a4"
self.remove_html_tables: bool = False
self.use_series_start_as_volume: bool = False
self.wait_on_ratelimit: bool = False
# NOTE: This was hardcoded before which is why it isn't in settings
self.wait_on_ratelimit_time: int = 20
def register_settings(self, parser: settngs.Manager) -> None:
parser.add_setting(
@ -180,13 +181,6 @@ class ComicVineTalker(ComicTalker):
display_name="Use series start as volume",
help="Use the series start year as the volume number",
)
parser.add_setting(
"--cv-wait-on-ratelimit",
default=False,
action=argparse.BooleanOptionalAction,
display_name="Wait on ratelimit",
help="Wait when the rate limit is hit",
)
parser.add_setting(
"--cv-remove-html-tables",
default=False,
@ -194,16 +188,16 @@ class ComicVineTalker(ComicTalker):
display_name="Remove HTML tables",
help="Removes html tables instead of converting them to text",
)
# The empty string being the default allows this setting to be unset, allowing the default to change
# The default needs to be unset or None.
# This allows this setting to be unset with the empty string, allowing the default to change
parser.add_setting(
f"--{self.id}-key",
default="",
display_name="API Key",
help=f"Use the given Comic Vine API Key. (default: {self.default_api_key})",
)
parser.add_setting(
f"--{self.id}-url",
default="",
display_name="API URL",
help=f"Use the given Comic Vine URL. (default: {self.default_api_url})",
)
@ -212,8 +206,14 @@ class ComicVineTalker(ComicTalker):
settings = super().parse_settings(settings)
self.use_series_start_as_volume = settings["cv_use_series_start_as_volume"]
self.wait_on_ratelimit = settings["cv_wait_on_ratelimit"]
self.remove_html_tables = settings["cv_remove_html_tables"]
# Set a different limit if using the default API key
if self.api_key == self.default_api_key:
self.limiter = default_limiter
else:
self.limiter = custom_limiter
return settings
def check_api_key(self, url: str, key: str) -> tuple[str, bool]:
@ -430,40 +430,24 @@ class ComicVineTalker(ComicTalker):
def _get_cv_content(self, url: str, params: dict[str, Any]) -> CVResult:
"""
Get the content from the CV server. If we're in "wait mode" and status code is a rate limit error
sleep for a bit and retry.
Get the content from the CV server.
"""
total_time_waited = 0
limit_wait_time = 1
counter = 0
wait_times = [1, 2, 3, 4]
while True:
with self.limiter.ratelimit("cv", delay=True):
cv_response: CVResult = self._get_url_content(url, params)
if self.wait_on_ratelimit and cv_response["status_code"] == CV_STATUS_RATELIMIT:
logger.info(f"Rate limit encountered. Waiting for {limit_wait_time} minutes\n")
time.sleep(limit_wait_time * 60)
total_time_waited += limit_wait_time
limit_wait_time = wait_times[counter]
if counter < 3:
counter += 1
# don't wait much more than 20 minutes
if total_time_waited < self.wait_on_ratelimit_time:
continue
if cv_response["status_code"] != 1:
logger.debug(
f"{self.name} query failed with error #{cv_response['status_code']}: [{cv_response['error']}]."
)
raise TalkerNetworkError(self.name, 0, f"{cv_response['status_code']}: {cv_response['error']}")
# it's all good
break
return cv_response
return cv_response
def _get_url_content(self, url: str, params: dict[str, Any]) -> Any:
# connect to server:
# if there is a 500 error, try a few more times before giving up
# any other error, just bail
for tries in range(3):
limit_counter = 0
tries = 0
while tries < 4:
try:
resp = requests.get(url, params=params, headers={"user-agent": "comictagger/" + self.version})
if resp.status_code == 200:
@ -472,6 +456,19 @@ class ComicVineTalker(ComicTalker):
logger.debug(f"Try #{tries + 1}: ")
time.sleep(1)
logger.debug(str(resp.status_code))
tries += 1
if resp.status_code == requests.status_codes.codes.TOO_MANY_REQUESTS:
logger.info(f"{self.name} rate limit encountered. Waiting for 10 seconds\n")
time.sleep(10)
limit_counter += 1
if limit_counter > 3:
# Tried 3 times, inform user to check CV website.
logger.error(f"{self.name} rate limit error. Exceeded 3 retires.")
raise TalkerNetworkError(
self.name,
3,
"Rate Limit Error: Check your current API usage limit at https://comicvine.gamespot.com/api/",
)
else:
break
@ -509,12 +506,15 @@ class ComicVineTalker(ComicTalker):
ComicSeries(
aliases=aliases.splitlines(),
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,
)
)
@ -523,7 +523,7 @@ class ComicVineTalker(ComicTalker):
def _format_issue_results(self, issue_results: list[CVIssue], complete: bool = False) -> list[ComicIssue]:
formatted_results = []
for record in issue_results:
# Extract image super and thumb to name only
# Extract image super
if record.get("image") is None:
image_url = ""
else:
@ -568,6 +568,7 @@ class ComicVineTalker(ComicTalker):
id=str(record["id"]),
image_url=image_url,
issue_number=record["issue_number"],
volume=None,
name=record["name"],
site_detail_url=record.get("site_detail_url", ""),
series=series, # CV uses volume to mean series
@ -576,6 +577,13 @@ class ComicVineTalker(ComicTalker):
locations=location_list,
teams=teams_list,
story_arcs=story_list,
critical_rating=0,
maturity_rating="",
manga="",
language="",
country="",
genres=[],
tags=[],
credits=persons_list,
complete=complete,
)

View File

@ -40,11 +40,12 @@ install_requires =
pathvalidate
pillow>=9.1.0,<10
pycountry
pyrate-limiter
rapidfuzz>=2.12.0
requests==2.*
settngs==0.6.3
settngs==0.7.1
text2digits
typing-extensions
typing-extensions>=4.3.0
wordninja
python_requires = >=3.9
@ -280,3 +281,4 @@ extend-ignore = E203, E501, A003
extend-exclude = venv, scripts, build, dist, comictaggerlib/ctversion.py
per-file-ignores =
comictaggerlib/cli.py: T20
build-tools/generate_settngs.py: T20

View File

@ -7,6 +7,7 @@ from comicapi import utils
search_results = [
comictalker.resulttypes.ComicSeries(
count_of_issues=1,
count_of_volumes=1,
description="this is a description",
id="1",
image_url="https://test.org/image/1",
@ -14,9 +15,12 @@ search_results = [
publisher="test",
start_year=0,
aliases=[],
genres=[],
format=None,
),
comictalker.resulttypes.ComicSeries(
count_of_issues=1,
count_of_volumes=1,
description="this is a description",
id="2",
image_url="https://test.org/image/2",
@ -24,6 +28,8 @@ search_results = [
publisher="test",
start_year=0,
aliases=[],
genres=[],
format=None,
),
]
@ -59,6 +65,14 @@ metadata = [
comicapi.genericmetadata.GenericMetadata(series="", issue="2", title="never"),
comicapi.genericmetadata.md_test.replace(series=None, issue="2", title="never"),
),
(
comicapi.genericmetadata.GenericMetadata(series="", issue="", title="never"),
comicapi.genericmetadata.md_test.replace(series=None, issue=None, title="never"),
),
(
comicapi.genericmetadata.GenericMetadata(series="", issue=None, title="never"),
comicapi.genericmetadata.md_test.replace(series=None, issue="1", title="never"),
),
(
comicapi.genericmetadata.GenericMetadata(),
comicapi.genericmetadata.md_test.copy(),

View File

@ -165,6 +165,7 @@ comic_issue_result = ComicIssue(
id=str(cv_issue_result["results"]["id"]),
image_url=cv_issue_result["results"]["image"]["super_url"],
issue_number=cv_issue_result["results"]["issue_number"],
volume=None,
name=cv_issue_result["results"]["name"],
site_detail_url=cv_issue_result["results"]["site_detail_url"],
series=ComicSeries(
@ -172,10 +173,13 @@ comic_issue_result = ComicIssue(
name=cv_issue_result["results"]["volume"]["name"],
aliases=[],
count_of_issues=cv_volume_result["results"]["count_of_issues"],
count_of_volumes=None,
description=cv_volume_result["results"]["description"],
image_url=cv_volume_result["results"]["image"]["super_url"],
publisher=cv_volume_result["results"]["publisher"]["name"],
start_year=int(cv_volume_result["results"]["start_year"]),
genres=[],
format=None,
),
characters=[],
alt_image_urls=[],
@ -183,6 +187,13 @@ comic_issue_result = ComicIssue(
credits=[],
locations=[],
story_arcs=[],
critical_rating=0,
maturity_rating="",
manga="",
language="",
country="",
genres=[],
tags=[],
teams=[],
)
date = utils.parse_date_str(cv_issue_result["results"]["cover_date"])
@ -198,7 +209,7 @@ cv_md = comicapi.genericmetadata.GenericMetadata(
month=date[1],
year=date[2],
day=date[0],
issue_count=None,
issue_count=6,
volume=None,
genre=None,
language=None,

View File

@ -6,6 +6,7 @@ import shutil
import pytest
from importlib_metadata import entry_points
import comicapi.archivers.rar
import comicapi.comicarchive
import comicapi.genericmetadata
from testing.filenames import datadir

View File

@ -9,7 +9,7 @@ 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()
assert config.runtime_config.user_cache_dir.exists()
def test_search_results(comic_cache):

View File

@ -9,7 +9,6 @@ from typing import Any
import pytest
import requests
import settngs
from PIL import Image
import comicapi.comicarchive
@ -128,7 +127,7 @@ def mock_version(monkeypatch):
monkeypatch.setattr(comictaggerlib.ctversion, "__version__", version)
monkeypatch.setattr(comictaggerlib.ctversion, "version_tuple", version_tuple)
monkeypatch.setattr(comictaggerlib.ctversion, "__version_tuple__", version_tuple)
yield (version, version_tuple)
yield version, version_tuple
@pytest.fixture
@ -154,11 +153,13 @@ def seed_all_publishers(monkeypatch):
@pytest.fixture
def config(settings_manager, tmp_path):
comictaggerlib.ctsettings.register_commandline_settings(settings_manager)
comictaggerlib.ctsettings.register_file_settings(settings_manager)
defaults = settings_manager.get_namespace(settings_manager.defaults())
defaults[0].runtime_config = comictaggerlib.ctsettings.ComicTaggerPaths(tmp_path / "config")
def config(tmp_path):
from comictaggerlib.main import App
app = App()
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)
@ -167,12 +168,6 @@ def config(settings_manager, tmp_path):
yield defaults
@pytest.fixture
def settings_manager():
manager = settngs.Manager()
yield manager
@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])

View File

@ -5,6 +5,7 @@ import os
import pytest
import comicapi.utils
import comictalker.talker_utils
def test_os_sorted():
@ -200,3 +201,20 @@ titles_2 = [
@pytest.mark.parametrize("value, result", titles_2)
def test_sanitize_title(value, result):
assert comicapi.utils.sanitize_title(value) == result.casefold()
urls = [
("", ""),
("http://test.test", "http://test.test/"),
("http://test.test/", "http://test.test/"),
("http://test.test/..", "http://test.test/"),
("http://test.test/../hello", "http://test.test/hello/"),
("http://test.test/../hello/", "http://test.test/hello/"),
("http://test.test/../hello/..", "http://test.test/"),
("http://test.test/../hello/../", "http://test.test/"),
]
@pytest.mark.parametrize("value, result", urls)
def test_fix_url(value, result):
assert comictalker.talker_utils.fix_url(value) == result