Compare commits

...

37 Commits

Author SHA1 Message Date
Timmy Welch
f45231c662 Fix enabling original hash widgets 2025-03-27 19:12:51 -07:00
Timmy Welch
89d82be504 Use proper function names for BS4 2025-03-27 19:01:46 -07:00
Timmy Welch
2eb889a596 Build the release with python 3.12 2025-03-27 18:56:57 -07:00
Timmy Welch
47742ac8ee Download appimage for the current platform 2025-03-27 18:56:38 -07:00
Timmy Welch
08efc75782 Switch to PyQt6 2025-03-27 18:56:00 -07:00
Timmy Welch
25eabaf841 Fix windows being an inferior OS 2025-03-18 21:50:00 -07:00
Timmy Welch
d6d7e0ec65 Add macOS arm64 build to package.yaml 2025-03-18 21:28:50 -07:00
Timmy Welch
3b5e9d8f95 Log a warning the first time we can't find rar for writing 2025-03-18 21:25:04 -07:00
Timmy Welch
3dad7c18f8 Fix removing disabled tags 2025-03-18 21:24:08 -07:00
Timmy Welch
ea945a6b2a Don't allow cr tags to be disabled if it's the only tags available 2025-03-18 19:57:06 -07:00
Timmy Welch
575d36b67f Update typing 2025-03-18 19:55:28 -07:00
Timmy Welch
6a9d4bf648 Update sys.path handling 2025-03-16 14:06:49 -07:00
Timmy Welch
719eedb8b5 Fix #739 2025-03-16 13:52:45 -07:00
Timmy Welch
36f5c72a65
Update issue templates 2025-03-02 15:20:46 -08:00
Timmy Welch
60a2c6168b Fix uploading multiple artifacts 2025-03-02 15:01:20 -08:00
Timmy Welch
f008763361 Add macos-14 for Apple Silicon binaries. Thanks to @pa-0 for testing 2025-03-02 14:35:17 -08:00
Timmy Welch
400092dd84 Notify user when no tags are enabled 2025-03-02 13:34:23 -08:00
Timmy Welch
c5c59f2c76 Merge branch 'original_hash' into develop 2025-03-02 12:46:56 -08:00
Timmy Welch
c8888cdbad Mark the checksum with the "sum:" prefix in the ScanInforamtion field 2025-03-02 12:44:02 -08:00
Timmy Welch
5b204501f3 Update pre-commit 2025-03-02 12:36:23 -08:00
Timmy Welch
5d96bdfda5 Allow printing combined CLI tags 2025-03-02 12:32:40 -08:00
Timmy Welch
803768b33a Allow recording the original hash 2025-03-02 12:32:40 -08:00
Timmy Welch
8837fea957 Merge branch 'mizaki/image_urls_hashes' into develop 2025-02-26 21:28:39 -08:00
Mizaki
085b599bc4 Parametrise cover match test and add ImageHash data 2025-02-23 18:11:40 -08:00
Mizaki
d2499f6bae Add ImageHash support for alternate_urls 2025-02-23 18:11:40 -08:00
Mizaki
c3f5badc7d Use source hashes for cover matching 2025-02-11 01:03:12 +00:00
github-actions[bot]
7e22b4cc22 Update AUTHORS 2025-02-09 01:05:20 +00:00
github-actions[bot]
f9a39aa183
docs(contributor): contrib-readme-action has updated readme 2025-02-09 01:05:17 +00:00
Timmy Welch
cadac0a79e Merge branch 'mizaki/auto_summary_attrib' into develop 2025-02-08 17:03:10 -08:00
David Bugl
7589dca948 Update readme with winget info 2025-02-08 16:58:59 -08:00
Timmy Welch
ea37f96abd Merge branch 'kcgthb/online_results_info' into develop 2025-02-08 15:43:18 -08:00
Mizaki
8847518818 Add source info to auto-tag summary window 2025-02-08 22:40:39 +00:00
Kilian Cavalotti
fbaec93d7d
Update comictaggerlib/cli.py
Make sure `results` exists before checking for `online_results`

Co-authored-by: Timmy Welch <timmy@narnian.us>
2025-02-02 11:38:51 -08:00
Timmy Welch
5ee467465a Fix a cache miss when retrieving multiple issues 2025-01-30 01:39:26 -08:00
kcgthb
7480e28eac only display metadata source info if results were found, to avoid confusion 2025-01-27 17:48:19 -08:00
Timmy Welch
7998944a71 Import pillow plugins 2025-01-21 19:23:14 -08:00
Timmy Welch
280606ae11 Remove dependency on Pillow <10 2025-01-21 19:16:01 -08:00
65 changed files with 879 additions and 305 deletions

37
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,37 @@
---
name: Bug report
about: Report a bug
title: ''
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Attach logs**
`%LOCALAPPDATA%\ComicTagger\logs` on windows
`~/Library/Logs/ComicTagger` on macOS
`~/.cache/ComicTagger/log` on Linux
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. Fedora]
- Version [e.g. 1.6.0b2]
- Where did you install ComicTagger from? [e.g. releases page]
**Additional context**
Add any other context about the problem here.

View File

@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: feature-request
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@ -47,7 +47,7 @@ jobs:
strategy:
matrix:
python-version: [3.9]
os: [ubuntu-22.04, macos-13, windows-latest]
os: [ubuntu-22.04, macos-13, macos-14, windows-latest]
steps:
- uses: actions/checkout@v4
@ -83,7 +83,7 @@ jobs:
- name: Archive production artifacts
uses: actions/upload-artifact@v4
with:
name: "${{ format('ComicTagger-{0}', runner.os) }}"
name: "${{ format('ComicTagger-{0}', matrix.os) }}"
path: |
dist/*.whl
dist/binary/*.zip

View File

@ -15,8 +15,8 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
python-version: [3.9]
os: [ubuntu-22.04, macos-13, windows-latest]
python-version: [3.12]
os: [ubuntu-22.04, macos-13, macos-14, windows-latest]
steps:
- uses: actions/checkout@v4

View File

@ -14,7 +14,7 @@ repos:
hooks:
- id: setup-cfg-fmt
- repo: https://github.com/asottile/pyupgrade
rev: v3.18.0
rev: v3.19.1
hooks:
- id: pyupgrade
args: [--py39-plus]
@ -24,7 +24,7 @@ repos:
- id: autoflake
args: [-i, --remove-all-unused-imports, --ignore-init-module-imports]
- repo: https://github.com/PyCQA/isort
rev: 5.13.2
rev: 6.0.1
hooks:
- id: isort
args: [--af,--add-import, 'from __future__ import annotations']
@ -33,14 +33,14 @@ repos:
hooks:
- id: black
- repo: https://github.com/PyCQA/flake8
rev: 7.1.1
rev: 7.1.2
hooks:
- id: flake8
additional_dependencies: [flake8-encodings, flake8-builtins, flake8-print, flake8-no-nested-comprehensions]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.11.2
rev: v1.15.0
hooks:
- id: mypy
additional_dependencies: [types-setuptools, types-requests, settngs>=0.10.4]
additional_dependencies: [types-setuptools, types-requests, settngs>=0.10.4, pillow>=9.1.0]
ci:
skip: [mypy]

View File

@ -16,3 +16,6 @@ github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Ben Longman <deck@steamdeck.lan>
Sven Hesse <drmccoy@drmccoy.de>
pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
kcgthb <kcgthb@users.noreply.github.com>
Kilian Cavalotti <kcgthb@users.noreply.github.com>
David Bugl <david.bugl@gmx.at>

View File

@ -41,7 +41,7 @@ Please open a [GitHub Pull Request](https://github.com/comictagger/comictagger/p
Currently only python 3.9 is supported however 3.10 will probably work if you try it
Those on linux should install `Pillow` from the system package manager if possible and if the GUI `pyqt5` should be installed from the system package manager
Those on linux should install `Pillow` from the system package manager if possible and if the GUI `PyQt6` should be installed from the system package manager
Those on macOS will need to ensure that you are using python3 in x86 mode either by installing an x86 only version of python or using the universal installer and using `python3-intel64` instead of `python3`

View File

@ -3,6 +3,7 @@
[![PyPI](https://img.shields.io/pypi/v/comictagger)](https://pypi.org/project/comictagger/)
[![PyPI - Downloads](https://img.shields.io/pypi/dm/comictagger)](https://pypistats.org/packages/comictagger)
[![Chocolatey package](https://img.shields.io/chocolatey/dt/comictagger?color=blue&label=chocolatey)](https://community.chocolatey.org/packages/comictagger)
[![WinGet](https://img.shields.io/winget/v/ComicTagger.ComicTagger)](https://github.com/microsoft/winget-pkgs/tree/master/manifests/c/ComicTagger/ComicTagger)
[![PyPI - License](https://img.shields.io/pypi/l/comictagger)](https://opensource.org/licenses/Apache-2.0)
[![GitHub Discussions](https://img.shields.io/github/discussions/comictagger/comictagger)](https://github.com/comictagger/comictagger/discussions)
@ -62,6 +63,12 @@ A [Chocolatey package](https://community.chocolatey.org/packages/comictagger), m
```powershell
choco install comictagger
```
### WinGet installation (Windows only)
A [WinGet package](https://github.com/microsoft/winget-pkgs/tree/master/manifests/c/ComicTagger/ComicTagger), maintained by @Sn1cket, is provided, you can install it with:
```powershell
winget install ComicTagger.ComicTagger
```
### From source
1. Ensure you have python 3.9 installed
@ -125,10 +132,10 @@ choco install comictagger
</a>
</td>
<td align="center">
<a href="https://github.com/AlbanSeurat">
<img src="https://avatars.githubusercontent.com/u/500180?v=4" width="100;" alt="AlbanSeurat"/>
<a href="https://github.com/kcgthb">
<img src="https://avatars.githubusercontent.com/u/186807?v=4" width="100;" alt="kcgthb"/>
<br />
<sub><b>AlbanSeurat</b></sub>
<sub><b>kcgthb</b></sub>
</a>
</td>
<td align="center">
@ -138,13 +145,28 @@ choco install comictagger
<sub><b>rhaussmann</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/AlbanSeurat">
<img src="https://avatars.githubusercontent.com/u/500180?v=4" width="100;" alt="AlbanSeurat"/>
<br />
<sub><b>AlbanSeurat</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/Sn1cket">
<img src="https://avatars.githubusercontent.com/u/32904645?v=4" width="100;" alt="Sn1cket"/>
<br />
<sub><b>Sn1cket</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/jpcranford">
<img src="https://avatars.githubusercontent.com/u/21347202?v=4" width="100;" alt="jpcranford"/>
<br />
<sub><b>jpcranford</b></sub>
</a>
</td>
</td></tr>
<tr>
<td align="center">
<a href="https://github.com/PawlakMarek">
<img src="https://avatars.githubusercontent.com/u/26022173?v=4" width="100;" alt="PawlakMarek"/>
@ -158,8 +180,7 @@ choco install comictagger
<br />
<sub><b>DrMcCoy</b></sub>
</a>
</td></tr>
<tr>
</td>
<td align="center">
<a href="https://github.com/Xav83">
<img src="https://avatars.githubusercontent.com/u/6787157?v=4" width="100;" alt="Xav83"/>

View File

@ -1,5 +1,6 @@
from __future__ import annotations
import os
import pathlib
import settngs
@ -15,7 +16,8 @@ def generate() -> str:
imports2, types2 = settngs.generate_ns(app.manager.definitions)
i = imports.splitlines()
i.extend(set(imports2.splitlines()) - set(i))
return "\n\n".join(("\n".join(i), types2, types))
os.linesep
return (os.linesep * 2).join((os.linesep.join(i), types2, types))
if __name__ == "__main__":

View File

@ -3,14 +3,16 @@ from __future__ import annotations
import argparse
import os
import pathlib
import platform
try:
import niquests as requests
except ImportError:
import requests
arch = platform.machine()
parser = argparse.ArgumentParser()
parser.add_argument("APPIMAGETOOL", default="build/appimagetool-x86_64.AppImage", type=pathlib.Path, nargs="?")
parser.add_argument("APPIMAGETOOL", default=f"build/appimagetool-{arch}.AppImage", type=pathlib.Path, nargs="?")
opts = parser.parse_args()
opts.APPIMAGETOOL = opts.APPIMAGETOOL.absolute()
@ -27,7 +29,8 @@ if opts.APPIMAGETOOL.exists():
raise SystemExit(0)
urlretrieve(
"https://github.com/AppImage/appimagetool/releases/latest/download/appimagetool-x86_64.AppImage", opts.APPIMAGETOOL
f"https://github.com/AppImage/appimagetool/releases/latest/download/appimagetool-{arch}.AppImage",
opts.APPIMAGETOOL,
)
os.chmod(opts.APPIMAGETOOL, 0o0700)

View File

@ -24,6 +24,12 @@ class Archiver(Protocol):
"""
enabled: bool = True
"""
If self.path is a single file that can be hashed.
For example directories cannot be hashed.
"""
hashable: bool = True
def __init__(self) -> None:
self.path = pathlib.Path()

View File

@ -12,6 +12,8 @@ logger = logging.getLogger(__name__)
class FolderArchiver(Archiver):
"""Folder implementation"""
hashable = False
def __init__(self) -> None:
super().__init__()
self.comment_file_name = "ComicTaggerFolderComment.txt"

View File

@ -1,5 +1,6 @@
from __future__ import annotations
import functools
import logging
import os
import pathlib
@ -271,10 +272,16 @@ class RarArchiver(Archiver):
else:
return True
@classmethod
@functools.cache
def _log_not_writeable(cls, exe: str) -> None:
logger.warning("Unable to find a useable copy of %r, will not be able to write rar files", exe)
def is_writable(self) -> bool:
writeable = False
try:
if bool(self.exe and (os.path.exists(self.exe) or shutil.which(self.exe))):
return (
writeable = (
subprocess.run(
(self.exe,),
startupinfo=self.startupinfo,
@ -286,6 +293,8 @@ class RarArchiver(Archiver):
)
except OSError:
...
if not writeable:
self._log_not_writeable(self.exe or "rar")
return False
def extension(self) -> str:

View File

@ -15,7 +15,9 @@
# limitations under the License.
from __future__ import annotations
import hashlib
import importlib.util
import inspect
import io
import itertools
import logging
@ -27,7 +29,7 @@ from collections.abc import Iterable
from comicapi import utils
from comicapi.archivers import Archiver, UnknownArchiver, ZipArchiver
from comicapi.genericmetadata import GenericMetadata
from comicapi.genericmetadata import FileHash, GenericMetadata
from comicapi.tags import Tag
from comictaggerlib.ctversion import version
@ -124,11 +126,15 @@ class ComicArchive:
pil_available = True
def __init__(
self, path: pathlib.Path | str | Archiver, default_image_path: pathlib.Path | str | None = None
self,
path: pathlib.Path | str | Archiver,
default_image_path: pathlib.Path | str | None = None,
hash_archive: str = "",
) -> None:
self.md: dict[str, GenericMetadata] = {}
self.page_count: int | None = None
self.page_list: list[str] = []
self.hash_archive = hash_archive
self.reset_cache()
self.default_image_path = default_image_path
@ -228,9 +234,10 @@ class ComicArchive:
if tag_id in self.md:
del self.md[tag_id]
if not tags[tag_id].enabled:
logger.warning("%s tags not enabled", tags[tag_id].name())
return False
self.apply_archive_info_to_metadata(metadata, True, True)
self.apply_archive_info_to_metadata(metadata, True, True, hash_archive=self.hash_archive)
return tags[tag_id].write_tags(metadata, self.archiver)
def has_tags(self, tag_id: str) -> bool:
@ -333,11 +340,34 @@ class ComicArchive:
return self.page_count
def apply_archive_info_to_metadata(
self, md: GenericMetadata, calc_page_sizes: bool = False, detect_double_page: bool = False
self,
md: GenericMetadata,
calc_page_sizes: bool = False,
detect_double_page: bool = False,
*,
hash_archive: str = "",
) -> None:
hash_archive = hash_archive
md.page_count = self.get_number_of_pages()
md.apply_default_page_list(self.get_page_name_list())
if not calc_page_sizes or not self.seems_to_be_a_comic_archive():
if not self.seems_to_be_a_comic_archive():
return
if hash_archive in hashlib.algorithms_available and not md.original_hash:
hasher = getattr(hashlib, hash_archive, hash_archive)
try:
with self.archiver.path.open("b+r") as archive:
digest = utils.file_digest(archive, hasher)
if len(inspect.signature(digest.hexdigest).parameters) > 0:
length = digest.name.rpartition("_")[2]
if not length.isdigit():
length = "128"
md.original_hash = FileHash(digest.name, digest.hexdigest(int(length) // 8)) # type: ignore[call-arg]
else:
md.original_hash = FileHash(digest.name, digest.hexdigest())
except Exception:
logger.exception("Failed to calculate original hash for '%s'", self.archiver.path)
if not calc_page_sizes:
return
for p in md.pages:

View File

@ -382,7 +382,7 @@ def lex_number(lex: Lexer) -> LexerFunc | None:
return lex_filename
def lex_issue_number(lex: Lexer) -> Callable[[Lexer], Callable | None] | None: # type: ignore[type-arg]
def lex_issue_number(lex: Lexer) -> LexerFunc:
# Only called when lex.input[lex.start] == "#"
original_start = lex.pos
lex.accept_run(str.isalpha)

View File

@ -23,6 +23,7 @@ from __future__ import annotations
import copy
import dataclasses
import hashlib
import logging
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any, Union, overload
@ -136,6 +137,32 @@ class MetadataOrigin(NamedTuple):
return self.name
class ImageHash(NamedTuple):
Hash: int
Kind: str # ahash, phash
def __str__(self) -> str:
return str(self.Hash) + ": " + self.Kind
class FileHash(NamedTuple):
name: str
hash: str
def __str__(self) -> str:
return self.name + ":" + self.hash
@classmethod
def parse(cls, string: str) -> FileHash:
name, _, parsed_hash = string.partition(":")
if name in hashlib.algorithms_available:
return FileHash(name, parsed_hash)
return FileHash("", "")
def __bool__(self) -> bool:
return all(self)
@dataclasses.dataclass
class GenericMetadata:
writer_synonyms = ("writer", "plotter", "scripter", "script")
@ -151,6 +178,7 @@ class GenericMetadata:
data_origin: MetadataOrigin | None = None
issue_id: str | None = None
series_id: str | None = None
original_hash: FileHash | None = None
series: str | None = None
series_aliases: set[str] = dataclasses.field(default_factory=set)
@ -202,8 +230,8 @@ class GenericMetadata:
last_mark: str | None = None
# urls to cover image, not generally part of the metadata
_cover_image: str | None = None
_alternate_images: list[str] = dataclasses.field(default_factory=list)
_cover_image: str | ImageHash | None = None
_alternate_images: list[str | ImageHash] = dataclasses.field(default_factory=list)
def __post_init__(self) -> None:
for key, value in self.__dict__.items():
@ -285,6 +313,9 @@ class GenericMetadata:
self.issue_id = assign(self.issue_id, new_md.issue_id)
self.series_id = assign(self.series_id, new_md.series_id)
# This should not usually be set by a talker or other online datasource
self.original_hash = assign(self.original_hash, new_md.original_hash)
self.series = assign(self.series, new_md.series)
self.series_aliases = assign_list(self.series_aliases, new_md.series_aliases)
@ -450,6 +481,7 @@ class GenericMetadata:
add_string("data_origin", self.data_origin)
add_string("series", self.series)
add_string("original_hash", self.original_hash)
add_string("series_aliases", ",".join(self.series_aliases))
add_string("issue", self.issue)
add_string("issue_count", self.issue_count)

View File

@ -3,7 +3,7 @@ from __future__ import annotations
import dataclasses
from collections.abc import Collection
from enum import auto
from typing import Any
from typing import Any, Callable
from comicapi.utils import DefaultDict, StrEnum, norm_fold
@ -54,7 +54,7 @@ def overlay(old: Any, new: Any) -> Any:
return new
attribute = DefaultDict(
attribute: DefaultDict[Mode, Callable[[Any, Any], Any]] = DefaultDict(
{
Mode.OVERLAY: overlay,
Mode.ADD_MISSING: lambda old, new: overlay(new, old),
@ -63,7 +63,7 @@ attribute = DefaultDict(
)
lists = DefaultDict(
lists: DefaultDict[Mode, Callable[[Collection[Any], Collection[Any]], list[Any] | set[Any]]] = DefaultDict(
{
Mode.OVERLAY: merge_lists,
Mode.ADD_MISSING: lambda old, new: merge_lists(new, old),

View File

@ -21,7 +21,7 @@ from typing import Any
from comicapi import utils
from comicapi.archivers import Archiver
from comicapi.genericmetadata import GenericMetadata, PageMetadata
from comicapi.genericmetadata import FileHash, GenericMetadata, PageMetadata
from comicapi.tags import Tag
logger = logging.getLogger(__name__)
@ -37,6 +37,7 @@ class ComicRack(Tag):
self.file = "ComicInfo.xml"
self.supported_attributes = {
"original_hash",
"series",
"issue",
"issue_count",
@ -244,7 +245,11 @@ class ComicRack(Tag):
assign("BlackAndWhite", "Yes" if md.black_and_white else None)
assign("AgeRating", md.maturity_rating)
assign("CommunityRating", md.critical_rating)
assign("ScanInformation", md.scan_info)
scan_info = md.scan_info or ""
if md.original_hash:
scan_info += f" sum:{md.original_hash}"
assign("ScanInformation", scan_info)
assign("PageCount", md.page_count)
@ -326,7 +331,18 @@ class ComicRack(Tag):
md.manga = utils.xlate(get("Manga"))
md.maturity_rating = utils.xlate(get("AgeRating"))
md.critical_rating = utils.xlate_float(get("CommunityRating"))
md.scan_info = utils.xlate(get("ScanInformation"))
scan_info_list = (utils.xlate(get("ScanInformation")) or "").split()
for word in scan_info_list.copy():
if not word.startswith("sum:"):
continue
original_hash = FileHash.parse(word[4:])
if original_hash:
md.original_hash = original_hash
scan_info_list.remove(word)
break
if scan_info_list:
md.scan_info = " ".join(scan_info_list)
md.is_empty = False
md.page_count = utils.xlate_int(get("PageCount"))

View File

@ -14,6 +14,7 @@ class Tag:
"data_origin",
"issue_id",
"series_id",
"original_hash",
"series",
"series_aliases",
"issue",

View File

@ -15,6 +15,7 @@
# limitations under the License.
from __future__ import annotations
import hashlib
import json
import logging
import os
@ -22,7 +23,7 @@ import pathlib
import platform
import sys
import unicodedata
from collections.abc import Iterable, Mapping
from collections.abc import Iterable, Mapping, Sequence
from enum import Enum, auto
from shutil import which # noqa: F401
from typing import Any, Callable, TypeVar, cast
@ -46,6 +47,45 @@ except ImportError:
if sys.version_info < (3, 11):
def file_digest(fileobj, digest, /, *, _bufsize=2**18): # type: ignore[no-untyped-def]
"""Hash the contents of a file-like object. Returns a digest object.
*fileobj* must be a file-like object opened for reading in binary mode.
It accepts file objects from open(), io.BytesIO(), and SocketIO objects.
The function may bypass Python's I/O and use the file descriptor *fileno*
directly.
*digest* must either be a hash algorithm name as a *str*, a hash
constructor, or a callable that returns a hash object.
"""
# On Linux we could use AF_ALG sockets and sendfile() to archive zero-copy
# hashing with hardware acceleration.
if isinstance(digest, str):
digestobj = hashlib.new(digest)
else:
digestobj = digest()
if hasattr(fileobj, "getbuffer"):
# io.BytesIO object, use zero-copy buffer
digestobj.update(fileobj.getbuffer())
return digestobj
# Only binary files implement readinto().
if not (hasattr(fileobj, "readinto") and hasattr(fileobj, "readable") and fileobj.readable()):
raise ValueError(f"'{fileobj!r}' is not a file-like object in binary reading mode.")
# binary file, socket.SocketIO object
# Note: socket I/O uses different syscalls than file I/O.
buf = bytearray(_bufsize) # Reusable buffer to reduce allocations.
view = memoryview(buf)
while True:
size = fileobj.readinto(buf)
if size == 0:
break # EOF
digestobj.update(view[:size])
return digestobj
class StrEnum(str, Enum):
"""
Enum where members are also (and must be) strings
@ -91,9 +131,10 @@ if sys.version_info < (3, 11):
return self.value
else:
from enum import StrEnum as s
from enum import StrEnum as _StrEnum
from hashlib import file_digest
class StrEnum(s):
class StrEnum(_StrEnum):
@classmethod
def _missing_(cls, value: Any) -> str | None:
if not isinstance(value, str):
@ -106,12 +147,16 @@ else:
logger = logging.getLogger(__name__)
class DefaultDict(dict):
def __init__(self, *args, default: Callable[[Any], Any] | None = None) -> None:
super().__init__(*args)
_KT = TypeVar("_KT")
_VT = TypeVar("_VT")
class DefaultDict(dict[_KT, _VT]):
def __init__(self, *args, default: Callable[[_KT], _VT | _KT] | None = None, **kwargs) -> None: # type: ignore[no-untyped-def]
super().__init__(*args, **kwargs)
self.default = default
def __missing__(self, key: Any) -> Any:
def __missing__(self, key: _KT) -> _VT | _KT:
if self.default is None:
return key
return self.default(key)
@ -129,7 +174,7 @@ def _custom_key(tup: Any) -> Any:
lst = []
for x in natsort.os_sort_keygen()(tup):
ret = x
if len(x) > 1 and isinstance(x[1], int) and isinstance(x[0], str) and x[0] == "":
if isinstance(x, Sequence) and len(x) > 1 and isinstance(x[1], int) and isinstance(x[0], str) and x[0] == "":
ret = ("a", *x[1:])
lst.append(ret)
@ -582,7 +627,7 @@ def update_publishers(new_publishers: Mapping[str, Mapping[str, str]]) -> None:
publishers[publisher] = ImprintDict(publisher, new_publishers[publisher])
class ImprintDict(dict): # type: ignore
class ImprintDict(dict[str, str]):
"""
ImprintDict takes a publisher and a dict or mapping of lowercased
imprint names to the proper imprint name. Retrieving a value from an
@ -590,14 +635,14 @@ class ImprintDict(dict): # type: ignore
if the key does not exist the key is returned as the publisher unchanged
"""
def __init__(self, publisher: str, mapping: tuple | Mapping = (), **kwargs: dict) -> None: # type: ignore
def __init__(self, publisher: str, mapping: Mapping[str, str] = {}, **kwargs) -> None: # type: ignore[no-untyped-def]
super().__init__(mapping, **kwargs)
self.publisher = publisher
def __missing__(self, key: str) -> None:
return None
def __getitem__(self, k: str) -> tuple[str, str, bool]:
def __getitem__(self, k: str) -> tuple[str, str, bool]: # type: ignore[override]
item = super().__getitem__(k.casefold())
if k.casefold() == self.publisher.casefold():
return "", self.publisher, True
@ -618,3 +663,40 @@ def load_publishers() -> None:
update_publishers(json.loads((comicapi.data.data_path / "publishers.json").read_text("utf-8")))
except Exception:
logger.exception("Failed to load publishers.json; The are no publishers or imprints loaded")
__all__ = (
"load_publishers",
"file_digest",
"Parser",
"ImprintDict",
"os_sorted",
"parse_filename",
"norm_fold",
"combine_notes",
"parse_date_str",
"shorten_path",
"path_to_short_str",
"get_page_name_list",
"get_recursive_filelist",
"add_to_path",
"remove_from_path",
"xlate_int",
"xlate_float",
"xlate",
"split",
"split_urls",
"remove_articles",
"sanitize_title",
"titles_match",
"unique_file",
"parse_version",
"countries",
"languages",
"get_language_from_iso",
"get_language_iso",
"get_country_from_iso",
"get_publisher",
"update_publishers",
"load_publishers",
)

View File

@ -3,7 +3,7 @@ from __future__ import annotations
import logging
import pathlib
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from PyQt6 import QtCore, QtGui, QtWidgets, uic
from comictaggerlib.ui import ui_path
@ -35,7 +35,7 @@ class ApplicationLogWindow(QtWidgets.QDialog):
self.log_handler.qlog.connect(self.textEdit.append)
f = QtGui.QFont("menlo")
f.setStyleHint(QtGui.QFont.Monospace)
f.setStyleHint(QtGui.QFont.StyleHint.Monospace)
self.setFont(f)
self._button = QtWidgets.QPushButton(self)
self._button.setText("Test Me")

View File

@ -20,7 +20,7 @@ import logging
import os
from typing import Callable
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from PyQt6 import QtCore, QtGui, QtWidgets, uic
from comicapi.comicarchive import ComicArchive, tags
from comicapi.genericmetadata import GenericMetadata
@ -182,7 +182,9 @@ class AutoTagMatchWindow(QtWidgets.QDialog):
self.teDescription.setText(match.description)
def set_cover_image(self) -> None:
ca = ComicArchive(self.current_match_set.original_path)
ca = ComicArchive(
self.current_match_set.original_path, hash_archive=self.config.Runtime_Options__preferred_hash
)
self.archiveCoverWidget.set_archive(ca)
def current_match(self) -> IssueResult:
@ -225,7 +227,9 @@ class AutoTagMatchWindow(QtWidgets.QDialog):
def save_match(self) -> None:
match = self.current_match()
ca = ComicArchive(self.current_match_set.original_path)
ca = ComicArchive(
self.current_match_set.original_path, hash_archive=self.config.Runtime_Options__preferred_hash
)
md, error = self.parent().read_selected_tags(self._tags, ca)
if error is not None:
logger.error("Failed to load tags for %s: %s", ca.path, error)

View File

@ -18,7 +18,7 @@ from __future__ import annotations
import logging
from PyQt5 import QtCore, QtWidgets, uic
from PyQt6 import QtCore, QtWidgets, uic
from comictaggerlib.coverimagewidget import CoverImageWidget
from comictaggerlib.ui import ui_path

View File

@ -18,7 +18,7 @@ from __future__ import annotations
import logging
from PyQt5 import QtCore, QtWidgets, uic
from PyQt6 import QtCore, QtWidgets, uic
from comictaggerlib.ctsettings import ct_ns
from comictaggerlib.ui import ui_path

View File

@ -121,7 +121,7 @@ class CLI:
self.post_process_matches(match_results)
if self.config.Auto_Tag__online:
if self.config.Auto_Tag__online and results and results[-1].online_results:
self.output(
f"\nFiles tagged with metadata provided by {self.current_talker().name} {self.current_talker().website}",
)
@ -184,7 +184,7 @@ class CLI:
if i != "s":
# save the data!
# we know at this point, that the file is all good to go
ca = ComicArchive(match_set.original_path)
ca = ComicArchive(match_set.original_path, hash_archive=self.config.Runtime_Options__preferred_hash)
md, match_set.tags_read = self.create_local_metadata(ca, self.config.Runtime_Options__tags_read)
ct_md = self.fetch_metadata(match_set.online_results[int(i) - 1].issue_id)
@ -330,6 +330,17 @@ class CLI:
self.output(md)
except Exception as e:
logger.error("Failed to read tags from %s: %s", ca.path, e)
if not self.config.Auto_Tag__metadata.is_empty and not self.config.Runtime_Options__raw:
try:
md, tags_read = self.create_local_metadata(
ca, self.config.Runtime_Options__tags_read or list(tags.keys())
)
tags_read_names = ", ".join(["CLI"] + [tags[t].name() for t in tags_read])
self.output(f"--------- Combined {tags_read_names} tags ---------")
self.output(md)
except Exception as e:
logger.error("Failed to read tags from %s: %s", ca.path, e)
return Result(Action.print, Status.success, ca.path, md=md)
def delete_tags(self, ca: ComicArchive, tag_id: str) -> Status:
@ -635,11 +646,11 @@ class CLI:
url_image_hash=-1,
issue_title=ct_md.title or "",
issue_id=ct_md.issue_id or "",
series_id=ct_md.issue_id or "",
series_id=ct_md.series_id or "",
month=ct_md.month,
year=ct_md.year,
publisher=None,
image_url=ct_md._cover_image or "",
image_url=str(ct_md._cover_image) or "",
alt_image_urls=[],
description=ct_md.description or "",
)
@ -790,7 +801,9 @@ class CLI:
logger.error("Cannot find %s", filename)
return Result(command, Status.read_failure, pathlib.Path(filename)), match_results
ca = ComicArchive(filename, str(graphics_path / "nocover.png"))
ca = ComicArchive(
filename, str(graphics_path / "nocover.png"), hash_archive=self.config.Runtime_Options__preferred_hash
)
if not ca.seems_to_be_a_comic_archive():
logger.error("Sorry, but %s is not a comic archive!", filename)

View File

@ -1,4 +1,4 @@
"""A PyQt5 widget to display cover images
"""A PyQt6 widget to display cover images
Display cover images from either a local archive, or from comic source metadata.
TODO: This should be re-factored using subclasses!
@ -23,7 +23,7 @@ from __future__ import annotations
import logging
import pathlib
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from PyQt6 import QtCore, QtGui, QtWidgets, uic
from comicapi.comicarchive import ComicArchive
from comictaggerlib.imagefetcher import ImageFetcher

View File

@ -20,7 +20,7 @@ import logging
import operator
import natsort
from PyQt5 import QtCore, QtWidgets, uic
from PyQt6 import QtCore, QtWidgets, uic
from comicapi import utils
from comicapi.genericmetadata import Credit

View File

@ -17,6 +17,7 @@
from __future__ import annotations
import argparse
import hashlib
import logging
import os
import platform
@ -25,7 +26,7 @@ import subprocess
import settngs
from comicapi import utils
from comicapi import comicarchive, utils
from comicapi.comicarchive import tags
from comictaggerlib import ctversion, quick_tag
from comictaggerlib.ctsettings.settngs_namespace import SettngsNS as ct_ns
@ -83,6 +84,20 @@ def register_runtime(parser: settngs.Manager) -> None:
help='Enable the expiremental "quick tagger"',
file=False,
)
parser.add_setting(
"--enable-embedding-hashes",
action=argparse.BooleanOptionalAction,
default=False,
help="Enable embedding hashes in metadata (currently only CR/CIX has support)",
file=False,
)
parser.add_setting(
"--preferred-hash",
default="shake_256",
choices=hashlib.algorithms_available,
help="The type of embedded hash to save when --enable-embedding-hashes is set\n\n",
file=False,
)
parser.add_setting("-q", "--quiet", action="store_true", help="Don't say much (for print mode).", file=False)
parser.add_setting(
"-j",
@ -268,6 +283,15 @@ def validate_commandline_settings(config: settngs.Config[ct_ns], parser: settngs
+ "Distributed under Apache License 2.0 (http://www.apache.org/licenses/LICENSE-2.0)\n",
)
enabled_tags = {tag for tag in comicarchive.tags if comicarchive.tags[tag].enabled}
if (
(not config[0].Metadata_Options__cr)
and "cr" in comicarchive.tags
and comicarchive.tags["cr"].enabled
and len(enabled_tags) > 1
):
comicarchive.tags["cr"].enabled = False
config[0].Runtime_Options__no_gui = any(
(config[0].Commands__command != Action.gui, config[0].Runtime_Options__no_gui, config[0].Commands__copy)
)
@ -287,6 +311,28 @@ def validate_commandline_settings(config: settngs.Config[ct_ns], parser: settngs
if config[0].Runtime_Options__tags_read and not config[0].Runtime_Options__tags_write:
config[0].Runtime_Options__tags_write = config[0].Runtime_Options__tags_read
disabled_tags = {tag for tag in comicarchive.tags if not comicarchive.tags[tag].enabled}
to_be_removed = (
set(config[0].Runtime_Options__tags_read)
.union(config[0].Runtime_Options__tags_write)
.intersection(disabled_tags)
)
if to_be_removed:
logger.debug("Removing disabled tags: %s", to_be_removed)
config[0].Runtime_Options__tags_read = [
tag for tag in config[0].Runtime_Options__tags_read if tag not in to_be_removed
]
config[0].Runtime_Options__tags_write = [
tag for tag in config[0].Runtime_Options__tags_write if tag not in to_be_removed
]
if (
config[0].Runtime_Options__no_gui
and not [tag.id for tag in tags.values() if tag.enabled]
and config[0].Commands__command != Action.list_plugins
):
parser.exit(status=1, message="There are no tags enabled see --list-plugins\n")
if config[0].Runtime_Options__no_gui and not config[0].Runtime_Options__files:
if config[0].Commands__command == Action.print and not config[0].Auto_Tag__metadata.is_empty:
... # allow printing the metadata provided on the commandline
@ -307,6 +353,9 @@ def validate_commandline_settings(config: settngs.Config[ct_ns], parser: settngs
if config[0].Runtime_Options__recursive:
config[0].Runtime_Options__files = utils.get_recursive_filelist(config[0].Runtime_Options__files)
if not config[0].Runtime_Options__enable_embedding_hashes:
config[0].Runtime_Options__preferred_hash = ""
# take a crack at finding rar exe if it's not in the path
if not utils.which("rar"):
if platform.system() == "Windows":

View File

@ -27,6 +27,7 @@ def general(parser: settngs.Manager) -> None:
def internal(parser: settngs.Manager) -> None:
# automatic settings
parser.add_setting("install_id", default=uuid.uuid4().hex, cmdline=False)
parser.add_setting("embedded_hash_type", default="shake_256", cmdline=False)
parser.add_setting("write_tags", default=["cr"], cmdline=False)
parser.add_setting("read_tags", default=["cr"], cmdline=False)
parser.add_setting("last_opened_folder", default="", cmdline=False)

View File

@ -10,7 +10,7 @@ import pathlib
import platform
import re
import sys
from collections.abc import Generator, Iterable
from collections.abc import Generator, Iterable, Sequence
from typing import Any, NamedTuple, TypeVar
if sys.version_info < (3, 10):
@ -31,7 +31,7 @@ def _custom_key(tup: Any) -> Any:
lst = []
for x in natsort.os_sort_keygen()(tup):
ret = x
if len(x) > 1 and isinstance(x[1], int) and isinstance(x[0], str) and x[0] == "":
if isinstance(x, Sequence) and len(x) > 1 and isinstance(x[1], int) and isinstance(x[0], str) and x[0] == "":
ret = ("a", *x[1:])
lst.append(ret)
@ -142,6 +142,7 @@ def find_plugins(plugin_folder: pathlib.Path) -> Plugins:
for plugin_path in os_sorted(zips):
logger.debug("looking for plugins in %s", plugin_path)
sys_path = sys.path.copy()
try:
sys.path.append(str(plugin_path))
for plugin in _find_local_plugins(plugin_path):
@ -150,7 +151,7 @@ def find_plugins(plugin_folder: pathlib.Path) -> Plugins:
except Exception as err:
logger.exception(FailedToLoadPlugin(plugin_path.name, err))
finally:
sys.path.remove(str(plugin_path))
sys.path = sys_path
for mod in list(sys.modules.values()):
if (
mod is not None

View File

@ -21,6 +21,8 @@ class SettngsNS(settngs.TypedNS):
Runtime_Options__config: comictaggerlib.ctsettings.types.ComicTaggerPaths
Runtime_Options__verbose: int
Runtime_Options__enable_quick_tag: bool
Runtime_Options__enable_embedding_hashes: bool
Runtime_Options__preferred_hash: str
Runtime_Options__quiet: bool
Runtime_Options__json: bool
Runtime_Options__raw: bool
@ -47,6 +49,7 @@ class SettngsNS(settngs.TypedNS):
Quick_Tag__exact_only: bool
internal__install_id: str
internal__embedded_hash_type: str
internal__write_tags: list[str]
internal__read_tags: list[str]
internal__last_opened_folder: str
@ -143,6 +146,8 @@ class Runtime_Options(typing.TypedDict):
config: comictaggerlib.ctsettings.types.ComicTaggerPaths
verbose: int
enable_quick_tag: bool
enable_embedding_hashes: bool
preferred_hash: str
quiet: bool
json: bool
raw: bool
@ -173,6 +178,7 @@ class Quick_Tag(typing.TypedDict):
class internal(typing.TypedDict):
install_id: str
embedded_hash_type: str
write_tags: list[str]
read_tags: list[str]
last_opened_folder: str

View File

@ -152,11 +152,12 @@ class ComicTaggerPaths(AppDirs):
def tag(types: str) -> list[str]:
enabled_tags = [tag for tag in tags if tags[tag].enabled]
result = []
types = types.casefold()
for typ in utils.split(types, ","):
if typ not in tags:
choices = ", ".join(tags)
if typ not in enabled_tags:
choices = ", ".join(enabled_tags)
raise argparse.ArgumentTypeError(f"invalid choice: {typ} (choose from {choices.upper()})")
result.append(tags[typ].id)
return result

View File

@ -18,7 +18,7 @@ from __future__ import annotations
import logging
from PyQt5 import QtCore, QtWidgets, uic
from PyQt6 import QtCore, QtWidgets, uic
from comictaggerlib.ui import ui_path

View File

@ -1,4 +1,4 @@
"""A PyQt5 widget for managing list of comic archive files"""
"""A PyQt6 widget for managing list of comic archive files"""
#
# Copyright 2012-2014 ComicTagger Authors
@ -21,7 +21,7 @@ import os
import platform
from typing import Callable, cast
from PyQt5 import QtCore, QtWidgets, uic
from PyQt6 import QtCore, QtGui, QtWidgets, uic
from comicapi import utils
from comicapi.comicarchive import ComicArchive
@ -63,9 +63,9 @@ class FileSelectionList(QtWidgets.QWidget):
self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.ActionsContextMenu)
self.dirty_flag = False
select_all_action = QtWidgets.QAction("Select All", self)
remove_action = QtWidgets.QAction("Remove Selected Items", self)
self.separator = QtWidgets.QAction("", self)
select_all_action = QtGui.QAction("Select All", self)
remove_action = QtGui.QAction("Remove Selected Items", self)
self.separator = QtGui.QAction("", self)
self.separator.setSeparator(True)
select_all_action.setShortcut("Ctrl+A")
@ -83,14 +83,14 @@ class FileSelectionList(QtWidgets.QWidget):
def get_sorting(self) -> tuple[int, int]:
col = self.twList.horizontalHeader().sortIndicatorSection()
order = self.twList.horizontalHeader().sortIndicatorOrder()
order = self.twList.horizontalHeader().sortIndicatorOrder().value
return int(col), int(order)
def set_sorting(self, col: int, order: QtCore.Qt.SortOrder) -> None:
self.twList.horizontalHeader().setSortIndicator(col, order)
def add_app_action(self, action: QtWidgets.QAction) -> None:
self.insertAction(QtWidgets.QAction(), action)
def add_app_action(self, action: QtGui.QAction) -> None:
self.insertAction(QtGui.QAction(), action)
def set_modified_flag(self, modified: bool) -> None:
self.dirty_flag = modified
@ -274,7 +274,9 @@ class FileSelectionList(QtWidgets.QWidget):
if self.is_list_dupe(path):
return self.get_current_list_row(path)
ca = ComicArchive(path, str(graphics_path / "nocover.png"))
ca = ComicArchive(
path, str(graphics_path / "nocover.png"), hash_archive=self.config.Runtime_Options__preferred_hash
)
if ca.seems_to_be_a_comic_archive():
row: int = self.twList.rowCount()

View File

@ -2,11 +2,11 @@
# Resource object code
#
# Created by: The Resource Compiler for PyQt5 (Qt v5.15.11)
# Created by: The Resource Compiler for PyQt6 (Qt v5.15.11)
#
# WARNING! All changes made in this file will be lost!
from PyQt5 import QtCore
from PyQt6 import QtCore
qt_resource_data = b"\
\x00\x00\x16\x0b\

View File

@ -16,7 +16,7 @@ from comictalker.comictalker import ComicTalker
logger = logging.getLogger("comictagger")
try:
qt_available = True
from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt6 import QtCore, QtGui, QtWidgets
def show_exception_box(log_msg: str) -> None:
"""Checks if a QApplication instance is available and shows a messagebox with the exception message.
@ -70,7 +70,7 @@ try:
try:
# needed here to initialize QWebEngine
from PyQt5.QtWebEngineWidgets import QWebEngineView # noqa: F401
from PyQt6.QtWebEngineWidgets import QWebEngineView # noqa: F401
qt_webengine_available = True
except ImportError:
@ -81,8 +81,8 @@ try:
# Handles "Open With" from Finder on macOS
def event(self, event: QtCore.QEvent) -> bool:
if event.type() == QtCore.QEvent.FileOpen:
logger.info(event.url().toLocalFile())
if event.type() == QtCore.QEvent.Type.FileOpen:
logger.info("file open recieved: %s", event.url().toLocalFile())
self.openFileRequest.emit(event.url())
return True
return super().event(event)

View File

@ -33,7 +33,7 @@ except ImportError:
from comictaggerlib import ctversion
if TYPE_CHECKING:
from PyQt5 import QtCore, QtNetwork
from PyQt6 import QtCore, QtNetwork
logger = logging.getLogger(__name__)
@ -57,7 +57,7 @@ class ImageFetcher:
if self.qt_available:
try:
from PyQt5 import QtNetwork
from PyQt6 import QtNetwork
self.qt_available = True
except ImportError:
@ -99,7 +99,7 @@ class ImageFetcher:
return image_data
if self.qt_available:
from PyQt5 import QtCore, QtNetwork
from PyQt6 import QtCore, QtNetwork
# if we found it, just emit the signal asap
if image_data:

View File

@ -35,7 +35,12 @@ logger = logging.getLogger(__name__)
class ImageHasher:
def __init__(
self, path: str | None = None, image: Image | None = None, data: bytes = b"", width: int = 8, height: int = 8
self,
path: str | None = None,
image: Image.Image | None = None,
data: bytes = b"",
width: int = 8,
height: int = 8,
) -> None:
self.width = width
self.height = height
@ -141,6 +146,7 @@ class ImageHasher:
row = []
for x in range(width):
pixel = image.getpixel((x, y))
assert isinstance(pixel, float)
row.append(pixel)
pixels2.append(row)

View File

@ -19,7 +19,7 @@ from __future__ import annotations
import logging
import platform
from PyQt5 import QtCore, QtGui, QtWidgets, sip, uic
from PyQt6 import QtCore, QtGui, QtWidgets, sip, uic
from comictaggerlib.ui import ui_path
@ -82,7 +82,7 @@ class ImagePopup(QtWidgets.QDialog):
screen_size.width(),
screen_size.height(),
QtCore.Qt.AspectRatioMode.IgnoreAspectRatio,
QtCore.Qt.SmoothTransformation,
QtCore.Qt.TransformationMode.SmoothTransformation,
)
self.setMask(self.clientBgPixmap.mask())
@ -104,7 +104,10 @@ class ImagePopup(QtWidgets.QDialog):
if self.imagePixmap.width() > win_w or self.imagePixmap.height() > win_h:
# scale the pixmap to fit in the frame
display_pixmap = self.imagePixmap.scaled(
win_w, win_h, QtCore.Qt.AspectRatioMode.KeepAspectRatio, QtCore.Qt.SmoothTransformation
win_w,
win_h,
QtCore.Qt.AspectRatioMode.KeepAspectRatio,
QtCore.Qt.TransformationMode.SmoothTransformation,
)
self.lblImage.setPixmap(display_pixmap)
else:

View File

@ -25,7 +25,7 @@ from typing_extensions import NotRequired, TypedDict
from comicapi import utils
from comicapi.comicarchive import ComicArchive
from comicapi.genericmetadata import ComicSeries, GenericMetadata
from comicapi.genericmetadata import ComicSeries, GenericMetadata, ImageHash
from comicapi.issuestring import IssueString
from comictaggerlib.ctsettings import ct_ns
from comictaggerlib.imagefetcher import ImageFetcher, ImageFetcherException
@ -132,13 +132,13 @@ class IssueIdentifier:
def set_cover_url_callback(self, cb_func: Callable[[bytes], None]) -> None:
self.cover_url_callback = cb_func
def calculate_hash(self, image_data: bytes) -> int:
def calculate_hash(self, image_data: bytes = b"", image: Image.Image | None = None) -> int:
if self.image_hasher == 3:
return ImageHasher(data=image_data).p_hash()
return ImageHasher(data=image_data, image=image).p_hash()
if self.image_hasher == 2:
return -1 # ImageHasher(data=image_data).average_hash2()
return -1 # ImageHasher(data=image_data, image=image).average_hash2()
return ImageHasher(data=image_data).average_hash()
return ImageHasher(data=image_data, image=image).average_hash()
def log_msg(self, msg: Any) -> None:
msg = str(msg)
@ -306,8 +306,8 @@ class IssueIdentifier:
def _get_issue_cover_match_score(
self,
primary_img_url: str,
alt_urls: list[str],
primary_img_url: str | ImageHash,
alt_urls: list[str | ImageHash],
local_hashes: list[tuple[str, int]],
use_alt_urls: bool = False,
) -> Score:
@ -316,16 +316,25 @@ class IssueIdentifier:
# If there is no URL return 100
if not primary_img_url:
return Score(score=100, url="", remote_hash=0)
return Score(score=100, url="", remote_hash=0, local_hash=0, local_hash_name="0")
self._user_canceled()
urls = [primary_img_url]
if use_alt_urls:
urls.extend(alt_urls)
self.log_msg(f"[{len(alt_urls)} alt. covers]")
remote_hashes = []
# If the cover is ImageHash and the alternate covers are URLs, the alts will not be hashed/checked currently
if isinstance(primary_img_url, ImageHash):
# ImageHash doesn't have a url so we just give it an empty string
remote_hashes.append(("", primary_img_url.Hash))
if use_alt_urls and alt_urls:
remote_hashes.extend(("", alt_hash.Hash) for alt_hash in alt_urls if isinstance(alt_hash, ImageHash))
else:
urls = [primary_img_url]
if use_alt_urls:
only_urls = [url for url in alt_urls if isinstance(url, str)]
urls.extend(only_urls)
self.log_msg(f"[{len(only_urls)} alt. covers]")
remote_hashes = self._get_remote_hashes(urls)
remote_hashes = self._get_remote_hashes(urls)
score_list = []
done = False
@ -368,8 +377,8 @@ class IssueIdentifier:
def _process_cover(self, name: str, image_data: bytes) -> list[tuple[str, Image.Image]]:
assert Image
cover_image = Image.open(io.BytesIO(image_data))
images = [(name, cover_image)]
cover_image: Image.Image = Image.open(io.BytesIO(image_data))
images: list[tuple[str, Image.Image]] = [(name, cover_image)]
# check the aspect ratio
# if it's wider than it is high, it's probably a two page spread (back_cover, front_cover)
@ -404,7 +413,7 @@ class IssueIdentifier:
def _get_search_keys(self, md: GenericMetadata) -> Any:
search_keys = SearchKeys(
series=md.series,
series=md.series or "",
issue_number=IssueString(md.issue).as_string(),
alternate_number=IssueString(md.alternate_number).as_string(),
month=md.month,
@ -490,7 +499,7 @@ class IssueIdentifier:
def _calculate_hashes(self, images: list[tuple[str, Image.Image]]) -> list[tuple[str, int]]:
hashes = []
for name, image in images:
hashes.append((name, ImageHasher(image=image).average_hash()))
hashes.append((name, self.calculate_hash(image=image)))
return hashes
def _match_covers(
@ -516,10 +525,14 @@ class IssueIdentifier:
)
try:
image_url = issue._cover_image or ""
alt_urls = issue._alternate_images
image_url = issue._cover_image if isinstance(issue._cover_image, str) else ""
# We only include urls in the IssueResult so we don't have to deal with it down the line
# TODO: display the hash to the user so they know a direct hash was used instead of downloading an image
alt_urls: list[str] = [url for url in issue._alternate_images if isinstance(url, str)]
score_item = self._get_issue_cover_match_score(image_url, alt_urls, hashes, use_alt_urls=use_alternates)
score_item = self._get_issue_cover_match_score(
image_url, issue._alternate_images, hashes, use_alt_urls=use_alternates
)
except Exception:
logger.exception(f"Scoring series{alternate} covers failed")
return []
@ -620,6 +633,16 @@ class IssueIdentifier:
extra_images: list[tuple[str, Image.Image]],
issues: list[tuple[ComicSeries, GenericMetadata]],
) -> list[IssueResult]:
# Set hashing kind, will presume all hashes are of the same kind
for series, issue in issues:
if isinstance(issue._cover_image, ImageHash):
if issue._cover_image.Kind == "phash":
self.image_hasher = 3
break
elif issue._cover_image.Kind == "ahash":
self.image_hasher = 1 # Set to 1 on init but might as well be sure
break
cover_matching_1 = self._match_covers(terms, images, issues, use_alternates=False)
if len(cover_matching_1) == 0:

View File

@ -18,7 +18,7 @@ from __future__ import annotations
import logging
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from PyQt6 import QtCore, QtGui, QtWidgets, uic
from comicapi.genericmetadata import GenericMetadata
from comicapi.issuestring import IssueString
@ -221,7 +221,10 @@ class IssueSelectionWindow(QtWidgets.QDialog):
QtWidgets.QApplication.restoreOverrideCursor()
self.issue_number = issue.issue or ""
self.coverWidget.set_issue_details(self.issue_id, [issue._cover_image or "", *issue._alternate_images])
# We don't currently have a way to display hashes to the user
# TODO: display the hash to the user so they know it will be used for cover matching
alt_images = [url for url in issue._alternate_images if isinstance(url, str)]
self.coverWidget.set_issue_details(self.issue_id, [str(issue._cover_image) or "", *alt_images])
if issue.description is None:
self.set_description(self.teDescription, "")
else:

View File

@ -18,7 +18,7 @@ from __future__ import annotations
import logging
from PyQt5 import QtCore, QtWidgets, uic
from PyQt6 import QtCore, QtWidgets, uic
from comictaggerlib.ui import qtutils, ui_path

View File

@ -33,7 +33,7 @@ import settngs
import comicapi.comicarchive
import comicapi.utils
import comictalker
from comictaggerlib import cli, ctsettings
from comictaggerlib import cli, ctsettings, pillow_plugins
from comictaggerlib.ctsettings import ct_ns, plugin_finder
from comictaggerlib.ctversion import version
from comictaggerlib.log import setup_logging
@ -48,6 +48,7 @@ logger = logging.getLogger("comictagger")
logger.setLevel(logging.DEBUG)
assert pillow_plugins
def _lang_code_mac() -> str:
@ -245,13 +246,6 @@ class App:
# config already loaded
error = None
if (
not self.config[0].Metadata_Options__cr
and "cr" in comicapi.comicarchive.tags
and comicapi.comicarchive.tags["cr"].enabled
):
comicapi.comicarchive.tags["cr"].enabled = False
if len(self.talkers) < 1:
error = (
"Failed to load any talkers, please re-install and check the log located in '"
@ -302,7 +296,7 @@ class App:
return gui.open_tagger_window(self.talkers, self.config, error)
except ImportError:
self.config[0].Runtime_Options__no_gui = True
logger.warning("PyQt5 is not available. ComicTagger is limited to command-line mode.")
logger.warning("PyQt6 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]:

View File

@ -19,7 +19,7 @@ from __future__ import annotations
import logging
import os
from PyQt5 import QtCore, QtWidgets, uic
from PyQt6 import QtCore, QtWidgets, uic
from comicapi.comicarchive import ComicArchive
from comictaggerlib.coverimagewidget import CoverImageWidget

View File

@ -1,4 +1,4 @@
"""A PyQt5 dialog to show a message and let the user check a box
"""A PyQt6 dialog to show a message and let the user check a box
Example usage:
@ -29,7 +29,7 @@ from __future__ import annotations
import logging
from PyQt5 import QtCore, QtWidgets
from PyQt6 import QtCore, QtWidgets
logger = logging.getLogger(__name__)

View File

@ -18,7 +18,7 @@ from __future__ import annotations
import logging
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from PyQt6 import QtCore, QtGui, QtWidgets, uic
from comicapi.comicarchive import ComicArchive
from comicapi.genericmetadata import GenericMetadata

View File

@ -1,4 +1,4 @@
"""A PyQt5 widget for editing the page list info"""
"""A PyQt6 widget for editing the page list info"""
#
# Copyright 2012-2014 ComicTagger Authors
@ -18,7 +18,7 @@ from __future__ import annotations
import logging
from PyQt5 import QtCore, QtWidgets, uic
from PyQt6 import QtCore, QtGui, QtWidgets, uic
from comicapi.comicarchive import ComicArchive, tags
from comicapi.genericmetadata import GenericMetadata, PageMetadata, PageType
@ -145,7 +145,7 @@ class PageListEditor(QtWidgets.QWidget):
if show_shortcut:
text = text + " (" + shortcut + ")"
self.cbPageType.addItem(text, user_data)
action_item = QtWidgets.QAction(shortcut, self)
action_item = QtGui.QAction(shortcut, self)
action_item.triggered.connect(lambda: self.select_page_type_item(self.cbPageType.findData(user_data)))
action_item.setShortcut(shortcut)
self.addAction(action_item)
@ -170,7 +170,7 @@ class PageListEditor(QtWidgets.QWidget):
return
md = GenericMetadata(pages=self.get_page_list())
double_pages = [bool(x.double_page) for x in md.pages]
self.comic_archive.apply_archive_info_to_metadata(md, True, True)
self.comic_archive.apply_archive_info_to_metadata(md, True, True, hash_archive="")
self.set_data(self.comic_archive, pages_list=md.pages)
if double_pages != [bool(x.double_page) for x in md.pages]:
self.modified.emit()

View File

@ -18,7 +18,7 @@ from __future__ import annotations
import logging
from PyQt5 import QtCore
from PyQt6 import QtCore
from comicapi.comicarchive import ComicArchive

View File

@ -0,0 +1,14 @@
from __future__ import annotations
try:
import pillow_avif
assert pillow_avif
except ImportError:
pass
try:
import pillow_jxl
assert pillow_jxl
except ImportError:
pass

View File

@ -1,4 +1,4 @@
"""A PyQt5 dialog to show ID log and progress"""
"""A PyQt6 dialog to show ID log and progress"""
#
# Copyright 2012-2014 ComicTagger Authors
@ -18,7 +18,7 @@ from __future__ import annotations
import logging
from PyQt5 import QtCore, QtWidgets, uic
from PyQt6 import QtCore, QtWidgets, uic
from comictaggerlib.ui import ui_path

View File

@ -19,7 +19,7 @@ from __future__ import annotations
import logging
import settngs
from PyQt5 import QtCore, QtWidgets, uic
from PyQt6 import QtCore, QtWidgets, uic
from comicapi import utils
from comicapi.comicarchive import ComicArchive, tags

View File

@ -21,8 +21,8 @@ import logging
from collections import deque
import natsort
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from PyQt5.QtCore import QUrl, pyqtSignal
from PyQt6 import QtCore, QtGui, QtWidgets, uic
from PyQt6.QtCore import QUrl, pyqtSignal
from comicapi import utils
from comicapi.comicarchive import ComicArchive

View File

@ -26,7 +26,7 @@ import urllib.parse
from typing import Any, cast
import settngs
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from PyQt6 import QtCore, QtGui, QtWidgets, uic
import comictaggerlib.ui.talkeruigenerator
from comicapi import merge, utils

View File

@ -17,6 +17,7 @@
from __future__ import annotations
import functools
import hashlib
import logging
import operator
import os
@ -30,7 +31,7 @@ from typing import Any, Callable, cast
import natsort
import settngs
from PyQt5 import QtCore, QtGui, QtNetwork, QtWidgets, uic
from PyQt6 import QtCore, QtGui, QtNetwork, QtWidgets, uic
import comicapi.merge
import comictaggerlib.graphics.resources
@ -38,7 +39,7 @@ import comictaggerlib.ui
from comicapi import utils
from comicapi.comicarchive import ComicArchive, tags
from comicapi.filenameparser import FileNameParser
from comicapi.genericmetadata import Credit, GenericMetadata
from comicapi.genericmetadata import Credit, FileHash, GenericMetadata
from comicapi.issuestring import IssueString
from comictaggerlib import ctsettings, ctversion
from comictaggerlib.applicationlogwindow import ApplicationLogWindow, QTextEditLogger
@ -93,6 +94,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.md_attributes = {
"data_origin": None,
"issue_id": None,
"original_hash": (self.cbHashName, self.leOriginalHash),
"series": self.leSeries,
"issue": self.leIssueNum,
"title": self.leTitle,
@ -229,9 +231,11 @@ class TaggerWindow(QtWidgets.QMainWindow):
for tag_id in config[0].internal__read_tags.copy():
if tag_id not in self.enabled_tags():
config[0].internal__read_tags.remove(tag_id)
if self.config[0].Runtime_Options__preferred_hash:
self.config[0].internal__embedded_hash_type = self.config[0].Runtime_Options__preferred_hash
self.selected_write_tags: list[str] = config[0].internal__write_tags or [self.enabled_tags()[0]]
self.selected_read_tags: list[str] = config[0].internal__read_tags or [self.enabled_tags()[0]]
self.selected_write_tags: list[str] = config[0].internal__write_tags
self.selected_read_tags: list[str] = config[0].internal__read_tags
self.setAcceptDrops(True)
self.view_tag_actions, self.remove_tag_actions = self.tag_actions()
@ -348,16 +352,27 @@ class TaggerWindow(QtWidgets.QMainWindow):
""",
)
self.config[0].Dialog_Flags__notify_plugin_changes = not checked
if self.enabled_tags():
self.selected_write_tags = [self.enabled_tags()[0]]
self.selected_read_tags = [self.enabled_tags()[0]]
else:
checked = OptionalMessageDialog.msg_no_checkbox(
self,
"No tags enabled",
"""
There are no tags enabled!<br/><br/>
Go to the "Metadata Options" tab in settings to enable the builtin "Comic Rack" tags
""",
)
if self.config[0].General__check_for_new_version:
self.check_latest_version_online()
def enabled_tags(self) -> Sequence[str]:
return [tag.id for tag in tags.values() if tag.enabled]
def tag_actions(self) -> tuple[dict[str, QtWidgets.QAction], dict[str, QtWidgets.QAction]]:
view_raw_tags: dict[str, QtWidgets.QAction] = {}
remove_raw_tags: dict[str, QtWidgets.QAction] = {}
def tag_actions(self) -> tuple[dict[str, QtGui.QAction], dict[str, QtGui.QAction]]:
view_raw_tags: dict[str, QtGui.QAction] = {}
remove_raw_tags: dict[str, QtGui.QAction] = {}
for tag in tags.values():
view_raw_tags[tag.id] = self.menuViewRawTags.addAction(f"View Raw {tag.name()} Tags")
view_raw_tags[tag.id].setEnabled(tag.enabled)
@ -432,6 +447,21 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.setWindowTitle(f"{self.appName} - {self.comic_archive.path}{mod_str}{ro_str}")
def toggle_enable_embedding_hashes(self) -> None:
self.config[0].Runtime_Options__enable_embedding_hashes = self.actionEnableEmbeddingHashes.isChecked()
enabled_widgets = set()
for tag_id in self.selected_write_tags:
if not tags[tag_id].enabled:
continue
enabled_widgets.update(tags[tag_id].supported_attributes)
enable_widget(self.md_attributes["original_hash"], self.config[0].Runtime_Options__enable_embedding_hashes and 'original_hash' in enabled_widgets)
if not self.leOriginalHash.text().strip():
self.cbHashName.setCurrentText(self.config[0].internal__embedded_hash_type)
if self.config[0].Runtime_Options__enable_embedding_hashes:
self.config[0].Runtime_Options__preferred_hash = self.config[0].internal__embedded_hash_type
else:
self.config[0].Runtime_Options__preferred_hash = ""
def config_menus(self) -> None:
# File Menu
self.actionAutoTag.triggered.connect(self.auto_tag)
@ -453,8 +483,11 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.actionLiteralSearch.triggered.connect(self.literal_search)
self.actionParse_Filename.triggered.connect(self.use_filename)
self.actionParse_Filename_split_words.triggered.connect(self.use_filename_split)
self.actionReCalcPageDims.triggered.connect(self.recalc_page_dimensions)
self.actionReCalcArchiveInfo.triggered.connect(self.recalc_archive_info)
self.actionSearchOnline.triggered.connect(self.query_online)
self.actionEnableEmbeddingHashes: QtGui.QAction
self.actionEnableEmbeddingHashes.triggered.connect(self.toggle_enable_embedding_hashes)
self.actionEnableEmbeddingHashes.setChecked(self.config[0].Runtime_Options__enable_embedding_hashes)
# Window Menu
self.actionLogWindow.triggered.connect(self.log_window.show)
self.actionPageBrowser.triggered.connect(self.show_page_browser)
@ -658,8 +691,6 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.page_browser.set_comic_archive(self.comic_archive)
self.page_browser.metadata = self.metadata
if self.comic_archive is not None:
self.page_list_editor.set_data(self.comic_archive, self.metadata.pages)
self.metadata_to_form()
self.clear_dirty_flag() # also updates the app title
self.update_info_box()
@ -680,7 +711,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.actionCopyTags.setEnabled(enabled)
self.actionParse_Filename.setEnabled(enabled)
self.actionParse_Filename_split_words.setEnabled(enabled)
self.actionReCalcPageDims.setEnabled(enabled)
self.actionReCalcArchiveInfo.setEnabled(enabled)
self.actionRemoveAuto.setEnabled(enabled)
self.actionRename.setEnabled(enabled)
self.actionRepackage.setEnabled(enabled)
@ -795,6 +826,9 @@ class TaggerWindow(QtWidgets.QMainWindow):
md = self.metadata
original_hash = md.original_hash or FileHash("", "")
self.cbHashName.setCurrentText(original_hash.name or self.config[0].internal__embedded_hash_type)
assign_text(self.leOriginalHash, original_hash.hash)
assign_text(self.leSeries, md.series)
assign_text(self.leIssueNum, md.issue)
assign_text(self.leIssueCount, md.issue_count)
@ -876,9 +910,12 @@ class TaggerWindow(QtWidgets.QMainWindow):
continue
self.add_new_credit_entry(row, credit)
if self.comic_archive:
self.page_list_editor.set_data(self.comic_archive, self.metadata.pages)
self.twCredits.setSortingEnabled(True)
self.update_credit_colors()
self.toggle_enable_embedding_hashes()
def add_new_credit_entry(self, row: int, credit: Credit) -> None:
self.twCredits.insertRow(row)
@ -922,6 +959,11 @@ class TaggerWindow(QtWidgets.QMainWindow):
# copy the data from the form into the metadata
md = GenericMetadata()
md.is_empty = False
if utils.xlate(self.cbHashName.currentText()) and utils.xlate(self.leOriginalHash.text()):
md.original_hash = FileHash(
utils.xlate(self.cbHashName.currentText()) or "", utils.xlate(self.leOriginalHash.text()) or ""
)
md.alternate_number = utils.xlate(IssueString(self.leAltIssueNum.text()).as_string())
md.issue = utils.xlate(IssueString(self.leIssueNum.text()).as_string())
md.issue_count = utils.xlate_int(self.leIssueCount.text())
@ -1237,6 +1279,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.update_credit_colors()
self.page_list_editor.select_read_tags(self.selected_write_tags)
self.toggle_enable_embedding_hashes()
def cell_double_clicked(self, r: int, c: int) -> None:
self.edit_credit()
@ -1415,6 +1458,9 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.adjust_tags_combo()
self.cbHashName: QtWidgets.QComboBox
self.cbHashName.addItems(sorted(hashlib.algorithms_available))
# Add talker entries
for t_id, talker in self.talkers.items():
self.cbx_sources.addItem(talker.name, t_id)
@ -1974,7 +2020,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.load_archive(new_ca)
self.atprogdialog = None
summary = ""
summary = f"<p>{self.current_talker().attribution}</p>"
summary += f"Successfully added {tag_names} tags to {len(match_results.good_matches)} archive(s)\n"
if len(match_results.multiple_matches) > 0:
@ -2114,12 +2160,22 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.metadata = CBLTransformer(self.metadata, self.config[0]).apply()
self.metadata_to_form()
def recalc_page_dimensions(self) -> None:
def recalc_archive_info(self) -> None:
QtWidgets.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.CursorShape.WaitCursor))
for p in self.metadata.pages:
p.byte_size = None
p.height = None
p.width = None
if self.comic_archive and self.config[0].Runtime_Options__preferred_hash:
self.metadata.original_hash = None
self.comic_archive.apply_archive_info_to_metadata(
self.metadata, True, hash_archive=self.cbHashName.currentText()
)
original_hash = self.metadata.original_hash or FileHash("", "")
self.leOriginalHash.setText(original_hash.hash)
self.cbHashName.setCurrentText(original_hash.name or self.config[0].internal__embedded_hash_type)
self.page_list_editor.set_data(self.comic_archive, self.metadata.pages)
self.set_dirty_flag()
QtWidgets.QApplication.restoreOverrideCursor()

View File

@ -6,8 +6,8 @@ from enum import auto
from sys import platform
from typing import Any
from PyQt5 import QtGui, QtWidgets
from PyQt5.QtCore import QEvent, QModelIndex, QPoint, QRect, QSize, Qt, pyqtSignal
from PyQt6 import QtGui, QtWidgets
from PyQt6.QtCore import QEvent, QModelIndex, QPoint, QRect, QSize, Qt, pyqtSignal
from comicapi.utils import StrEnum
@ -30,41 +30,41 @@ class ModifyStyleItemDelegate(QtWidgets.QStyledItemDelegate):
# Draw background with the same color as other widgets
palette = self.combobox.palette()
background_color = palette.color(QtGui.QPalette.Window)
background_color = palette.color(QtGui.QPalette.ColorRole.Window)
painter.fillRect(options.rect, background_color)
style.drawPrimitive(QtWidgets.QStyle.PE_PanelItemViewItem, options, painter, self.combobox)
style.drawPrimitive(QtWidgets.QStyle.PrimitiveElement.PE_PanelItemViewItem, options, painter, self.combobox)
painter.save()
# Checkbox drawing logic
checked = index.data(Qt.CheckStateRole)
checked = index.data(Qt.ItemDataRole.CheckStateRole)
opts = QtWidgets.QStyleOptionButton()
opts.state |= QtWidgets.QStyle.State_Active
opts.state |= QtWidgets.QStyle.StateFlag.State_Active
opts.rect = self.getCheckBoxRect(options)
opts.state |= QtWidgets.QStyle.State_ReadOnly
opts.state |= QtWidgets.QStyle.StateFlag.State_ReadOnly
if checked:
opts.state |= QtWidgets.QStyle.State_On
opts.state |= QtWidgets.QStyle.StateFlag.State_On
style.drawPrimitive(
QtWidgets.QStyle.PrimitiveElement.PE_IndicatorMenuCheckMark, opts, painter, self.combobox
)
else:
opts.state |= QtWidgets.QStyle.State_Off
opts.state |= QtWidgets.QStyle.StateFlag.State_Off
if platform != "darwin":
style.drawControl(QtWidgets.QStyle.CE_CheckBox, opts, painter, self.combobox)
style.drawControl(QtWidgets.QStyle.ControlElement.CE_CheckBox, opts, painter, self.combobox)
label = index.data(Qt.DisplayRole)
label = index.data(Qt.ItemDataRole.DisplayRole)
rectangle = options.rect
rectangle.setX(opts.rect.width() + 10)
painter.drawText(rectangle, Qt.AlignVCenter, label)
# We need the restore here so that text is colored properly
painter.restore()
painter.drawText(rectangle, Qt.AlignmentFlag.AlignVCenter, label)
def getCheckBoxRect(self, option: QtWidgets.QStyleOptionViewItem) -> QRect:
# Get size of a standard checkbox.
opts = QtWidgets.QStyleOptionButton()
style = option.widget.style()
checkBoxRect = style.subElementRect(QtWidgets.QStyle.SE_CheckBoxIndicator, opts, None)
checkBoxRect = style.subElementRect(QtWidgets.QStyle.SubElement.SE_CheckBoxIndicator, opts, None)
y = option.rect.y()
h = option.rect.height()
checkBoxTopLeftCorner = QPoint(5, int(y + h / 2 - checkBoxRect.height() / 2))
@ -99,23 +99,23 @@ class CheckableComboBox(QtWidgets.QComboBox):
# https://stackoverflow.com/questions/65826378/how-do-i-use-qcombobox-setplaceholdertext/65830989#65830989
def paintEvent(self, event: QEvent) -> None:
painter = QtWidgets.QStylePainter(self)
painter.setPen(self.palette().color(QtGui.QPalette.Text))
painter.setPen(self.palette().color(QtGui.QPalette.ColorRole.Text))
# draw the combobox frame, focusrect and selected etc.
opt = QtWidgets.QStyleOptionComboBox()
self.initStyleOption(opt)
painter.drawComplexControl(QtWidgets.QStyle.CC_ComboBox, opt)
painter.drawComplexControl(QtWidgets.QStyle.ComplexControl.CC_ComboBox, opt)
if self.currentIndex() < 0:
opt.palette.setBrush(
QtGui.QPalette.ButtonText,
opt.palette.brush(QtGui.QPalette.ButtonText).color(),
QtGui.QPalette.ColorRole.ButtonText,
opt.palette.brush(QtGui.QPalette.ColorRole.ButtonText).color(),
)
if self.placeholderText():
opt.currentText = self.placeholderText()
# draw the icon and text
painter.drawControl(QtWidgets.QStyle.CE_ComboBoxLabel, opt)
painter.drawControl(QtWidgets.QStyle.ControlElement.CE_ComboBoxLabel, opt)
def resizeEvent(self, event: Any) -> None:
# Recompute text to elide as needed
@ -126,16 +126,16 @@ class CheckableComboBox(QtWidgets.QComboBox):
# Allow events before the combobox list is shown
if obj == self.view().viewport():
# We record that the combobox list has been shown
if event.type() == QEvent.Show:
if event.type() == QEvent.Type.Show:
self.justShown = True
# We record that the combobox list has hidden,
# this will happen if the user does not make a selection
# but clicks outside of the combobox list or presses escape
if event.type() == QEvent.Hide:
if event.type() == QEvent.Type.Hide:
self._updateText()
self.justShown = False
# QEvent.MouseButtonPress is inconsistent on activation because double clicks are a thing
if event.type() == QEvent.MouseButtonRelease:
# QEvent.Type.MouseButtonPress is inconsistent on activation because double clicks are a thing
if event.type() == QEvent.Type.MouseButtonRelease:
# If self.justShown is true it means that they clicked on the combobox to change the checked items
# This is standard behavior (on macos) but I think it is surprising when it has a multiple select
if self.justShown:
@ -153,7 +153,7 @@ class CheckableComboBox(QtWidgets.QComboBox):
res = []
for i in range(self.count()):
item = self.model().item(i)
if item.checkState() == Qt.Checked:
if item.checkState() == Qt.CheckState.Checked:
res.append(self.itemData(i))
return res
@ -168,7 +168,7 @@ class CheckableComboBox(QtWidgets.QComboBox):
texts = []
for i in range(self.count()):
item = self.model().item(i)
if item.checkState() == Qt.Checked:
if item.checkState() == Qt.CheckState.Checked:
texts.append(item.text())
text = ", ".join(texts)
@ -180,22 +180,24 @@ class CheckableComboBox(QtWidgets.QComboBox):
so.initFrom(self)
# Ask the style for the size of the text field
rect = self.style().subControlRect(QtWidgets.QStyle.CC_ComboBox, so, QtWidgets.QStyle.SC_ComboBoxEditField)
rect = self.style().subControlRect(
QtWidgets.QStyle.ComplexControl.CC_ComboBox, so, QtWidgets.QStyle.SubControl.SC_ComboBoxEditField
)
# Compute the elided text
elidedText = self.fontMetrics().elidedText(text, Qt.ElideRight, rect.width())
elidedText = self.fontMetrics().elidedText(text, Qt.TextElideMode.ElideRight, rect.width())
# This CheckableComboBox does not use the index, so we clear it and set the placeholder text
self.setCurrentIndex(-1)
self.setPlaceholderText(elidedText)
def setItemChecked(self, index: Any, state: bool) -> None:
qt_state = Qt.Checked if state else Qt.Unchecked
qt_state = Qt.CheckState.Checked if state else Qt.CheckState.Unchecked
item = self.model().item(index)
current = self.currentData()
# If we have at least one item checked emit itemChecked with the current check state and update text
# Require at least one item to be checked and provide a tooltip
if len(current) == 1 and not state and item.checkState() == Qt.Checked:
if len(current) == 1 and not state and item.checkState() == Qt.CheckState.Checked:
QtWidgets.QToolTip.showText(QtGui.QCursor.pos(), self.toolTip(), self, QRect(), 3000)
return
@ -205,7 +207,7 @@ class CheckableComboBox(QtWidgets.QComboBox):
self._updateText()
def toggleItem(self, index: int) -> None:
if self.model().item(index).checkState() == Qt.Checked:
if self.model().item(index).checkState() == Qt.CheckState.Checked:
self.setItemChecked(index, False)
else:
self.setItemChecked(index, True)
@ -240,44 +242,44 @@ class ReadStyleItemDelegate(QtWidgets.QStyledItemDelegate):
# Draw background with the same color as other widgets
palette = self.combobox.palette()
background_color = palette.color(QtGui.QPalette.Window)
background_color = palette.color(QtGui.QPalette.ColorRole.Window)
painter.fillRect(options.rect, background_color)
style.drawPrimitive(QtWidgets.QStyle.PE_PanelItemViewItem, options, painter, self.combobox)
style.drawPrimitive(QtWidgets.QStyle.PrimitiveElement.PE_PanelItemViewItem, options, painter, self.combobox)
painter.save()
# Checkbox drawing logic
checked = index.data(Qt.CheckStateRole)
checked = index.data(Qt.ItemDataRole.CheckStateRole)
opts = QtWidgets.QStyleOptionButton()
opts.state |= QtWidgets.QStyle.State_Active
opts.state |= QtWidgets.QStyle.StateFlag.State_Active
opts.rect = self.getCheckBoxRect(options)
opts.state |= QtWidgets.QStyle.State_ReadOnly
opts.state |= QtWidgets.QStyle.StateFlag.State_ReadOnly
if checked:
opts.state |= QtWidgets.QStyle.State_On
opts.state |= QtWidgets.QStyle.StateFlag.State_On
style.drawPrimitive(
QtWidgets.QStyle.PrimitiveElement.PE_IndicatorMenuCheckMark, opts, painter, self.combobox
)
else:
opts.state |= QtWidgets.QStyle.State_Off
opts.state |= QtWidgets.QStyle.StateFlag.State_Off
if platform != "darwin":
style.drawControl(QtWidgets.QStyle.CE_CheckBox, opts, painter, self.combobox)
style.drawControl(QtWidgets.QStyle.ControlElement.CE_CheckBox, opts, painter, self.combobox)
label = index.data(Qt.DisplayRole)
label = index.data(Qt.ItemDataRole.DisplayRole)
rectangle = options.rect
rectangle.setX(opts.rect.width() + 10)
painter.drawText(rectangle, Qt.AlignVCenter, label)
# We need the restore here so that text is colored properly
painter.restore()
painter.drawText(rectangle, Qt.AlignmentFlag.AlignVCenter, label)
# Draw buttons
if checked and (options.state & QtWidgets.QStyle.State_Selected):
if checked and (options.state & QtWidgets.QStyle.StateFlag.State_Selected):
up_rect = self._button_up_rect(options.rect)
down_rect = self._button_down_rect(options.rect)
painter.drawImage(up_rect, self.up_icon)
painter.drawImage(down_rect, self.down_icon)
painter.restore()
def _button_up_rect(self, rect: QRect) -> QRect:
return QRect(
self.combobox.view().width() - (self.button_width * 2) - (self.button_padding * 2),
@ -298,7 +300,7 @@ class ReadStyleItemDelegate(QtWidgets.QStyledItemDelegate):
# Get size of a standard checkbox.
opts = QtWidgets.QStyleOptionButton()
style = option.widget.style()
checkBoxRect = style.subElementRect(QtWidgets.QStyle.SE_CheckBoxIndicator, opts, None)
checkBoxRect = style.subElementRect(QtWidgets.QStyle.SubElement.SE_CheckBoxIndicator, opts, None)
y = option.rect.y()
h = option.rect.height()
checkBoxTopLeftCorner = QPoint(5, int(y + h / 2 - checkBoxRect.height() / 2))
@ -307,7 +309,7 @@ class ReadStyleItemDelegate(QtWidgets.QStyledItemDelegate):
def itemClicked(self, index: QModelIndex, pos: QPoint) -> None:
item_rect = self.combobox.view().visualRect(index)
checked = index.data(Qt.CheckStateRole)
checked = index.data(Qt.ItemDataRole.CheckStateRole)
button_up_rect = self._button_up_rect(item_rect)
button_down_rect = self._button_down_rect(item_rect)
@ -336,11 +338,11 @@ class ReadStyleItemDelegate(QtWidgets.QStyledItemDelegate):
item_rect = view.visualRect(index)
button_up_rect = self._button_up_rect(item_rect)
button_down_rect = self._button_down_rect(item_rect)
checked = index.data(Qt.CheckStateRole)
checked = index.data(Qt.ItemDataRole.CheckStateRole)
if checked == Qt.Checked and button_up_rect.contains(event.pos()):
if checked == Qt.CheckState.Checked and button_up_rect.contains(event.pos()):
QtWidgets.QToolTip.showText(event.globalPos(), self.up_help, self.combobox, QRect(), 3000)
elif checked == Qt.Checked and button_down_rect.contains(event.pos()):
elif checked == Qt.CheckState.Checked and button_down_rect.contains(event.pos()):
QtWidgets.QToolTip.showText(event.globalPos(), self.down_help, self.combobox, QRect(), 3000)
else:
QtWidgets.QToolTip.showText(event.globalPos(), self.item_help, self.combobox, QRect(), 3000)
@ -380,23 +382,23 @@ class CheckableOrderComboBox(QtWidgets.QComboBox):
# https://stackoverflow.com/questions/65826378/how-do-i-use-qcombobox-setplaceholdertext/65830989#65830989
def paintEvent(self, event: QEvent) -> None:
painter = QtWidgets.QStylePainter(self)
painter.setPen(self.palette().color(QtGui.QPalette.Text))
painter.setPen(self.palette().color(QtGui.QPalette.ColorRole.Text))
# draw the combobox frame, focusrect and selected etc.
opt = QtWidgets.QStyleOptionComboBox()
self.initStyleOption(opt)
painter.drawComplexControl(QtWidgets.QStyle.CC_ComboBox, opt)
painter.drawComplexControl(QtWidgets.QStyle.ComplexControl.CC_ComboBox, opt)
if self.currentIndex() < 0:
opt.palette.setBrush(
QtGui.QPalette.ButtonText,
opt.palette.brush(QtGui.QPalette.ButtonText).color(),
QtGui.QPalette.ColorRole.ButtonText,
opt.palette.brush(QtGui.QPalette.ColorRole.ButtonText).color(),
)
if self.placeholderText():
opt.currentText = self.placeholderText()
# draw the icon and text
painter.drawControl(QtWidgets.QStyle.CE_ComboBoxLabel, opt)
painter.drawControl(QtWidgets.QStyle.ControlElement.CE_ComboBoxLabel, opt)
def buttonClicked(self, index: QModelIndex, button: ClickedButtonEnum) -> None:
if button == ClickedButtonEnum.up:
@ -415,18 +417,18 @@ class CheckableOrderComboBox(QtWidgets.QComboBox):
# Allow events before the combobox list is shown
if obj == self.view().viewport():
# We record that the combobox list has been shown
if event.type() == QEvent.Show:
if event.type() == QEvent.Type.Show:
self.justShown = True
# We record that the combobox list has hidden,
# this will happen if the user does not make a selection
# but clicks outside of the combobox list or presses escape
if event.type() == QEvent.Hide:
if event.type() == QEvent.Type.Hide:
self._updateText()
self.justShown = False
# Reverse as the display order is in "priority" order for the user whereas overlay requires reversed
self.dropdownClosed.emit(self.currentData())
# QEvent.MouseButtonPress is inconsistent on activation because double clicks are a thing
if event.type() == QEvent.MouseButtonRelease:
# QEvent.Type.MouseButtonPress is inconsistent on activation because double clicks are a thing
if event.type() == QEvent.Type.MouseButtonRelease:
# If self.justShown is true it means that they clicked on the combobox to change the checked items
# This is standard behavior (on macos) but I think it is surprising when it has a multiple select
if self.justShown:
@ -446,7 +448,7 @@ class CheckableOrderComboBox(QtWidgets.QComboBox):
res = []
for i in range(self.count()):
item = self.model().item(i)
if item.checkState() == Qt.Checked:
if item.checkState() == Qt.CheckState.Checked:
res.append(self.itemData(i))
return res
@ -458,7 +460,7 @@ class CheckableOrderComboBox(QtWidgets.QComboBox):
self.model().item(0).setCheckState(Qt.CheckState.Checked)
# Add room for "move" arrows
text_width = self.fontMetrics().width(text)
text_width = self.fontMetrics().boundingRect(text).width()
checkbox_width = 40
total_width = text_width + checkbox_width + (self.itemDelegate().button_width * 2)
if total_width > self.view().minimumWidth():
@ -477,19 +479,19 @@ class CheckableOrderComboBox(QtWidgets.QComboBox):
return
# Grab values for the rows to swap
cur_data = self.model().item(index).data(Qt.UserRole)
cur_title = self.model().item(index).data(Qt.DisplayRole)
cur_state = self.model().item(index).data(Qt.CheckStateRole)
cur_data = self.model().item(index).data(Qt.ItemDataRole.UserRole)
cur_title = self.model().item(index).data(Qt.ItemDataRole.DisplayRole)
cur_state = self.model().item(index).checkState()
swap_data = self.model().item(row).data(Qt.UserRole)
swap_title = self.model().item(row).data(Qt.DisplayRole)
swap_data = self.model().item(row).data(Qt.ItemDataRole.UserRole)
swap_title = self.model().item(row).data(Qt.ItemDataRole.DisplayRole)
swap_state = self.model().item(row).checkState()
self.model().item(row).setData(cur_data, Qt.UserRole)
self.model().item(row).setData(cur_data, Qt.ItemDataRole.UserRole)
self.model().item(row).setCheckState(cur_state)
self.model().item(row).setText(cur_title)
self.model().item(index).setData(swap_data, Qt.UserRole)
self.model().item(index).setData(swap_data, Qt.ItemDataRole.UserRole)
self.model().item(index).setCheckState(swap_state)
self.model().item(index).setText(swap_title)
@ -497,7 +499,7 @@ class CheckableOrderComboBox(QtWidgets.QComboBox):
texts = []
for i in range(self.count()):
item = self.model().item(i)
if item.checkState() == Qt.Checked:
if item.checkState() == Qt.CheckState.Checked:
texts.append(item.text())
text = ", ".join(texts)
@ -509,22 +511,24 @@ class CheckableOrderComboBox(QtWidgets.QComboBox):
so.initFrom(self)
# Ask the style for the size of the text field
rect = self.style().subControlRect(QtWidgets.QStyle.CC_ComboBox, so, QtWidgets.QStyle.SC_ComboBoxEditField)
rect = self.style().subControlRect(
QtWidgets.QStyle.ComplexControl.CC_ComboBox, so, QtWidgets.QStyle.SubControl.SC_ComboBoxEditField
)
# Compute the elided text
elidedText = self.fontMetrics().elidedText(text, Qt.ElideRight, rect.width())
elidedText = self.fontMetrics().elidedText(text, Qt.TextElideMode.ElideRight, rect.width())
# This CheckableComboBox does not use the index, so we clear it and set the placeholder text
self.setCurrentIndex(-1)
self.setPlaceholderText(elidedText)
def setItemChecked(self, index: Any, state: bool) -> None:
qt_state = Qt.Checked if state else Qt.Unchecked
qt_state = Qt.CheckState.Checked if state else Qt.CheckState.Unchecked
item = self.model().item(index)
current = self.currentData()
# If we have at least one item checked emit itemChecked with the current check state and update text
# Require at least one item to be checked and provide a tooltip
if len(current) == 1 and not state and item.checkState() == Qt.Checked:
if len(current) == 1 and not state and item.checkState() == Qt.CheckState.Checked:
QtWidgets.QToolTip.showText(QtGui.QCursor.pos(), self.toolTip(), self, QRect(), 3000)
return
@ -533,7 +537,7 @@ class CheckableOrderComboBox(QtWidgets.QComboBox):
self._updateText()
def toggleItem(self, index: int) -> None:
if self.model().item(index).checkState() == Qt.Checked:
if self.model().item(index).checkState() == Qt.CheckState.Checked:
self.setItemChecked(index, False)
else:
self.setItemChecked(index, True)

View File

@ -6,16 +6,16 @@ import io
import logging
import traceback
import webbrowser
from collections.abc import Sequence
from collections.abc import Collection, Sequence
from PyQt5.QtCore import QUrl
from PyQt5.QtWidgets import QWidget
from PyQt6.QtCore import QUrl
from PyQt6.QtWidgets import QWidget
logger = logging.getLogger(__name__)
try:
from PyQt5 import QtGui, QtWidgets
from PyQt5.QtCore import Qt
from PyQt6 import QtGui, QtWidgets
from PyQt6.QtCore import Qt
qt_available = True
except ImportError:
@ -23,14 +23,15 @@ except ImportError:
if qt_available:
try:
from PIL import Image, ImageQt
from PIL import Image
pil_available = True
except ImportError:
pil_available = False
try:
from PyQt5.QtWebEngineWidgets import QWebEnginePage, QWebEngineView
from PyQt6.QtWebEngineCore import QWebEnginePage
from PyQt6.QtWebEngineWidgets import QWebEngineView
class WebPage(QWebEnginePage):
def acceptNavigationRequest(
@ -53,6 +54,7 @@ if qt_available:
webengine.setPage(WebPage(parent))
webengine.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu)
settings = webengine.settings()
assert settings is not None
settings.setAttribute(settings.WebAttribute.AutoLoadImages, True)
settings.setAttribute(settings.WebAttribute.JavascriptEnabled, False)
settings.setAttribute(settings.WebAttribute.JavascriptCanOpenWindows, False)
@ -129,10 +131,11 @@ if qt_available:
try:
if pil_available:
# Qt doesn't understand the format, but maybe PIL does
img = ImageQt.ImageQt(Image.open(io.BytesIO(image_data)))
success = True
buffer = io.BytesIO()
Image.open(io.BytesIO(image_data)).save(buffer, format="ppm")
success = img.loadFromData(buffer.getvalue())
except Exception:
pass
logger.exception("Failed to load the image")
# if still nothing, go with default image
if not success:
img.load(":/graphics/nocover.png")
@ -147,7 +150,7 @@ if qt_available:
active_palette = None
def enable_widget(widget: QtWidgets.QWidget | list[QtWidgets.QWidget], enable: bool) -> None:
def enable_widget(widget: QtWidgets.QWidget | Collection[QtWidgets.QWidget], enable: bool) -> None:
if isinstance(widget, Sequence):
for w in widget:
_enable_widget(w, enable)
@ -189,7 +192,7 @@ if qt_available:
if isinstance(widget, (QtWidgets.QTextEdit, QtWidgets.QLineEdit, QtWidgets.QAbstractSpinBox)):
widget.setReadOnly(False)
elif isinstance(widget, QtWidgets.QListWidget):
widget.setMovement(QtWidgets.QListWidget.Free)
widget.setMovement(QtWidgets.QListWidget.Movement.Free)
else:
if isinstance(widget, QtWidgets.QTableWidgetItem):
widget.setBackground(inactive_brush)
@ -206,7 +209,7 @@ if qt_available:
elif isinstance(widget, QtWidgets.QListWidget):
inactive_palette = palettes()
widget.setPalette(inactive_palette[0])
widget.setMovement(QtWidgets.QListWidget.Static)
widget.setMovement(QtWidgets.QListWidget.Movement.Static)
def replaceWidget(
layout: QtWidgets.QLayout | QtWidgets.QSplitter, old_widget: QtWidgets.QWidget, new_widget: QtWidgets.QWidget

View File

@ -955,7 +955,7 @@ Source</string>
<item>
<layout class="QFormLayout" name="formLayout_8">
<property name="fieldGrowthPolicy">
<enum>QFormLayout::ExpandingFieldsGrow</enum>
<enum>QFormLayout::AllNonFixedFieldsGrow</enum>
</property>
<item row="0" column="0">
<widget class="QLabel" name="label_20">
@ -970,6 +970,30 @@ Source</string>
<item row="0" column="1">
<widget class="QLineEdit" name="leScanInfo"/>
</item>
<item row="2" column="1">
<widget class="QLineEdit" name="leOriginalHash"/>
</item>
<item row="2" column="0">
<widget class="QLabel" name="lblOriginalHash">
<property name="text">
<string>Original Hash</string>
</property>
<property name="buddy">
<cstring>leOriginalHash</cstring>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QComboBox" name="cbHashName">
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="lblHashName">
<property name="text">
<string>Hash Type</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
@ -1428,7 +1452,8 @@ Source</string>
<addaction name="actionLiteralSearch"/>
<addaction name="separator"/>
<addaction name="actionApplyCBLTransform"/>
<addaction name="actionReCalcPageDims"/>
<addaction name="actionReCalcArchiveInfo"/>
<addaction name="actionEnableEmbeddingHashes"/>
</widget>
<widget class="QMenu" name="menuWindow">
<property name="title">
@ -1717,9 +1742,12 @@ Source</string>
<string>Ctrl+Shift+F</string>
</property>
</action>
<action name="actionReCalcPageDims">
<action name="actionReCalcArchiveInfo">
<property name="text">
<string>Re-Calculate Page Dimensions</string>
<string>Re-Calculate Archive Information</string>
</property>
<property name="toolTip">
<string>Only includes page size info and original hash, original hash wil be overwritten</string>
</property>
<property name="shortcut">
<string>Ctrl+Shift+R</string>
@ -1814,6 +1842,14 @@ Source</string>
<string>Ctrl+S</string>
</property>
</action>
<action name="actionEnableEmbeddingHashes">
<property name="checkable">
<bool>true</bool>
</property>
<property name="text">
<string>Enable Embedding Hashes</string>
</property>
</action>
</widget>
<layoutdefault spacing="6" margin="11"/>
<customwidgets>

View File

@ -6,7 +6,7 @@ from pathlib import Path
from typing import Any, NamedTuple, cast
import settngs
from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt6 import QtCore, QtGui, QtWidgets
from comictaggerlib.coverimagewidget import CoverImageWidget
from comictaggerlib.ctsettings import ct_ns, group_for_plugin
@ -39,11 +39,13 @@ class PasswordEdit(QtWidgets.QLineEdit):
self.visibleIcon = QtGui.QIcon(":/graphics/eye.svg")
self.hiddenIcon = QtGui.QIcon(":/graphics/hidden.svg")
self.setEchoMode(QtWidgets.QLineEdit.Password)
self.setEchoMode(QtWidgets.QLineEdit.EchoMode.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 = self.addAction(
self.visibleIcon, QtWidgets.QLineEdit.ActionPosition.TrailingPosition
)
self.togglepasswordAction.setToolTip("Show password")
self.togglepasswordAction.triggered.connect(self.on_toggle_password_action)
@ -51,12 +53,12 @@ class PasswordEdit(QtWidgets.QLineEdit):
def on_toggle_password_action(self) -> None:
if not self.password_shown:
self.setEchoMode(QtWidgets.QLineEdit.Normal)
self.setEchoMode(QtWidgets.QLineEdit.EchoMode.Normal)
self.password_shown = True
self.togglepasswordAction.setIcon(self.hiddenIcon)
self.togglepasswordAction.setToolTip("Hide password")
else:
self.setEchoMode(QtWidgets.QLineEdit.Password)
self.setEchoMode(QtWidgets.QLineEdit.EchoMode.Password)
self.password_shown = False
self.togglepasswordAction.setIcon(self.visibleIcon)
self.togglepasswordAction.setToolTip("Show password")
@ -125,7 +127,7 @@ def generate_spinbox(option: settngs.Setting, layout: QtWidgets.QGridLayout) ->
widget = QtWidgets.QSpinBox()
widget.setRange(0, 9999)
widget.setToolTip(option.help)
layout.addWidget(widget, row, 1, alignment=QtCore.Qt.AlignLeft)
layout.addWidget(widget, row, 1, alignment=QtCore.Qt.AlignmentFlag.AlignLeft)
return widget
@ -138,7 +140,7 @@ def generate_doublespinbox(option: settngs.Setting, layout: QtWidgets.QGridLayou
widget = QtWidgets.QDoubleSpinBox()
widget.setRange(0, 9999.99)
widget.setToolTip(option.help)
layout.addWidget(widget, row, 1, alignment=QtCore.Qt.AlignLeft)
layout.addWidget(widget, row, 1, alignment=QtCore.Qt.AlignmentFlag.AlignLeft)
return widget
@ -223,8 +225,8 @@ def generate_talker_info(talker: ComicTalker, config: settngs.Config[ct_ns], lay
# Add horizontal divider
line = QtWidgets.QFrame()
line.setFrameShape(QtWidgets.QFrame.HLine)
line.setFrameShadow(QtWidgets.QFrame.Sunken)
line.setFrameShape(QtWidgets.QFrame.Shape.HLine)
line.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken)
layout.addWidget(line, row + 3, 0, 1, -1)
@ -352,15 +354,15 @@ def generate_source_option_tabs(
talker_layout = QtWidgets.QGridLayout()
lbl_select_talker = QtWidgets.QLabel("Metadata Source:")
line = QtWidgets.QFrame()
line.setFrameShape(QtWidgets.QFrame.HLine)
line.setFrameShadow(QtWidgets.QFrame.Sunken)
line.setFrameShape(QtWidgets.QFrame.Shape.HLine)
line.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken)
talker_tabs = QtWidgets.QTabWidget()
# Store all widgets as to allow easier access to their values vs. using findChildren etc. on the tab widget
sources: Sources = Sources(QtWidgets.QComboBox(), [])
talker_layout.addWidget(lbl_select_talker, 0, 0, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Maximum)
talker_layout.addWidget(sources[0], 0, 1, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Maximum)
talker_layout.addWidget(lbl_select_talker, 0, 0)
talker_layout.addWidget(sources[0], 0, 1)
talker_layout.addWidget(line, 1, 0, 1, -1)
talker_layout.addWidget(talker_tabs, 2, 0, 1, -1)
@ -440,7 +442,9 @@ def generate_source_option_tabs(
generate_api_widgets(talker, tab, key_option, url_option, layout_grid, definitions=config.definitions)
# Add vertical spacer
vspacer = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding)
vspacer = QtWidgets.QSpacerItem(
20, 40, QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Expanding
)
layout_grid.addItem(vspacer, layout_grid.rowCount() + 1, 0)
# Display the new widgets
tab.tab.setLayout(layout_grid)

View File

@ -21,7 +21,7 @@ import logging
import os
import pathlib
import sqlite3
from typing import Any
from typing import Any, Generic, TypeVar
from typing_extensions import NamedTuple
@ -39,6 +39,14 @@ class Issue(NamedTuple):
data: bytes
T = TypeVar("T", Issue, Series)
class CacheResult(NamedTuple, Generic[T]):
data: T
complete: bool
class ComicCacher:
def __init__(self, cache_folder: pathlib.Path, version: str) -> None:
self.cache_folder = cache_folder
@ -174,7 +182,7 @@ class ComicCacher:
}
self.upsert(cur, "issues", data)
def get_search_results(self, source: str, search_term: str, expire_stale: bool = True) -> list[tuple[Series, bool]]:
def get_search_results(self, source: str, search_term: str, expire_stale: bool = True) -> list[CacheResult[Series]]:
results = []
with sqlite3.connect(self.db_file) as con:
con.row_factory = sqlite3.Row
@ -197,11 +205,11 @@ class ComicCacher:
for record in rows:
result = Series(id=record["id"], data=record["data"])
results.append((result, record["complete"]))
results.append(CacheResult(result, record["complete"]))
return results
def get_series_info(self, series_id: str, source: str, expire_stale: bool = True) -> tuple[Series, bool] | None:
def get_series_info(self, series_id: str, source: str, expire_stale: bool = True) -> CacheResult[Series] | None:
with sqlite3.connect(self.db_file) as con:
con.row_factory = sqlite3.Row
cur = con.cursor()
@ -220,11 +228,11 @@ class ComicCacher:
result = Series(id=row["id"], data=row["data"])
return (result, row["complete"])
return CacheResult(result, row["complete"])
def get_series_issues_info(
self, series_id: str, source: str, expire_stale: bool = True
) -> list[tuple[Issue, bool]]:
) -> list[CacheResult[Issue]]:
with sqlite3.connect(self.db_file) as con:
con.row_factory = sqlite3.Row
cur = con.cursor()
@ -234,20 +242,20 @@ class ComicCacher:
self.expire_stale_records(cur, "Issues")
# fetch
results: list[tuple[Issue, bool]] = []
results: list[CacheResult[Issue]] = []
cur.execute("SELECT * FROM Issues WHERE series_id=? AND source=?", [series_id, source])
rows = cur.fetchall()
# now process the results
for row in rows:
record = (Issue(id=row["id"], series_id=row["series_id"], data=row["data"]), row["complete"])
record = CacheResult(Issue(id=row["id"], series_id=row["series_id"], data=row["data"]), row["complete"])
results.append(record)
return results
def get_issue_info(self, issue_id: str, source: str, expire_stale: bool = True) -> tuple[Issue, bool] | None:
def get_issue_info(self, issue_id: str, source: str, expire_stale: bool = True) -> CacheResult[Issue] | None:
with sqlite3.connect(self.db_file) as con:
con.row_factory = sqlite3.Row
cur = con.cursor()
@ -262,7 +270,7 @@ class ComicCacher:
record = None
if row:
record = (Issue(id=row["id"], series_id=row["series_id"], data=row["data"]), row["complete"])
record = CacheResult(Issue(id=row["id"], series_id=row["series_id"], data=row["data"]), row["complete"])
return record
@ -297,7 +305,7 @@ class ComicCacher:
sql_ins = f"INSERT OR REPLACE INTO {tablename} ({keys}) VALUES ({ins_slots})"
if not data.get("complete", True):
sql_ins += f" ON CONFLICT DO UPDATE SET {set_slots} WHERE complete != ?"
vals.extend(vals)
vals.extend(vals.copy())
vals.append(True) # If the cache is complete and this isn't complete we don't update it
cur.execute(sql_ins, vals)

View File

@ -43,7 +43,7 @@ def cleanup_html(string: str | None, remove_html_tables: bool = False) -> str:
# find any tables
soup = BeautifulSoup(string, "html.parser")
tables = soup.findAll("table")
tables = soup.find_all("table")
# put in our own
string = re.sub(r"<br>|</li>", "\n", string, flags=re.IGNORECASE)
@ -78,15 +78,15 @@ def cleanup_html(string: str | None, remove_html_tables: bool = False) -> str:
rows = []
hdrs = []
col_widths = []
for hdr in table.findAll("th"):
for hdr in table.find_all("th"):
item = hdr.string.strip()
hdrs.append(item)
col_widths.append(len(item))
rows.append(hdrs)
for row in table.findAll("tr"):
for row in table.find_all("tr"):
cols = []
col = row.findAll("td")
col = row.find_all("td")
for i, c in enumerate(col):
item = c.string.strip()

View File

@ -485,7 +485,7 @@ class ComicVineTalker(ComicTalker):
for issue_id in issue_ids:
cached_issue = cvc.get_issue_info(issue_id, self.id)
if cached_issue and cached_issue[1]:
if cached_issue is not None:
cached_results.append(
self._map_comic_issue_to_metadata(
json.loads(cached_issue[0].data),
@ -531,21 +531,26 @@ class ComicVineTalker(ComicTalker):
series_info = {s[0].id: s[0] for s in self._fetch_series([int(i["volume"]["id"]) for i in issue_results])}
cache_issue: list[Issue] = []
for issue in issue_results:
cvc.add_issues_info(
self.id,
[
Issue(
id=str(issue["id"]),
series_id=str(issue["volume"]["id"]),
data=json.dumps(issue).encode("utf-8"),
),
],
False, # The /issues/ endpoint never provides credits
cache_issue.append(
Issue(
id=str(issue["id"]),
series_id=str(issue["volume"]["id"]),
data=json.dumps(issue).encode("utf-8"),
)
)
cached_results.append(
self._map_comic_issue_to_metadata(issue, series_info[str(issue["volume"]["id"])]),
)
from pprint import pp
pp(cache_issue, indent=2)
cvc.add_issues_info(
self.id,
cache_issue,
False, # The /issues/ endpoint never provides credits
)
return cached_results
@ -820,7 +825,7 @@ class ComicVineTalker(ComicTalker):
cached_issue = cvc.get_issue_info(issue_id, self.id)
logger.debug("Issue cached: %s", bool(cached_issue and cached_issue[1]))
if cached_issue and cached_issue[1]:
if cached_issue and cached_issue.complete:
return self._map_comic_issue_to_metadata(
json.loads(cached_issue[0].data), self._fetch_series_data(int(cached_issue[0].series_id))[0]
)

View File

@ -23,4 +23,4 @@ disable = "C0330, C0326, C0115, C0116, C0103"
max-line-length=120
[tool.pylint.master]
extension-pkg-whitelist="PyQt5"
extension-pkg-whitelist="PyQt6"

View File

@ -43,7 +43,7 @@ install_requires =
natsort>=8.1.0
packaging>=20
pathvalidate
pillow>=9.1.0,<10
pillow>=9.1.0
pyrate-limiter>=2.6,<3
pyyaml
rapidfuzz>=2.12.0
@ -77,8 +77,8 @@ pyinstaller40 =
7z =
py7zr
all =
PyQt5
PyQtWebEngine
PyQt6
PyQt6-WebEngine
comicinfoxml==0.4.*
gcd-talker>0.1.0
metron-talker>0.1.5
@ -98,7 +98,7 @@ cix =
gcd =
gcd-talker>0.1.0
gui =
PyQt5
PyQt6
icu =
pyicu;sys_platform == 'linux' or sys_platform == 'darwin'
jxl =
@ -106,8 +106,8 @@ jxl =
metron =
metron-talker>0.1.5
pyinstaller =
PyQt5
PyQtWebEngine
PyQt6
PyQt6-WebEngine
comicinfoxml==0.4.*
pillow-avif-plugin>=1.4.1
pillow-jxl-plugin>=1.2.5
@ -115,8 +115,8 @@ pyinstaller =
rarfile>=4.0
pyicu;sys_platform == 'linux' or sys_platform == 'darwin'
qtw =
PyQt5
PyQtWebEngine
PyQt6
PyQt6-WebEngine
[options.package_data]
comicapi =
@ -257,7 +257,6 @@ deps =
extras =
pyinstaller
commands =
pyrcc5 comictaggerlib/graphics/graphics.qrc -o comictaggerlib/graphics/resources.py
pyinstaller -y build-tools/comictagger.spec
python -c 'import importlib,platform; importlib.import_module("icu") if platform.system() != "Windows" else ...' # Sanity check for icu
@ -275,11 +274,11 @@ depends =
deps =
requests
allowlist_externals =
{tox_root}/build/appimagetool-x86_64.AppImage
{tox_root}/build/appimagetool-aarch64.AppImage
change_dir = {tox_root}/dist/binary
commands_pre =
-python -c 'import shutil; shutil.rmtree("{tox_root}/build/", ignore_errors=True)'
python {tox_root}/build-tools/get_appimage.py {tox_root}/build/appimagetool-x86_64.AppImage
python {tox_root}/build-tools/get_appimage.py {tox_root}/build/appimagetool.AppImage
commands =
python -c 'import shutil,pathlib; shutil.copytree("{tox_root}/dist/comictagger/", "{tox_root}/build/appimage", dirs_exist_ok=True); \
shutil.copy("{tox_root}/comictaggerlib/graphics/app.png", "{tox_root}/build/appimage/app.png"); \
@ -287,7 +286,7 @@ commands =
pathlib.Path("{tox_root}/build/appimage/AppRun.desktop").write_text( \
pathlib.Path("{tox_root}/build-tools/ComicTagger.desktop").read_text() \
.replace("/usr/local/share/comictagger/app.png", "app"))'
{tox_root}/build/appimagetool-x86_64.AppImage {tox_root}/build/appimage
{tox_root}/build/appimagetool.AppImage {tox_root}/build/appimage
[testenv:zip_artifacts]
description = Zip release artifacts

View File

@ -258,7 +258,7 @@ all_seed_imprints = {
"Marvel": seed_imprints["Marvel"].copy(),
"DC Comics": additional_seed_imprints["DC Comics"].copy(),
}
all_seed_imprints["Marvel"].update(additional_seed_imprints["Marvel"])
all_seed_imprints["Marvel"].update(additional_seed_imprints["Marvel"].items())
conflicting_seed_imprints = {"Marvel": {"test": "Never"}}
@ -288,3 +288,76 @@ metadata_prepared = (
),
),
)
issueidentifier_score = (
(
(
comicapi.genericmetadata.ImageHash(
Hash=0, # Force using the alternate, since the alternate is a url it will be ignored
Kind="ahash",
),
["https://comicvine.gamespot.com/cory-doctorows-futuristic-tales-of-the-here-and-no/4000-140529/"],
True,
),
{
"remote_hash": 0,
"score": 31,
"url": "",
"local_hash": 212201432349720,
"local_hash_name": "Cover 1",
},
),
(
(
comicapi.genericmetadata.ImageHash(
Hash=0,
Kind="ahash",
),
[
comicapi.genericmetadata.ImageHash(
Hash=212201432349720,
Kind="ahash",
),
],
True,
),
{
"remote_hash": 212201432349720,
"score": 0,
"url": "",
"local_hash": 212201432349720,
"local_hash_name": "Cover 1",
},
),
(
(
comicapi.genericmetadata.ImageHash(
Hash=212201432349720,
Kind="ahash",
),
["https://comicvine.gamespot.com/cory-doctorows-futuristic-tales-of-the-here-and-no/4000-140529/"],
False,
),
{
"remote_hash": 212201432349720,
"score": 0,
"url": "",
"local_hash": 212201432349720,
"local_hash_name": "Cover 1",
},
),
(
(
"https://comicvine.gamespot.com/a/uploads/scale_large/0/574/585444-109004_20080707014047_large.jpg",
["https://comicvine.gamespot.com/cory-doctorows-futuristic-tales-of-the-here-and-no/4000-140529/"],
False,
),
{
"remote_hash": 212201432349720,
"score": 0,
"url": "https://comicvine.gamespot.com/a/uploads/scale_large/0/574/585444-109004_20080707014047_large.jpg",
"local_hash": 212201432349720,
"local_hash_name": "Cover 1",
},
),
)

View File

@ -9,6 +9,7 @@ import comictaggerlib.imagehasher
import comictaggerlib.issueidentifier
import testing.comicdata
import testing.comicvine
from comicapi.genericmetadata import ImageHash
from comictaggerlib.resulttypes import IssueResult
@ -36,21 +37,22 @@ def test_get_search_keys(cbz, config, additional_md, expected, comicvine_api):
assert expected == ii._get_search_keys(additional_md)
def test_get_issue_cover_match_score(cbz, config, comicvine_api):
@pytest.mark.parametrize("data, expected", testing.comicdata.issueidentifier_score)
def test_get_issue_cover_match_score(
cbz,
config,
comicvine_api,
data: tuple[str | ImageHash, list[str | ImageHash], bool],
expected: comictaggerlib.issueidentifier.Score,
):
config, definitions = config
ii = comictaggerlib.issueidentifier.IssueIdentifier(cbz, config, comicvine_api)
score = ii._get_issue_cover_match_score(
"https://comicvine.gamespot.com/a/uploads/scale_large/0/574/585444-109004_20080707014047_large.jpg",
["https://comicvine.gamespot.com/cory-doctorows-futuristic-tales-of-the-here-and-no/4000-140529/"],
[("Cover 1", ii.calculate_hash(cbz.get_page(0)))],
primary_img_url=data[0],
alt_urls=data[1],
local_hashes=[("Cover 1", ii.calculate_hash(cbz.get_page(0)))],
use_alt_urls=data[2],
)
expected = {
"remote_hash": 212201432349720,
"score": 0,
"url": "https://comicvine.gamespot.com/a/uploads/scale_large/0/574/585444-109004_20080707014047_large.jpg",
"local_hash": 212201432349720,
"local_hash_name": "Cover 1",
}
assert expected == score