Compare commits

..

No commits in common. "289761100633a4a45d58f535d6d8db31cfe04aa7" and "956c383e5f133f3147f55f8f65b501db1c4a0284" have entirely different histories.

52 changed files with 922 additions and 3079 deletions

View File

@ -1,6 +1,7 @@
name: CI
env:
PKG_CONFIG_PATH: /usr/local/opt/icu4c/lib/pkgconfig
LC_COLLATE: en_US.UTF-8
on:
pull_request:
@ -22,12 +23,12 @@ jobs:
os: [ubuntu-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
uses: actions/setup-python@v3
with:
python-version: ${{ matrix.python-version }}
@ -50,12 +51,12 @@ jobs:
os: [ubuntu-latest, macos-11, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
uses: actions/setup-python@v3
with:
python-version: ${{ matrix.python-version }}
@ -65,20 +66,21 @@ jobs:
- name: Install macos dependencies
run: |
brew upgrade icu4c pkg-config || brew install icu4c pkg-config
brew install icu4c pkg-config
# export PKG_CONFIG_PATH="/usr/local/opt/icu4c/lib/pkgconfig";
# export PATH="/usr/local/opt/icu4c/bin:/usr/local/opt/icu4c/sbin:$PATH"
if: runner.os == 'macOS'
- name: Install linux dependencies
run: |
sudo apt-get update && sudo apt-get upgrade && sudo apt-get install pkg-config libicu-dev libqt5gui5 libfuse2
# export PKG_CONFIG_PATH="/usr/local/opt/icu4c/lib/pkgconfig";
# export PATH="/usr/local/opt/icu4c/bin:/usr/local/opt/icu4c/sbin:$PATH"
if: runner.os == 'Linux'
- name: Build and install PyPi packages
run: |
export PKG_CONFIG_PATH="/usr/local/opt/icu4c/lib/pkgconfig:/opt/homebrew/opt/icu4c/lib/pkgconfig${PKG_CONFIG_PATH+:$PKG_CONFIG_PATH}";
export PATH="/usr/local/opt/icu4c/bin:/usr/local/opt/icu4c/sbin${PATH+:$PATH}"
python -m tox r -m build
shell: bash
- name: Archive production artifacts
uses: actions/upload-artifact@v3

View File

@ -23,7 +23,7 @@ jobs:
use_username: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/checkout@v4
- uses: actions/checkout@v3
with:
fetch-depth: 0

View File

@ -1,6 +1,7 @@
name: Package
env:
PKG_CONFIG_PATH: /usr/local/opt/icu4c/lib/pkgconfig
LC_COLLATE: en_US.UTF-8
on:
push:
@ -17,12 +18,12 @@ jobs:
os: [ubuntu-latest, macos-11, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
uses: actions/setup-python@v3
with:
python-version: ${{ matrix.python-version }}
@ -32,21 +33,22 @@ jobs:
- name: Install macos dependencies
run: |
brew upgrade && brew install icu4c pkg-config
brew install icu4c pkg-config
# export PKG_CONFIG_PATH="/usr/local/opt/icu4c/lib/pkgconfig";
# export PATH="/usr/local/opt/icu4c/bin:/usr/local/opt/icu4c/sbin:$PATH"
if: runner.os == 'macOS'
- name: Install linux dependencies
run: |
sudo apt-get update && sudo apt-get upgrade && sudo apt-get install pkg-config libicu-dev libqt5gui5 libfuse2
# export PKG_CONFIG_PATH="/usr/local/opt/icu4c/lib/pkgconfig";
# export PATH="/usr/local/opt/icu4c/bin:/usr/local/opt/icu4c/sbin:$PATH"
if: runner.os == 'Linux'
- name: Build, Install and Test PyPi packages
run: |
export PKG_CONFIG_PATH="/usr/local/opt/icu4c/lib/pkgconfig:/opt/homebrew/opt/icu4c/lib/pkgconfig${PKG_CONFIG_PATH+:$PKG_CONFIG_PATH}";
export PATH="/usr/local/opt/icu4c/bin:/usr/local/opt/icu4c/sbin${PATH+:$PATH}"
python -m tox r
python -m tox r -m release
shell: bash
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
@ -59,7 +61,7 @@ jobs:
echo "release_name=$(git tag -l --format "%(refname:strip=2): %(contents:lines=1)" ${{ github.ref_name }})" >> $GITHUB_ENV
- name: Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
name: "${{ env.release_name }}"

View File

@ -1,7 +1,7 @@
exclude: ^scripts
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
rev: v4.5.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
@ -14,12 +14,12 @@ repos:
hooks:
- id: setup-cfg-fmt
- repo: https://github.com/asottile/pyupgrade
rev: v3.15.2
rev: v3.15.1
hooks:
- id: pyupgrade
args: [--py39-plus]
- repo: https://github.com/PyCQA/autoflake
rev: v2.3.1
rev: v2.3.0
hooks:
- id: autoflake
args: [-i, --remove-all-unused-imports, --ignore-init-module-imports]
@ -29,16 +29,16 @@ repos:
- id: isort
args: [--af,--add-import, 'from __future__ import annotations']
- repo: https://github.com/psf/black
rev: 24.4.2
rev: 24.2.0
hooks:
- id: black
- repo: https://github.com/PyCQA/flake8
rev: 7.0.0
hooks:
- id: flake8
additional_dependencies: [flake8-encodings, flake8-builtins, flake8-print, flake8-no-nested-comprehensions]
additional_dependencies: [flake8-encodings, flake8-builtins, flake8-length, flake8-print, flake8-no-nested-comprehensions]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.10.0
rev: v1.8.0
hooks:
- id: mypy
additional_dependencies: [types-setuptools, types-requests, settngs>=0.10.0]

View File

@ -1,468 +0,0 @@
# mypy: disable-error-code="no-redef"
from __future__ import annotations
try:
from urllib3.exceptions import HTTPError, LocationParseError, LocationValueError
from urllib3.util import Url, parse_url
except ImportError:
import re
import typing
class HTTPError(Exception):
"""Base exception used by this module."""
class LocationValueError(ValueError, HTTPError):
"""Raised when there is something wrong with a given URL input."""
class LocationParseError(LocationValueError):
"""Raised when get_host or similar fails to parse the URL input."""
def __init__(self, location: str) -> None:
message = f"Failed to parse: {location}"
super().__init__(message)
self.location = location
def to_str(x: str | bytes, encoding: str | None = None, errors: str | None = None) -> str:
if isinstance(x, str):
return x
elif not isinstance(x, bytes):
raise TypeError(f"not expecting type {type(x).__name__}")
if encoding or errors:
return x.decode(encoding or "utf-8", errors=errors or "strict")
return x.decode()
# We only want to normalize urls with an HTTP(S) scheme.
# urllib3 infers URLs without a scheme (None) to be http.
_NORMALIZABLE_SCHEMES = ("http", "https", None)
# Almost all of these patterns were derived from the
# 'rfc3986' module: https://github.com/python-hyper/rfc3986
_PERCENT_RE = re.compile(r"%[a-fA-F0-9]{2}")
_SCHEME_RE = re.compile(r"^(?:[a-zA-Z][a-zA-Z0-9+-]*:|/)")
_URI_RE = re.compile(
r"^(?:([a-zA-Z][a-zA-Z0-9+.-]*):)?" r"(?://([^\\/?#]*))?" r"([^?#]*)" r"(?:\?([^#]*))?" r"(?:#(.*))?$",
re.UNICODE | re.DOTALL,
)
_IPV4_PAT = r"(?:[0-9]{1,3}\.){3}[0-9]{1,3}"
_HEX_PAT = "[0-9A-Fa-f]{1,4}"
_LS32_PAT = "(?:{hex}:{hex}|{ipv4})".format(hex=_HEX_PAT, ipv4=_IPV4_PAT)
_subs = {"hex": _HEX_PAT, "ls32": _LS32_PAT}
_variations = [
# 6( h16 ":" ) ls32
"(?:%(hex)s:){6}%(ls32)s",
# "::" 5( h16 ":" ) ls32
"::(?:%(hex)s:){5}%(ls32)s",
# [ h16 ] "::" 4( h16 ":" ) ls32
"(?:%(hex)s)?::(?:%(hex)s:){4}%(ls32)s",
# [ *1( h16 ":" ) h16 ] "::" 3( h16 ":" ) ls32
"(?:(?:%(hex)s:)?%(hex)s)?::(?:%(hex)s:){3}%(ls32)s",
# [ *2( h16 ":" ) h16 ] "::" 2( h16 ":" ) ls32
"(?:(?:%(hex)s:){0,2}%(hex)s)?::(?:%(hex)s:){2}%(ls32)s",
# [ *3( h16 ":" ) h16 ] "::" h16 ":" ls32
"(?:(?:%(hex)s:){0,3}%(hex)s)?::%(hex)s:%(ls32)s",
# [ *4( h16 ":" ) h16 ] "::" ls32
"(?:(?:%(hex)s:){0,4}%(hex)s)?::%(ls32)s",
# [ *5( h16 ":" ) h16 ] "::" h16
"(?:(?:%(hex)s:){0,5}%(hex)s)?::%(hex)s",
# [ *6( h16 ":" ) h16 ] "::"
"(?:(?:%(hex)s:){0,6}%(hex)s)?::",
]
_UNRESERVED_PAT = r"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._\-~"
_IPV6_PAT = "(?:" + "|".join([x % _subs for x in _variations]) + ")"
_ZONE_ID_PAT = "(?:%25|%)(?:[" + _UNRESERVED_PAT + "]|%[a-fA-F0-9]{2})+"
_IPV6_ADDRZ_PAT = r"\[" + _IPV6_PAT + r"(?:" + _ZONE_ID_PAT + r")?\]"
_REG_NAME_PAT = r"(?:[^\[\]%:/?#]|%[a-fA-F0-9]{2})*"
_TARGET_RE = re.compile(r"^(/[^?#]*)(?:\?([^#]*))?(?:#.*)?$")
_IPV4_RE = re.compile("^" + _IPV4_PAT + "$")
_IPV6_RE = re.compile("^" + _IPV6_PAT + "$")
_IPV6_ADDRZ_RE = re.compile("^" + _IPV6_ADDRZ_PAT + "$")
_BRACELESS_IPV6_ADDRZ_RE = re.compile("^" + _IPV6_ADDRZ_PAT[2:-2] + "$")
_ZONE_ID_RE = re.compile("(" + _ZONE_ID_PAT + r")\]$")
_HOST_PORT_PAT = ("^(%s|%s|%s)(?::0*?(|0|[1-9][0-9]{0,4}))?$") % (
_REG_NAME_PAT,
_IPV4_PAT,
_IPV6_ADDRZ_PAT,
)
_HOST_PORT_RE = re.compile(_HOST_PORT_PAT, re.UNICODE | re.DOTALL)
_UNRESERVED_CHARS = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-~")
_SUB_DELIM_CHARS = set("!$&'()*+,;=")
_USERINFO_CHARS = _UNRESERVED_CHARS | _SUB_DELIM_CHARS | {":"}
_PATH_CHARS = _USERINFO_CHARS | {"@", "/"}
_QUERY_CHARS = _FRAGMENT_CHARS = _PATH_CHARS | {"?"}
class Url(
typing.NamedTuple(
"Url",
[
("scheme", typing.Optional[str]),
("auth", typing.Optional[str]),
("host", typing.Optional[str]),
("port", typing.Optional[int]),
("path", typing.Optional[str]),
("query", typing.Optional[str]),
("fragment", typing.Optional[str]),
],
)
):
"""
Data structure for representing an HTTP URL. Used as a return value for
:func:`parse_url`. Both the scheme and host are normalized as they are
both case-insensitive according to RFC 3986.
"""
def __new__( # type: ignore[no-untyped-def]
cls,
scheme: str | None = None,
auth: str | None = None,
host: str | None = None,
port: int | None = None,
path: str | None = None,
query: str | None = None,
fragment: str | None = None,
):
if path and not path.startswith("/"):
path = "/" + path
if scheme is not None:
scheme = scheme.lower()
return super().__new__(cls, scheme, auth, host, port, path, query, fragment)
@property
def hostname(self) -> str | None:
"""For backwards-compatibility with urlparse. We're nice like that."""
return self.host
@property
def request_uri(self) -> str:
"""Absolute path including the query string."""
uri = self.path or "/"
if self.query is not None:
uri += "?" + self.query
return uri
@property
def authority(self) -> str | None:
"""
Authority component as defined in RFC 3986 3.2.
This includes userinfo (auth), host and port.
i.e.
userinfo@host:port
"""
userinfo = self.auth
netloc = self.netloc
if netloc is None or userinfo is None:
return netloc
else:
return f"{userinfo}@{netloc}"
@property
def netloc(self) -> str | None:
"""
Network location including host and port.
If you need the equivalent of urllib.parse's ``netloc``,
use the ``authority`` property instead.
"""
if self.host is None:
return None
if self.port:
return f"{self.host}:{self.port}"
return self.host
@property
def url(self) -> str:
"""
Convert self into a url
This function should more or less round-trip with :func:`.parse_url`. The
returned url may not be exactly the same as the url inputted to
:func:`.parse_url`, but it should be equivalent by the RFC (e.g., urls
with a blank port will have : removed).
Example:
.. code-block:: python
import urllib3
U = urllib3.util.parse_url("https://google.com/mail/")
print(U.url)
# "https://google.com/mail/"
print( urllib3.util.Url("https", "username:password",
"host.com", 80, "/path", "query", "fragment"
).url
)
# "https://username:password@host.com:80/path?query#fragment"
"""
scheme, auth, host, port, path, query, fragment = self
url = ""
# We use "is not None" we want things to happen with empty strings (or 0 port)
if scheme is not None:
url += scheme + "://"
if auth is not None:
url += auth + "@"
if host is not None:
url += host
if port is not None:
url += ":" + str(port)
if path is not None:
url += path
if query is not None:
url += "?" + query
if fragment is not None:
url += "#" + fragment
return url
def __str__(self) -> str:
return self.url
@typing.overload
def _encode_invalid_chars(component: str, allowed_chars: typing.Container[str]) -> str: # Abstract
...
@typing.overload
def _encode_invalid_chars(component: None, allowed_chars: typing.Container[str]) -> None: # Abstract
...
def _encode_invalid_chars(component: str | None, allowed_chars: typing.Container[str]) -> str | None:
"""Percent-encodes a URI component without reapplying
onto an already percent-encoded component.
"""
if component is None:
return component
component = to_str(component)
# Normalize existing percent-encoded bytes.
# Try to see if the component we're encoding is already percent-encoded
# so we can skip all '%' characters but still encode all others.
component, percent_encodings = _PERCENT_RE.subn(lambda match: match.group(0).upper(), component)
uri_bytes = component.encode("utf-8", "surrogatepass")
is_percent_encoded = percent_encodings == uri_bytes.count(b"%")
encoded_component = bytearray()
for i in range(0, len(uri_bytes)):
# Will return a single character bytestring
byte = uri_bytes[i : i + 1]
byte_ord = ord(byte)
if (is_percent_encoded and byte == b"%") or (byte_ord < 128 and byte.decode() in allowed_chars):
encoded_component += byte
continue
encoded_component.extend(b"%" + (hex(byte_ord)[2:].encode().zfill(2).upper()))
return encoded_component.decode()
def _remove_path_dot_segments(path: str) -> str:
# See http://tools.ietf.org/html/rfc3986#section-5.2.4 for pseudo-code
segments = path.split("/") # Turn the path into a list of segments
output = [] # Initialize the variable to use to store output
for segment in segments:
# '.' is the current directory, so ignore it, it is superfluous
if segment == ".":
continue
# Anything other than '..', should be appended to the output
if segment != "..":
output.append(segment)
# In this case segment == '..', if we can, we should pop the last
# element
elif output:
output.pop()
# If the path starts with '/' and the output is empty or the first string
# is non-empty
if path.startswith("/") and (not output or output[0]):
output.insert(0, "")
# If the path starts with '/.' or '/..' ensure we add one more empty
# string to add a trailing '/'
if path.endswith(("/.", "/..")):
output.append("")
return "/".join(output)
@typing.overload
def _normalize_host(host: None, scheme: str | None) -> None: ...
@typing.overload
def _normalize_host(host: str, scheme: str | None) -> str: ...
def _normalize_host(host: str | None, scheme: str | None) -> str | None:
if host:
if scheme in _NORMALIZABLE_SCHEMES:
is_ipv6 = _IPV6_ADDRZ_RE.match(host)
if is_ipv6:
# IPv6 hosts of the form 'a::b%zone' are encoded in a URL as
# such per RFC 6874: 'a::b%25zone'. Unquote the ZoneID
# separator as necessary to return a valid RFC 4007 scoped IP.
match = _ZONE_ID_RE.search(host)
if match:
start, end = match.span(1)
zone_id = host[start:end]
if zone_id.startswith("%25") and zone_id != "%25":
zone_id = zone_id[3:]
else:
zone_id = zone_id[1:]
zone_id = _encode_invalid_chars(zone_id, _UNRESERVED_CHARS)
return f"{host[:start].lower()}%{zone_id}{host[end:]}"
else:
return host.lower()
elif not _IPV4_RE.match(host):
return to_str(
b".".join([_idna_encode(label) for label in host.split(".")]),
"ascii",
)
return host
def _idna_encode(name: str) -> bytes:
if not name.isascii():
try:
import idna
except ImportError:
raise LocationParseError("Unable to parse URL without the 'idna' module") from None
try:
return idna.encode(name.lower(), strict=True, std3_rules=True)
except idna.IDNAError:
raise LocationParseError(f"Name '{name}' is not a valid IDNA label") from None
return name.lower().encode("ascii")
def _encode_target(target: str) -> str:
"""Percent-encodes a request target so that there are no invalid characters
Pre-condition for this function is that 'target' must start with '/'.
If that is the case then _TARGET_RE will always produce a match.
"""
match = _TARGET_RE.match(target)
if not match: # Defensive:
raise LocationParseError(f"{target!r} is not a valid request URI")
path, query = match.groups()
encoded_target = _encode_invalid_chars(path, _PATH_CHARS)
if query is not None:
query = _encode_invalid_chars(query, _QUERY_CHARS)
encoded_target += "?" + query
return encoded_target
def parse_url(url: str) -> Url:
"""
Given a url, return a parsed :class:`.Url` namedtuple. Best-effort is
performed to parse incomplete urls. Fields not provided will be None.
This parser is RFC 3986 and RFC 6874 compliant.
The parser logic and helper functions are based heavily on
work done in the ``rfc3986`` module.
:param str url: URL to parse into a :class:`.Url` namedtuple.
Partly backwards-compatible with :mod:`urllib.parse`.
Example:
.. code-block:: python
import urllib3
print( urllib3.util.parse_url('http://google.com/mail/'))
# Url(scheme='http', host='google.com', port=None, path='/mail/', ...)
print( urllib3.util.parse_url('google.com:80'))
# Url(scheme=None, host='google.com', port=80, path=None, ...)
print( urllib3.util.parse_url('/foo?bar'))
# Url(scheme=None, host=None, port=None, path='/foo', query='bar', ...)
"""
if not url:
# Empty
return Url()
source_url = url
if not _SCHEME_RE.search(url):
url = "//" + url
scheme: str | None
authority: str | None
auth: str | None
host: str | None
port: str | None
port_int: int | None
path: str | None
query: str | None
fragment: str | None
try:
scheme, authority, path, query, fragment = _URI_RE.match(url).groups() # type: ignore[union-attr]
normalize_uri = scheme is None or scheme.lower() in _NORMALIZABLE_SCHEMES
if scheme:
scheme = scheme.lower()
if authority:
auth, _, host_port = authority.rpartition("@")
auth = auth or None
host, port = _HOST_PORT_RE.match(host_port).groups() # type: ignore[union-attr]
if auth and normalize_uri:
auth = _encode_invalid_chars(auth, _USERINFO_CHARS)
if port == "":
port = None
else:
auth, host, port = None, None, None
if port is not None:
port_int = int(port)
if not (0 <= port_int <= 65535):
raise LocationParseError(url)
else:
port_int = None
host = _normalize_host(host, scheme)
if normalize_uri and path:
path = _remove_path_dot_segments(path)
path = _encode_invalid_chars(path, _PATH_CHARS)
if normalize_uri and query:
query = _encode_invalid_chars(query, _QUERY_CHARS)
if normalize_uri and fragment:
fragment = _encode_invalid_chars(fragment, _FRAGMENT_CHARS)
except (ValueError, AttributeError) as e:
raise LocationParseError(source_url) from e
# For the sake of backwards compatibility we put empty
# string values for path if there are any defined values
# beyond the path in the URL.
# TODO: Remove this when we break backwards compatibility.
if not path:
if query is not None or fragment is not None:
path = ""
else:
path = None
return Url(
scheme=scheme,
auth=auth,
host=host,
port=port_int,
path=path,
query=query,
fragment=fragment,
)
__all__ = ("Url", "parse_url", "HTTPError", "LocationParseError", "LocationValueError")

View File

@ -24,7 +24,7 @@ import pathlib
import shutil
import sys
import traceback
from collections.abc import Iterable
from collections.abc import Sequence
from typing import TYPE_CHECKING
from comicapi import utils
@ -43,7 +43,7 @@ archivers: list[type[Archiver]] = []
metadata_styles: dict[str, Metadata] = {}
def load_archive_plugins(local_plugins: Iterable[EntryPoint] = tuple()) -> None:
def load_archive_plugins(local_plugins: Sequence[EntryPoint] = tuple()) -> None:
if not archivers:
if sys.version_info < (3, 10):
from importlib_metadata import entry_points
@ -70,7 +70,7 @@ def load_archive_plugins(local_plugins: Iterable[EntryPoint] = tuple()) -> None:
archivers.extend(builtin)
def load_metadata_plugins(version: str = f"ComicAPI/{version}", local_plugins: Iterable[EntryPoint] = tuple()) -> None:
def load_metadata_plugins(version: str = f"ComicAPI/{version}", local_plugins: Sequence[EntryPoint] = tuple()) -> None:
if not metadata_styles:
if sys.version_info < (3, 10):
from importlib_metadata import entry_points
@ -162,7 +162,7 @@ class ComicArchive:
self.page_list.clear()
self.md.clear()
def load_cache(self, style_list: Iterable[str]) -> None:
def load_cache(self, style_list: list[str]) -> None:
for style in style_list:
if style in metadata_styles:
md = metadata_styles[style].get_metadata(self.archiver)
@ -364,7 +364,7 @@ class ComicArchive:
def metadata_from_filename(
self,
parser: utils.Parser = utils.Parser.ORIGINAL,
complicated_parser: bool = False,
remove_c2c: bool = False,
remove_fcbd: bool = False,
remove_publisher: bool = False,
@ -376,7 +376,7 @@ class ComicArchive:
filename_info = utils.parse_filename(
self.path.name,
parser=parser,
complicated_parser=complicated_parser,
remove_c2c=remove_c2c,
remove_fcbd=remove_fcbd,
remove_publisher=remove_publisher,

View File

@ -146,8 +146,7 @@ class Lexer:
return False
# AcceptRun consumes a run of runes from the valid set.
def accept_run(self, valid: str | Callable[[str], bool]) -> bool:
initial = self.pos
def accept_run(self, valid: str | Callable[[str], bool]) -> None:
if isinstance(valid, str):
while self.get() in valid:
continue
@ -156,13 +155,11 @@ class Lexer:
continue
self.backup()
return initial != self.pos
def scan_number(self) -> bool:
digits = "0123456789.,"
if not self.accept_run(lambda x: x.isnumeric() or x in digits):
return False
self.accept_run(digits)
if self.input[self.pos] == ".":
self.backup()
self.accept_run(str.isalpha)
@ -220,7 +217,7 @@ def lex_filename(lex: Lexer) -> LexerFunc | None:
elif r == "#":
if lex.allow_issue_start_with_letter and is_alpha_numeric(lex.peek()):
return lex_issue_number
elif lex.peek().isnumeric() or lex.peek() in "-+.":
elif lex.peek().isdigit() or lex.peek() in "-+.":
return lex_issue_number
lex.emit(ItemType.Symbol)
elif is_operator(r):
@ -347,7 +344,7 @@ def lex_number(lex: Lexer) -> LexerFunc | None:
if lex.input[lex.start] == "#":
lex.emit(ItemType.IssueNumber)
elif not lex.input[lex.pos].isnumeric():
elif not lex.input[lex.pos].isdigit():
# Assume that 80th is just text and not a number
lex.emit(ItemType.Text)
else:

View File

@ -25,23 +25,15 @@ import copy
import dataclasses
import logging
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any, TypedDict, Union, overload
from typing import Any, TypedDict
from typing_extensions import NamedTuple, Required
from comicapi import merge, utils
from comicapi._url import Url, parse_url
from comicapi.utils import norm_fold
if TYPE_CHECKING:
Union
from comicapi import utils
logger = logging.getLogger(__name__)
REMOVE = object()
class PageType:
"""
These page info classes are exactly the same as the CIX scheme, since
@ -72,7 +64,10 @@ class ImageMetadata(TypedDict, total=False):
width: str
Credit = merge.Credit
class Credit(TypedDict):
person: str
role: str
primary: bool
@dataclasses.dataclass
@ -138,7 +133,7 @@ class GenericMetadata:
year: int | None = None
language: str | None = None # 2 letter iso code
country: str | None = None
web_links: list[Url] = dataclasses.field(default_factory=list)
web_link: str | None = None
format: str | None = None
manga: str | None = None
black_and_white: bool | None = None
@ -207,87 +202,96 @@ class GenericMetadata:
new_md.__post_init__()
return new_md
def overlay(
self, new_md: GenericMetadata, mode: merge.Mode = merge.Mode.OVERLAY, merge_lists: bool = False
) -> None:
"""Overlay a new metadata object on this one"""
def overlay(self, new_md: GenericMetadata) -> None:
"""Overlay a metadata object on this one
attribute_merge = merge.attribute[mode]
list_merge = merge.lists[mode]
That is, when the new object has non-None values, over-write them
to this one.
"""
def assign(old: Any, new: Any, attribute_merge: Any = attribute_merge) -> Any:
if new is REMOVE:
return None
return attribute_merge(old, new)
def assign_list(old: list[Any] | set[Any], new: list[Any] | set[Any], list_merge: Any = list_merge) -> Any:
if new is REMOVE:
old.clear()
return old
if merge_lists:
return list_merge(old, new)
else:
return assign(old, new)
def assign(cur: str, new: Any) -> None:
if new is not None:
if isinstance(new, str) and len(new) == 0:
if isinstance(getattr(self, cur), (list, set)):
getattr(self, cur).clear()
else:
setattr(self, cur, None)
elif isinstance(new, (list, set)) and len(new) == 0:
pass
else:
setattr(self, cur, new)
if not new_md.is_empty:
self.is_empty = False
self.tag_origin = assign(self.tag_origin, new_md.tag_origin) # TODO use and purpose now?
self.issue_id = assign(self.issue_id, new_md.issue_id)
self.series_id = assign(self.series_id, new_md.series_id)
assign("tag_origin", new_md.tag_origin)
assign("issue_id", new_md.issue_id)
assign("series_id", new_md.series_id)
self.series = assign(self.series, new_md.series)
assign("series", new_md.series)
assign("series_aliases", new_md.series_aliases)
assign("issue", new_md.issue)
assign("issue_count", new_md.issue_count)
assign("title", new_md.title)
assign("title_aliases", new_md.title_aliases)
assign("volume", new_md.volume)
assign("volume_count", new_md.volume_count)
assign("genres", new_md.genres)
assign("description", new_md.description)
assign("notes", new_md.notes)
self.series_aliases = assign_list(self.series_aliases, new_md.series_aliases)
self.issue = assign(self.issue, new_md.issue)
self.issue_count = assign(self.issue_count, new_md.issue_count)
self.title = assign(self.title, new_md.title)
self.title_aliases = assign_list(self.title_aliases, new_md.title_aliases)
self.volume = assign(self.volume, new_md.volume)
self.volume_count = assign(self.volume_count, new_md.volume_count)
self.genres = assign_list(self.genres, new_md.genres)
self.description = assign(self.description, new_md.description)
self.notes = assign(self.notes, new_md.notes)
assign("alternate_series", new_md.alternate_series)
assign("alternate_number", new_md.alternate_number)
assign("alternate_count", new_md.alternate_count)
assign("story_arcs", new_md.story_arcs)
assign("series_groups", new_md.series_groups)
self.alternate_series = assign(self.alternate_series, new_md.alternate_series)
self.alternate_number = assign(self.alternate_number, new_md.alternate_number)
self.alternate_count = assign(self.alternate_count, new_md.alternate_count)
self.story_arcs = assign_list(self.story_arcs, new_md.story_arcs)
self.series_groups = assign_list(self.series_groups, new_md.series_groups)
assign("publisher", new_md.publisher)
assign("imprint", new_md.imprint)
assign("day", new_md.day)
assign("month", new_md.month)
assign("year", new_md.year)
assign("language", new_md.language)
assign("country", new_md.country)
assign("web_link", new_md.web_link)
assign("format", new_md.format)
assign("manga", new_md.manga)
assign("black_and_white", new_md.black_and_white)
assign("maturity_rating", new_md.maturity_rating)
assign("critical_rating", new_md.critical_rating)
assign("scan_info", new_md.scan_info)
self.publisher = assign(self.publisher, new_md.publisher)
self.imprint = assign(self.imprint, new_md.imprint)
self.day = assign(self.day, new_md.day)
self.month = assign(self.month, new_md.month)
self.year = assign(self.year, new_md.year)
self.language = assign(self.language, new_md.language)
self.country = assign(self.country, new_md.country)
self.web_links = assign_list(self.web_links, new_md.web_links)
self.format = assign(self.format, new_md.format)
self.manga = assign(self.manga, new_md.manga)
self.black_and_white = assign(self.black_and_white, new_md.black_and_white)
self.maturity_rating = assign(self.maturity_rating, new_md.maturity_rating)
self.critical_rating = assign(self.critical_rating, new_md.critical_rating)
self.scan_info = assign(self.scan_info, new_md.scan_info)
assign("tags", new_md.tags)
assign("pages", new_md.pages)
assign("page_count", new_md.page_count)
self.tags = assign_list(self.tags, new_md.tags)
assign("characters", new_md.characters)
assign("teams", new_md.teams)
assign("locations", new_md.locations)
self.overlay_credits(new_md.credits)
self.characters = assign_list(self.characters, new_md.characters)
self.teams = assign_list(self.teams, new_md.teams)
self.locations = assign_list(self.locations, new_md.locations)
[self.add_credit(c) for c in assign_list(self.credits, new_md.credits)]
assign("price", new_md.price)
assign("is_version_of", new_md.is_version_of)
assign("rights", new_md.rights)
assign("identifier", new_md.identifier)
assign("last_mark", new_md.last_mark)
assign("_cover_image", new_md._cover_image)
assign("_alternate_images", new_md._alternate_images)
self.price = assign(self.price, new_md.price)
self.is_version_of = assign(self.is_version_of, new_md.is_version_of)
self.rights = assign(self.rights, new_md.rights)
self.identifier = assign(self.identifier, new_md.identifier)
self.last_mark = assign(self.last_mark, new_md.last_mark)
self._cover_image = assign(self._cover_image, new_md._cover_image)
self._alternate_images = assign_list(self._alternate_images, new_md._alternate_images)
def overlay_credits(self, new_credits: list[Credit]) -> None:
if isinstance(new_credits, str) and len(new_credits) == 0:
self.credits = []
for c in new_credits:
primary = bool("primary" in c and c["primary"])
self.pages = assign_list(self.pages, new_md.pages)
self.page_count = assign(self.page_count, new_md.page_count)
# Remove credit role if person is blank
if c["person"] == "":
for r in reversed(self.credits):
if r["role"].casefold() == c["role"].casefold():
self.credits.remove(r)
# otherwise, add it!
else:
self.add_credit(c["person"], c["role"], primary)
def apply_default_page_list(self, page_list: Sequence[str]) -> None:
# generate a default page list, with the first page marked as the cover
@ -332,35 +336,15 @@ class GenericMetadata:
return coverlist
@overload
def add_credit(self, person: Credit) -> None: ...
@overload
def add_credit(self, person: str, role: str, primary: bool = False) -> None: ...
def add_credit(self, person: str | Credit, role: str | None = None, primary: bool = False) -> None:
credit: Credit
if isinstance(person, Credit):
credit = person
else:
assert role is not None
credit = Credit(person=person, role=role, primary=primary)
if credit.role is None:
raise TypeError("GenericMetadata.add_credit takes either a Credit object or a person name and role")
if credit.person == "":
return
person = norm_fold(credit.person)
role = norm_fold(credit.role)
def add_credit(self, person: str, role: str, primary: bool = False) -> None:
credit = Credit(person=person, role=role, primary=primary)
# look to see if it's not already there...
found = False
for c in self.credits:
if norm_fold(c.person) == person and norm_fold(c.role) == role:
if c["person"].casefold() == person.casefold() and c["role"].casefold() == role.casefold():
# no need to add it. just adjust the "primary" flag as needed
c.primary = c.primary or primary
c["primary"] = primary
found = True
break
@ -370,10 +354,12 @@ class GenericMetadata:
def get_primary_credit(self, role: str) -> str:
primary = ""
for credit in self.credits:
if (primary == "" and credit.role.casefold() == role.casefold()) or (
credit.role.casefold() == role.casefold() and credit.primary
if "role" not in credit or "person" not in credit:
continue
if (primary == "" and credit["role"].casefold() == role.casefold()) or (
credit["role"].casefold() == role.casefold() and "primary" in credit and credit["primary"]
):
primary = credit.person
primary = credit["person"]
return primary
def __str__(self) -> str:
@ -388,53 +374,56 @@ class GenericMetadata:
elif val is not None:
vals.append((tag, val))
add_string("series", self.series)
add_string("issue", self.issue)
add_string("issue_count", self.issue_count)
add_string("title", self.title)
add_string("publisher", self.publisher)
add_string("year", self.year)
add_string("month", self.month)
add_string("day", self.day)
add_string("volume", self.volume)
add_string("volume_count", self.volume_count)
add_string("genres", ", ".join(self.genres))
add_string("language", self.language)
add_string("country", self.country)
add_string("critical_rating", self.critical_rating)
add_string("alternate_series", self.alternate_series)
add_string("alternate_number", self.alternate_number)
add_string("alternate_count", self.alternate_count)
add_string("imprint", self.imprint)
add_string("web_links", [str(x) for x in self.web_links])
add_string("format", self.format)
add_string("manga", self.manga)
def add_attr_string(tag: str) -> None:
add_string(tag, getattr(self, tag))
add_string("price", self.price)
add_string("is_version_of", self.is_version_of)
add_string("rights", self.rights)
add_string("identifier", self.identifier)
add_string("last_mark", self.last_mark)
add_attr_string("series")
add_attr_string("issue")
add_attr_string("issue_count")
add_attr_string("title")
add_attr_string("publisher")
add_attr_string("year")
add_attr_string("month")
add_attr_string("day")
add_attr_string("volume")
add_attr_string("volume_count")
add_string("genres", ", ".join(self.genres))
add_attr_string("language")
add_attr_string("country")
add_attr_string("critical_rating")
add_attr_string("alternate_series")
add_attr_string("alternate_number")
add_attr_string("alternate_count")
add_attr_string("imprint")
add_attr_string("web_link")
add_attr_string("format")
add_attr_string("manga")
add_attr_string("price")
add_attr_string("is_version_of")
add_attr_string("rights")
add_attr_string("identifier")
add_attr_string("last_mark")
if self.black_and_white:
add_string("black_and_white", self.black_and_white)
add_string("maturity_rating", self.maturity_rating)
add_string("story_arcs", self.story_arcs)
add_string("series_groups", self.series_groups)
add_string("scan_info", self.scan_info)
add_attr_string("black_and_white")
add_attr_string("maturity_rating")
add_attr_string("story_arcs")
add_attr_string("series_groups")
add_attr_string("scan_info")
add_string("characters", ", ".join(self.characters))
add_string("teams", ", ".join(self.teams))
add_string("locations", ", ".join(self.locations))
add_string("description", self.description)
add_string("notes", self.notes)
add_attr_string("description")
add_attr_string("notes")
add_string("tags", ", ".join(self.tags))
for c in self.credits:
primary = ""
if c.primary:
if "primary" in c and c["primary"]:
primary = " [P]"
add_string("credit", c.role + ": " + c.person + primary)
add_string("credit", c["role"] + ": " + c["person"] + primary)
# find the longest field name
flen = 0
@ -498,9 +487,7 @@ md_test: GenericMetadata = GenericMetadata(
alternate_count=7,
imprint="craphound.com",
notes="Tagged with ComicTagger 1.3.2a5 using info from Comic Vine on 2022-04-16 15:52:26. [Issue ID 140529]",
web_links=[
parse_url("https://comicvine.gamespot.com/cory-doctorows-futuristic-tales-of-the-here-and-no/4000-140529/")
],
web_link="https://comicvine.gamespot.com/cory-doctorows-futuristic-tales-of-the-here-and-no/4000-140529/",
format="Series",
manga="No",
black_and_white=None,
@ -564,15 +551,3 @@ md_test: GenericMetadata = GenericMetadata(
last_mark=None,
_cover_image=None,
)
__all__ = (
"Url",
"parse_url",
"PageType",
"ImageMetadata",
"Credit",
"ComicSeries",
"TagOrigin",
"GenericMetadata",
)

View File

@ -1,105 +0,0 @@
from __future__ import annotations
import dataclasses
import sys
from collections import defaultdict
from collections.abc import Collection
from enum import Enum, auto
from typing import Any
from comicapi.utils import norm_fold
if sys.version_info < (3, 11):
class StrEnum(str, Enum):
"""
Enum where members are also (and must be) strings
"""
def __new__(cls, *values: Any) -> Any:
"values must already be of type `str`"
if len(values) > 3:
raise TypeError(f"too many arguments for str(): {values!r}")
if len(values) == 1:
# it must be a string
if not isinstance(values[0], str):
raise TypeError(f"{values[0]!r} is not a string")
if len(values) >= 2:
# check that encoding argument is a string
if not isinstance(values[1], str):
raise TypeError(f"encoding must be a string, not {values[1]!r}")
if len(values) == 3:
# check that errors argument is a string
if not isinstance(values[2], str):
raise TypeError("errors must be a string, not %r" % (values[2]))
value = str(*values)
member = str.__new__(cls, value)
member._value_ = value
return member
@staticmethod
def _generate_next_value_(name: str, start: int, count: int, last_values: Any) -> str:
"""
Return the lower-cased version of the member name.
"""
return name.lower()
else:
from enum import StrEnum
@dataclasses.dataclass
class Credit:
person: str = ""
role: str = ""
primary: bool = False
class Mode(StrEnum):
OVERLAY = auto()
ADD_MISSING = auto()
def merge_lists(old: Collection[Any], new: Collection[Any]) -> list[Any] | set[Any]:
"""Dedupes normalised (NFKD), casefolded values using 'new' values on collisions"""
if len(new) == 0:
return old if isinstance(old, set) else list(old)
if len(old) == 0:
return new if isinstance(new, set) else list(new)
# Create dict to preserve case
new_dict = {norm_fold(str(n)): n for n in new}
old_dict = {norm_fold(str(c)): c for c in old}
old_dict.update(new_dict)
if isinstance(old, set):
return set(old_dict.values())
return list(old_dict.values())
def overlay(old: Any, new: Any) -> Any:
"""overlay - When the `new` object is not empty, replace `old` with `new`."""
if new is None or (isinstance(new, Collection) and len(new) == 0):
return old
return new
attribute = defaultdict(
lambda: overlay,
{
Mode.OVERLAY: overlay,
Mode.ADD_MISSING: lambda old, new: overlay(new, old),
},
)
lists = defaultdict(
lambda: overlay,
{
Mode.OVERLAY: merge_lists,
Mode.ADD_MISSING: lambda old, new: merge_lists(new, old),
},
)

View File

@ -199,26 +199,26 @@ class CoMet(Metadata):
# loop thru credits, and build a list for each role that CoMet supports
for credit in metadata.credits:
if credit.role.casefold() in set(GenericMetadata.writer_synonyms):
ET.SubElement(root, "writer").text = str(credit.person)
if credit["role"].casefold() in set(GenericMetadata.writer_synonyms):
ET.SubElement(root, "writer").text = str(credit["person"])
if credit.role.casefold() in set(GenericMetadata.penciller_synonyms):
ET.SubElement(root, "penciller").text = str(credit.person)
if credit["role"].casefold() in set(GenericMetadata.penciller_synonyms):
ET.SubElement(root, "penciller").text = str(credit["person"])
if credit.role.casefold() in set(GenericMetadata.inker_synonyms):
ET.SubElement(root, "inker").text = str(credit.person)
if credit["role"].casefold() in set(GenericMetadata.inker_synonyms):
ET.SubElement(root, "inker").text = str(credit["person"])
if credit.role.casefold() in set(GenericMetadata.colorist_synonyms):
ET.SubElement(root, "colorist").text = str(credit.person)
if credit["role"].casefold() in set(GenericMetadata.colorist_synonyms):
ET.SubElement(root, "colorist").text = str(credit["person"])
if credit.role.casefold() in set(GenericMetadata.letterer_synonyms):
ET.SubElement(root, "letterer").text = str(credit.person)
if credit["role"].casefold() in set(GenericMetadata.letterer_synonyms):
ET.SubElement(root, "letterer").text = str(credit["person"])
if credit.role.casefold() in set(GenericMetadata.cover_synonyms):
ET.SubElement(root, "coverDesigner").text = str(credit.person)
if credit["role"].casefold() in set(GenericMetadata.cover_synonyms):
ET.SubElement(root, "coverDesigner").text = str(credit["person"])
if credit.role.casefold() in set(GenericMetadata.editor_synonyms):
ET.SubElement(root, "editor").text = str(credit.person)
if credit["role"].casefold() in set(GenericMetadata.editor_synonyms):
ET.SubElement(root, "editor").text = str(credit["person"])
ET.indent(root)

View File

@ -47,12 +47,6 @@ _CBILiteralType = Literal[
]
class credit(TypedDict):
person: str
role: str
primary: bool
class _ComicBookInfoJson(TypedDict, total=False):
series: str
title: str
@ -67,7 +61,7 @@ class _ComicBookInfoJson(TypedDict, total=False):
genre: str
language: str
country: str
credits: list[credit]
credits: list[Credit]
tags: list[str]
comments: str
@ -223,7 +217,7 @@ class ComicBookInfo(Metadata):
assign("language", utils.xlate(utils.get_language_from_iso(metadata.language)))
assign("country", utils.xlate(metadata.country))
assign("rating", utils.xlate_int(metadata.critical_rating))
assign("credits", [credit(person=c.person, role=c.role, primary=c.primary) for c in metadata.credits])
assign("credits", metadata.credits)
assign("tags", list(metadata.tags))
return cbi_container

View File

@ -57,7 +57,7 @@ class ComicRack(Metadata):
"month",
"year",
"language",
"web_links",
"web_link",
"format",
"manga",
"black_and_white",
@ -187,26 +187,26 @@ class ComicRack(Metadata):
# first, loop thru credits, and build a list for each role that CIX
# supports
for credit in metadata.credits:
if credit.role.casefold() in set(GenericMetadata.writer_synonyms):
credit_writer_list.append(credit.person.replace(",", ""))
if credit["role"].casefold() in set(GenericMetadata.writer_synonyms):
credit_writer_list.append(credit["person"].replace(",", ""))
if credit.role.casefold() in set(GenericMetadata.penciller_synonyms):
credit_penciller_list.append(credit.person.replace(",", ""))
if credit["role"].casefold() in set(GenericMetadata.penciller_synonyms):
credit_penciller_list.append(credit["person"].replace(",", ""))
if credit.role.casefold() in set(GenericMetadata.inker_synonyms):
credit_inker_list.append(credit.person.replace(",", ""))
if credit["role"].casefold() in set(GenericMetadata.inker_synonyms):
credit_inker_list.append(credit["person"].replace(",", ""))
if credit.role.casefold() in set(GenericMetadata.colorist_synonyms):
credit_colorist_list.append(credit.person.replace(",", ""))
if credit["role"].casefold() in set(GenericMetadata.colorist_synonyms):
credit_colorist_list.append(credit["person"].replace(",", ""))
if credit.role.casefold() in set(GenericMetadata.letterer_synonyms):
credit_letterer_list.append(credit.person.replace(",", ""))
if credit["role"].casefold() in set(GenericMetadata.letterer_synonyms):
credit_letterer_list.append(credit["person"].replace(",", ""))
if credit.role.casefold() in set(GenericMetadata.cover_synonyms):
credit_cover_list.append(credit.person.replace(",", ""))
if credit["role"].casefold() in set(GenericMetadata.cover_synonyms):
credit_cover_list.append(credit["person"].replace(",", ""))
if credit.role.casefold() in set(GenericMetadata.editor_synonyms):
credit_editor_list.append(credit.person.replace(",", ""))
if credit["role"].casefold() in set(GenericMetadata.editor_synonyms):
credit_editor_list.append(credit["person"].replace(",", ""))
assign("Series", md.series)
assign("Number", md.issue)
@ -229,7 +229,7 @@ class ComicRack(Metadata):
assign("Month", md.month)
assign("Year", md.year)
assign("LanguageISO", md.language)
assign("Web", " ".join(u.url for u in md.web_links))
assign("Web", md.web_link)
assign("Format", md.format)
assign("Manga", md.manga)
assign("BlackAndWhite", "Yes" if md.black_and_white else None)
@ -313,7 +313,7 @@ class ComicRack(Metadata):
md.month = utils.xlate_int(get("Month"))
md.year = utils.xlate_int(get("Year"))
md.language = utils.xlate(get("LanguageISO"))
md.web_links = utils.split_urls(utils.xlate(get("Web")))
md.web_link = utils.xlate(get("Web"))
md.format = utils.xlate(get("Format"))
md.manga = utils.xlate(get("Manga"))
md.maturity_rating = utils.xlate(get("AgeRating"))

View File

@ -20,23 +20,15 @@ import logging
import os
import pathlib
import platform
import sys
import unicodedata
from collections import defaultdict
from collections.abc import Iterable, Mapping
from enum import Enum, auto
from shutil import which # noqa: F401
from typing import Any, TypeVar, cast
from comicfn2dict import comicfn2dict
import comicapi.data
from comicapi import filenamelexer, filenameparser
from ._url import LocationParseError as LocationParseError # noqa: F401
from ._url import Url as Url
from ._url import parse_url as parse_url
try:
import icu
@ -45,55 +37,9 @@ try:
except ImportError:
icu_available = False
if sys.version_info < (3, 11):
class StrEnum(str, Enum):
"""
Enum where members are also (and must be) strings
"""
def __new__(cls, *values: Any) -> Any:
"values must already be of type `str`"
if len(values) > 3:
raise TypeError(f"too many arguments for str(): {values!r}")
if len(values) == 1:
# it must be a string
if not isinstance(values[0], str):
raise TypeError(f"{values[0]!r} is not a string")
if len(values) >= 2:
# check that encoding argument is a string
if not isinstance(values[1], str):
raise TypeError(f"encoding must be a string, not {values[1]!r}")
if len(values) == 3:
# check that errors argument is a string
if not isinstance(values[2], str):
raise TypeError("errors must be a string, not %r" % (values[2]))
value = str(*values)
member = str.__new__(cls, value)
member._value_ = value
return member
@staticmethod
def _generate_next_value_(name: str, start: int, count: int, last_values: Any) -> str:
"""
Return the lower-cased version of the member name.
"""
return name.lower()
else:
from enum import StrEnum
logger = logging.getLogger(__name__)
class Parser(StrEnum):
ORIGINAL = auto()
COMPLICATED = auto()
COMICFN2DICT = auto()
def _custom_key(tup: Any) -> Any:
import natsort
@ -121,7 +67,7 @@ def os_sorted(lst: Iterable[T]) -> Iterable[T]:
def parse_filename(
filename: str,
parser: Parser = Parser.ORIGINAL,
complicated_parser: bool = False,
remove_c2c: bool = False,
remove_fcbd: bool = False,
remove_publisher: bool = False,
@ -153,25 +99,7 @@ def parse_filename(
filename, ext = os.path.splitext(filename)
filename = " ".join(wordninja.split(filename)) + ext
fni = filenameparser.FilenameInfo(
alternate="",
annual=False,
archive="",
c2c=False,
fcbd=False,
format="",
issue="",
issue_count="",
publisher="",
remainder="",
series="",
title="",
volume="",
volume_count="",
year="",
)
if parser == Parser.COMPLICATED:
if complicated_parser:
lex = filenamelexer.Lex(filename, allow_issue_start_with_letter)
p = filenameparser.Parse(
lex.items,
@ -180,26 +108,7 @@ def parse_filename(
remove_publisher=remove_publisher,
protofolius_issue_number_scheme=protofolius_issue_number_scheme,
)
fni = p.filename_info
elif parser == Parser.COMICFN2DICT:
fn2d = comicfn2dict(filename)
fni = filenameparser.FilenameInfo(
alternate="",
annual=False,
archive=fn2d.get("ext", ""),
c2c=False,
fcbd=False,
issue=fn2d.get("issue", ""),
issue_count=fn2d.get("issue_count", ""),
publisher=fn2d.get("publisher", ""),
remainder=fn2d.get("scan_info", ""),
series=fn2d.get("series", ""),
title=fn2d.get("title", ""),
volume=fn2d.get("volume", ""),
volume_count=fn2d.get("volume_count", ""),
year=fn2d.get("year", ""),
format=fn2d.get("original_format", ""),
)
return p.filename_info
else:
fnp = filenameparser.FileNameParser()
fnp.parse_filename(filename)
@ -220,12 +129,7 @@ def parse_filename(
year=fnp.year,
format="",
)
return fni
def norm_fold(string: str) -> str:
"""Normalise and casefold string"""
return unicodedata.normalize("NFKD", string).casefold()
return fni
def combine_notes(existing_notes: str | None, new_notes: str | None, split: str) -> str:
@ -379,24 +283,6 @@ def split(s: str | None, c: str) -> list[str]:
return []
def split_urls(s: str | None) -> list[Url]:
if s is None:
return []
# Find occurences of ' http'
if s.count("http") > 1 and s.count(" http") >= 1:
urls = []
# Split urls out
url_strings = split(s, " http")
# Return the scheme 'http' and parse the url
for i, url_string in enumerate(url_strings):
if not url_string.startswith("http"):
url_string = "http" + url_string
urls.append(parse_url(url_string))
return urls
else:
return [parse_url(s)]
def remove_articles(text: str) -> str:
text = text.casefold()
articles = [

View File

@ -29,7 +29,7 @@ from comictaggerlib.ctsettings import ct_ns
from comictaggerlib.resulttypes import IssueResult, Result
from comictaggerlib.ui import ui_path
from comictaggerlib.ui.qtutils import reduce_widget_font_size
from comictalker.comictalker import ComicTalker, TalkerError
from comictalker.comictalker import ComicTalker
logger = logging.getLogger(__name__)
@ -39,7 +39,7 @@ class AutoTagMatchWindow(QtWidgets.QDialog):
self,
parent: QtWidgets.QWidget,
match_set_list: list[Result],
load_styles: list[str],
styles: list[str],
fetch_func: Callable[[IssueResult], GenericMetadata],
config: ct_ns,
talker: ComicTalker,
@ -81,7 +81,7 @@ class AutoTagMatchWindow(QtWidgets.QDialog):
self.buttonBox.button(QtWidgets.QDialogButtonBox.StandardButton.Ok).setText("Accept and Write Tags")
self.match_set_list = match_set_list
self._styles = load_styles
self._styles = styles
self.fetch_func = fetch_func
self.current_match_set_idx = 0
@ -229,40 +229,24 @@ class AutoTagMatchWindow(QtWidgets.QDialog):
def save_match(self) -> None:
match = self.current_match()
ca = ComicArchive(self.current_match_set.original_path)
md, error = self.parent().overlay_ca_read_style(self.load_data_styles, ca)
if error is not None:
logger.error("Failed to load metadata for %s: %s", ca.path, error)
QtWidgets.QApplication.restoreOverrideCursor()
QtWidgets.QMessageBox.critical(
self,
"Read Failed!",
f"One or more of the read styles failed to load for {ca.path}, check log for details",
)
return
md = ca.read_metadata(self.config.internal__load_data_style)
if md.is_empty:
md = ca.metadata_from_filename(
self.config.Filename_Parsing__filename_parser,
self.config.Filename_Parsing__complicated_parser,
self.config.Filename_Parsing__remove_c2c,
self.config.Filename_Parsing__remove_fcbd,
self.config.Filename_Parsing__remove_publisher,
)
# now get the particular issue data
try:
self.current_match_set.md = ct_md = self.fetch_func(match)
except TalkerError as e:
QtWidgets.QApplication.restoreOverrideCursor()
QtWidgets.QMessageBox.critical(self, f"{e.source} {e.code_name} Error", f"{e}")
return
if ct_md is None or ct_md.is_empty:
self.current_match_set.md = ct_md = self.fetch_func(match)
if ct_md is None:
QtWidgets.QMessageBox.critical(self, "Network Issue", "Could not retrieve issue details!")
return
QtWidgets.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.CursorShape.WaitCursor))
md.overlay(ct_md, self.config.internal__source_data_overlay, self.config.internal__overlay_merge_lists)
md.overlay(ct_md)
for style in self._styles:
success = ca.write_metadata(md, style)
QtWidgets.QApplication.restoreOverrideCursor()
@ -274,4 +258,4 @@ class AutoTagMatchWindow(QtWidgets.QDialog):
)
break
ca.reset_cache()
ca.load_cache(list(metadata_styles))

View File

@ -26,24 +26,24 @@ logger = logging.getLogger(__name__)
class CBLTransformer:
def __init__(self, metadata: GenericMetadata, config: ct_ns) -> None:
self.metadata = metadata.copy()
self.metadata = metadata
self.config = config
def apply(self) -> GenericMetadata:
if self.config.Metadata_Options__cbl_assume_lone_credit_is_primary:
if self.config.Comic_Book_Lover__assume_lone_credit_is_primary:
# helper
def set_lone_primary(role_list: list[str]) -> tuple[Credit | None, int]:
lone_credit: Credit | None = None
count = 0
for c in self.metadata.credits:
if c.role.casefold() in role_list:
if c["role"].casefold() in role_list:
count += 1
lone_credit = c
if count > 1:
lone_credit = None
break
if lone_credit is not None:
lone_credit.primary = True
lone_credit["primary"] = True
return lone_credit, count
# need to loop three times, once for 'writer', 'artist', and then
@ -53,22 +53,22 @@ class CBLTransformer:
if c is None and count == 0:
c, count = set_lone_primary(["penciler", "penciller"])
if c is not None:
c.primary = False
self.metadata.add_credit(c.person, "Artist", True)
c["primary"] = False
self.metadata.add_credit(c["person"], "Artist", True)
if self.config.Metadata_Options__cbl_copy_characters_to_tags:
if self.config.Comic_Book_Lover__copy_characters_to_tags:
self.metadata.tags.update(x for x in self.metadata.characters)
if self.config.Metadata_Options__cbl_copy_teams_to_tags:
if self.config.Comic_Book_Lover__copy_teams_to_tags:
self.metadata.tags.update(x for x in self.metadata.teams)
if self.config.Metadata_Options__cbl_copy_locations_to_tags:
if self.config.Comic_Book_Lover__copy_locations_to_tags:
self.metadata.tags.update(x for x in self.metadata.locations)
if self.config.Metadata_Options__cbl_copy_storyarcs_to_tags:
if self.config.Comic_Book_Lover__copy_storyarcs_to_tags:
self.metadata.tags.update(x for x in self.metadata.story_arcs)
if self.config.Metadata_Options__cbl_copy_notes_to_comments:
if self.config.Comic_Book_Lover__copy_notes_to_comments:
if self.metadata.notes is not None:
if self.metadata.description is None:
self.metadata.description = ""
@ -77,14 +77,13 @@ class CBLTransformer:
if self.metadata.notes not in self.metadata.description:
self.metadata.description += self.metadata.notes
if self.config.Metadata_Options__cbl_copy_weblink_to_comments:
for web_link in self.metadata.web_links:
temp_desc = self.metadata.description
if temp_desc is None:
temp_desc = ""
if self.config.Comic_Book_Lover__copy_weblink_to_comments:
if self.metadata.web_link is not None:
if self.metadata.description is None:
self.metadata.description = ""
else:
temp_desc += "\n\n"
if web_link.url and web_link.url not in temp_desc:
self.metadata.description = temp_desc + web_link.url
self.metadata.description += "\n\n"
if self.metadata.web_link not in self.metadata.description:
self.metadata.description += self.metadata.web_link
return self.metadata

View File

@ -24,20 +24,22 @@ import os
import pathlib
import sys
from collections.abc import Collection
from datetime import datetime
from typing import Any, TextIO
from comicapi import merge, utils
from comicapi import utils
from comicapi.comicarchive import ComicArchive
from comicapi.comicarchive import metadata_styles as md_styles
from comicapi.genericmetadata import GenericMetadata
from comictaggerlib import ctversion
from comictaggerlib.cbltransformer import CBLTransformer
from comictaggerlib.ctsettings import ct_ns
from comictaggerlib.filerenamer import FileRenamer, get_rename_dir
from comictaggerlib.graphics import graphics_path
from comictaggerlib.issueidentifier import IssueIdentifier
from comictaggerlib.md import prepare_metadata
from comictaggerlib.resulttypes import Action, IssueResult, MatchStatus, OnlineMatchResults, Result, Status
from comictalker.comictalker import ComicTalker, TalkerError
from comictalker.talker_utils import cleanup_html
logger = logging.getLogger(__name__)
@ -102,8 +104,7 @@ class CLI:
self.batch_mode = len(self.config.Runtime_Options__files) > 1
for f in self.config.Runtime_Options__files:
res, match_results = self.process_file_cli(self.config.Commands__command, f, match_results)
results.append(res)
results.append(self.process_file_cli(self.config.Commands__command, f, match_results))
if results[-1].status != Status.success:
return_code = 3
if self.config.Runtime_Options__json:
@ -127,14 +128,14 @@ class CLI:
logger.exception(f"Error retrieving issue details. Save aborted.\n{e}")
return GenericMetadata()
if self.config.Metadata_Options__cbl_apply_transform_on_import:
if self.config.Comic_Book_Lover__apply_transform_on_import:
ct_md = CBLTransformer(ct_md, self.config).apply()
return ct_md
def actual_metadata_save(self, ca: ComicArchive, md: GenericMetadata) -> bool:
if not self.config.Runtime_Options__dryrun:
for style in self.config.Runtime_Options__type_modify:
for style in self.config.Runtime_Options__type:
# write out the new data
if not ca.write_metadata(md, style):
logger.error("The tag save seemed to fail for style: %s!", md_styles[style].name())
@ -179,8 +180,19 @@ class CLI:
ca = ComicArchive(match_set.original_path)
md = self.create_local_metadata(ca)
ct_md = self.actual_issue_data_fetch(match_set.online_results[int(i) - 1].issue_id)
if self.config.Issue_Identifier__clear_metadata:
md = ct_md
else:
notes = (
f"Tagged with ComicTagger {ctversion.version} using info from {self.current_talker().name} on"
f" {datetime.now():%Y-%m-%d %H:%M:%S}. [Issue ID {ct_md.issue_id}]"
)
md.overlay(ct_md.replace(notes=utils.combine_notes(md.notes, notes, "Tagged with ComicTagger")))
match_set.md = prepare_metadata(md, ct_md, self.config)
if self.config.Issue_Identifier__auto_imprint:
md.fix_publisher()
match_set.md = md
self.actual_metadata_save(ca, md)
@ -238,7 +250,7 @@ class CLI:
# now, overlay the parsed filename info
if self.config.Runtime_Options__parse_filename:
f_md = ca.metadata_from_filename(
self.config.Filename_Parsing__filename_parser,
self.config.Filename_Parsing__complicated_parser,
self.config.Filename_Parsing__remove_c2c,
self.config.Filename_Parsing__remove_fcbd,
self.config.Filename_Parsing__remove_publisher,
@ -249,22 +261,22 @@ class CLI:
md.overlay(f_md)
for style in self.config.Runtime_Options__type_read:
for style in self.config.Runtime_Options__type:
if ca.has_metadata(style):
try:
t_md = ca.read_metadata(style)
md.overlay(t_md, self.config.internal__load_data_overlay, self.config.internal__overlay_merge_lists)
md.overlay(t_md)
break
except Exception as e:
logger.error("Failed to load metadata for %s: %s", ca.path, e)
# finally, use explicit stuff (always 'overlay' mode)
md.overlay(self.config.Runtime_Options__metadata, mode=merge.Mode.OVERLAY, merge_lists=True)
# finally, use explicit stuff
md.overlay(self.config.Runtime_Options__metadata)
return md
def print(self, ca: ComicArchive) -> Result:
if not self.config.Runtime_Options__type_read:
if not self.config.Runtime_Options__type:
page_count = ca.get_number_of_pages()
brief = ""
@ -290,7 +302,7 @@ class CLI:
md = None
for style, style_obj in md_styles.items():
if not self.config.Runtime_Options__type_read or style in self.config.Runtime_Options__type_read:
if not self.config.Runtime_Options__type or style in self.config.Runtime_Options__type:
if ca.has_metadata(style):
self.output(f"--------- {style_obj.name()} tags ---------")
try:
@ -322,7 +334,7 @@ class CLI:
def delete(self, ca: ComicArchive) -> Result:
res = Result(Action.delete, Status.success, ca.path)
for style in self.config.Runtime_Options__type_modify:
for style in self.config.Runtime_Options__type:
status = self.delete_style(ca, style)
if status == Status.success:
res.tags_deleted.append(style)
@ -341,7 +353,7 @@ class CLI:
src_style_name = md_styles[self.config.Commands__copy].name()
if not self.config.Runtime_Options__dryrun:
if self.config.Metadata_Options__cbl_apply_transform_on_bulk_operation == "cbi":
if self.config.Comic_Book_Lover__apply_transform_on_bulk_operation == "cbi":
md = CBLTransformer(md, self.config).apply()
if ca.write_metadata(md, style):
@ -367,9 +379,7 @@ class CLI:
except Exception as e:
logger.error("Failed to load metadata for %s: %s", ca.path, e)
return res
for style in self.config.Runtime_Options__type_modify:
if style == src_style_name:
continue
for style in self.config.Runtime_Options__type:
status = self.copy_style(ca, res.md, style)
if status == Status.success:
res.tags_written.append(style)
@ -377,19 +387,16 @@ class CLI:
res.status = status
return res
def save(self, ca: ComicArchive, match_results: OnlineMatchResults) -> tuple[Result, OnlineMatchResults]:
def save(self, ca: ComicArchive, match_results: OnlineMatchResults) -> Result:
if not self.config.Runtime_Options__overwrite:
for style in self.config.Runtime_Options__type_modify:
for style in self.config.Runtime_Options__type:
if ca.has_metadata(style):
self.output(f"{ca.path}: Already has {md_styles[style].name()} tags. Not overwriting.")
return (
Result(
Action.save,
original_path=ca.path,
status=Status.existing_tags,
tags_written=self.config.Runtime_Options__type_modify,
),
match_results,
return Result(
Action.save,
original_path=ca.path,
status=Status.existing_tags,
tags_written=self.config.Runtime_Options__type,
)
if self.batch_mode:
@ -402,8 +409,6 @@ class CLI:
matches: list[IssueResult] = []
# now, search online
ct_md = GenericMetadata()
if self.config.Runtime_Options__online:
if self.config.Runtime_Options__issue_id is not None:
# we were given the actual issue ID to search with
@ -415,23 +420,25 @@ class CLI:
Action.save,
original_path=ca.path,
status=Status.fetch_data_failure,
tags_written=self.config.Runtime_Options__type_modify,
tags_written=self.config.Runtime_Options__type,
)
match_results.fetch_data_failures.append(res)
return res, match_results
return res
if ct_md is None or ct_md.is_empty:
if ct_md is None:
logger.error("No match for ID %s was found.", self.config.Runtime_Options__issue_id)
res = Result(
Action.save,
status=Status.match_failure,
original_path=ca.path,
match_status=MatchStatus.no_match,
tags_written=self.config.Runtime_Options__type_modify,
tags_written=self.config.Runtime_Options__type,
)
match_results.no_matches.append(res)
return res, match_results
return res
if self.config.Comic_Book_Lover__apply_transform_on_import:
ct_md = CBLTransformer(ct_md, self.config).apply()
else:
if md is None or md.is_empty:
logger.error("No metadata given to search online with!")
@ -440,10 +447,10 @@ class CLI:
status=Status.match_failure,
original_path=ca.path,
match_status=MatchStatus.no_match,
tags_written=self.config.Runtime_Options__type_modify,
tags_written=self.config.Runtime_Options__type,
)
match_results.no_matches.append(res)
return res, match_results
return res
ii = IssueIdentifier(ca, self.config, self.current_talker())
@ -451,8 +458,11 @@ class CLI:
if self.config.Runtime_Options__verbose:
self.output(text)
# use our overlaid MD struct to search
# ii.set_additional_metadata(md)
# ii.only_use_additional_meta_data = True
ii.set_output_function(functools.partial(self.output, already_logged=True))
# use our overlaid MD to search
# ii.cover_page_index = md.get_cover_page_index_list()[0]
result, matches = ii.identify(ca, md)
found_match = False
@ -483,10 +493,10 @@ class CLI:
original_path=ca.path,
online_results=matches,
match_status=MatchStatus.low_confidence_match,
tags_written=self.config.Runtime_Options__type_modify,
tags_written=self.config.Runtime_Options__type,
)
match_results.low_confidence_matches.append(res)
return res, match_results
return res
logger.error("Online search: Multiple good matches. Save aborted")
res = Result(
@ -495,10 +505,10 @@ class CLI:
original_path=ca.path,
online_results=matches,
match_status=MatchStatus.multiple_match,
tags_written=self.config.Runtime_Options__type_modify,
tags_written=self.config.Runtime_Options__type,
)
match_results.multiple_matches.append(res)
return res, match_results
return res
if low_confidence and self.config.Runtime_Options__abort_on_low_confidence:
logger.error("Online search: Low confidence match. Save aborted")
res = Result(
@ -507,10 +517,10 @@ class CLI:
original_path=ca.path,
online_results=matches,
match_status=MatchStatus.low_confidence_match,
tags_written=self.config.Runtime_Options__type_modify,
tags_written=self.config.Runtime_Options__type,
)
match_results.low_confidence_matches.append(res)
return res, match_results
return res
if not found_match:
logger.error("Online search: No match found. Save aborted")
res = Result(
@ -519,10 +529,10 @@ class CLI:
original_path=ca.path,
online_results=matches,
match_status=MatchStatus.no_match,
tags_written=self.config.Runtime_Options__type_modify,
tags_written=self.config.Runtime_Options__type,
)
match_results.no_matches.append(res)
return res, match_results
return res
# we got here, so we have a single match
@ -535,10 +545,27 @@ class CLI:
original_path=ca.path,
online_results=matches,
match_status=MatchStatus.good_match,
tags_written=self.config.Runtime_Options__type_modify,
tags_written=self.config.Runtime_Options__type,
)
match_results.fetch_data_failures.append(res)
return res, match_results
return res
if self.config.Issue_Identifier__clear_metadata:
md = GenericMetadata()
notes = (
f"Tagged with ComicTagger {ctversion.version} using info from {self.current_talker().name} on"
+ f" {datetime.now():%Y-%m-%d %H:%M:%S}. [Issue ID {ct_md.issue_id}]"
)
md.overlay(
ct_md.replace(
notes=utils.combine_notes(md.notes, notes, "Tagged with ComicTagger"),
description=cleanup_html(ct_md.description, self.config.Sources__remove_html_tables),
)
)
if self.config.Issue_Identifier__auto_imprint:
md.fix_publisher()
res = Result(
Action.save,
@ -546,17 +573,16 @@ class CLI:
original_path=ca.path,
online_results=matches,
match_status=MatchStatus.good_match,
md=prepare_metadata(md, ct_md, self.config),
tags_written=self.config.Runtime_Options__type_modify,
md=md,
tags_written=self.config.Runtime_Options__type,
)
assert res.md
# ok, done building our metadata. time to save
if self.actual_metadata_save(ca, res.md):
if self.actual_metadata_save(ca, md):
match_results.good_matches.append(res)
else:
res.status = Status.write_failure
match_results.write_failures.append(res)
return res, match_results
return res
def rename(self, ca: ComicArchive) -> Result:
original_path = ca.path
@ -575,16 +601,14 @@ class CLI:
new_ext = ca.extension()
renamer = FileRenamer(
None,
md,
platform="universal" if self.config.File_Rename__strict else "auto",
replacements=self.config.File_Rename__replacements,
)
renamer.set_metadata(md, ca.path.name)
renamer.set_template(self.config.File_Rename__template)
renamer.set_issue_zero_padding(self.config.File_Rename__issue_number_padding)
renamer.set_smart_cleanup(self.config.File_Rename__use_smart_string_cleanup)
renamer.move = self.config.File_Rename__move
renamer.move_only = self.config.File_Rename__only_move
renamer.move = self.config.File_Rename__move_to_dir
try:
new_name = renamer.determine_name(ext=new_ext)
@ -604,7 +628,7 @@ class CLI:
logger.exception("Formatter failure: %s metadata: %s", self.config.File_Rename__template, renamer.metadata)
return Result(Action.rename, Status.rename_failure, original_path, md=md)
folder = get_rename_dir(ca, self.config.File_Rename__dir if self.config.File_Rename__move else None)
folder = get_rename_dir(ca, self.config.File_Rename__dir if self.config.File_Rename__move_to_dir else None)
full_path = folder / new_name
@ -676,38 +700,36 @@ class CLI:
return Result(Action.export, Status.success, ca.path, new_file)
def process_file_cli(
self, command: Action, filename: str, match_results: OnlineMatchResults
) -> tuple[Result, OnlineMatchResults]:
def process_file_cli(self, command: Action, filename: str, match_results: OnlineMatchResults) -> Result:
if not os.path.lexists(filename):
logger.error("Cannot find %s", filename)
return Result(command, Status.read_failure, pathlib.Path(filename)), match_results
return Result(command, Status.read_failure, pathlib.Path(filename))
ca = ComicArchive(filename, str(graphics_path / "nocover.png"))
if not ca.seems_to_be_a_comic_archive():
logger.error("Sorry, but %s is not a comic archive!", filename)
return Result(Action.rename, Status.read_failure, ca.path), match_results
return Result(Action.rename, Status.read_failure, ca.path)
if not ca.is_writable() and (command in (Action.delete, Action.copy, Action.save, Action.rename)):
logger.error("This archive is not writable")
return Result(command, Status.write_permission_failure, ca.path), match_results
return Result(command, Status.write_permission_failure, ca.path)
if command == Action.print:
return self.print(ca), match_results
return self.print(ca)
elif command == Action.delete:
return self.delete(ca), match_results
return self.delete(ca)
elif command == Action.copy is not None:
return self.copy(ca), match_results
return self.copy(ca)
elif command == Action.save:
return self.save(ca, match_results)
elif command == Action.rename:
return self.rename(ca), match_results
return self.rename(ca)
elif command == Action.export:
return self.export(ca), match_results
return Result(None, Status.read_failure, ca.path), match_results # type: ignore[arg-type]
return self.export(ca)
return Result(None, Status.read_failure, ca.path) # type: ignore[arg-type]

View File

@ -3,7 +3,6 @@ from __future__ import annotations
import json
import logging
import pathlib
from enum import Enum
from typing import Any
import settngs
@ -56,11 +55,7 @@ def validate_types(config: settngs.Config[settngs.Values]) -> settngs.Config[set
if setting.type is not None:
# If it is not the default and the type attribute is not None
# use it to convert the loaded string into the expected value
if (
isinstance(value, str)
or isinstance(default, Enum)
or (isinstance(setting.type, type) and issubclass(setting.type, Enum))
):
if isinstance(value, str):
config.values[setting.group][setting.dest] = setting.type(value)
return config

View File

@ -25,7 +25,7 @@ import subprocess
import settngs
from comicapi import merge, utils
from comicapi import utils
from comicapi.comicarchive import metadata_styles
from comicapi.genericmetadata import GenericMetadata
from comictaggerlib import ctversion
@ -109,7 +109,7 @@ def register_runtime(parser: settngs.Manager) -> None:
"--metadata",
default=GenericMetadata(),
type=parse_metadata_from_string,
help="""Explicitly define some tags to be used in YAML syntax. Use @file.yaml to read from a file. e.g.:\n"series: Plastic Man, publisher: Quality Comics, year: "\n"series: 'Kickers, Inc.', issue: '1', year: 1986"\nIf you want to erase a tag leave the value blank.\nSome names that can be used: series, issue, issue_count, year,\npublisher, title\n\n""",
help="""Explicitly define, as a list, some tags to be used. e.g.:\n"series=Plastic Man, publisher=Quality Comics"\n"series=Kickers^, Inc., issue=1, year=1986"\nName-Value pairs are comma separated. Use a\n"^" to escape an "=" or a ",", as shown in\nthe example above. Some names that can be\nused: series, issue, issue_count, year,\npublisher, title\n\n""",
file=False,
)
parser.add_setting(
@ -160,42 +160,14 @@ def register_runtime(parser: settngs.Manager) -> None:
parser.add_setting(
"--json", "-j", action="store_true", help="Output json on stdout. Ignored in interactive mode.", file=False
)
parser.add_setting(
"--type-modify",
metavar=f"{{{','.join(metadata_styles).upper()}}}",
default=[],
type=metadata_type,
help="""Specify the type of tags to write.\nUse commas for multiple types.\nRead types will be used if unspecified\nSee --list-plugins for the available types.\n\n""",
file=False,
)
parser.add_setting(
"-t",
"--type-read",
"--type",
metavar=f"{{{','.join(metadata_styles).upper()}}}",
default=[],
type=metadata_type,
help="""Specify the type of tags to read.\nUse commas for multiple types.\nSee --list-plugins for the available types.\nThe tag use will be 'overlayed' in order:\ne.g. '-t cbl,cr' with no CBL tags, CR will be used if they exist and CR will overwrite any shared CBL tags.\n\n""",
file=False,
)
parser.add_setting(
"--read-style-overlay",
type=merge.Mode,
default=merge.Mode.OVERLAY,
help="How to overlay new metadata on the current for enabled read styles (CR, CBL, etc.)",
file=False,
)
parser.add_setting(
"--source-overlay",
type=merge.Mode,
default=merge.Mode.OVERLAY,
help="How to overlay new metadata from a data source (CV, Metron, GCD, etc.) on to the current",
file=False,
)
parser.add_setting(
"--overlay-merge-lists",
action=argparse.BooleanOptionalAction,
default=True,
help="When overlaying, merge or replace lists (genres, characters, etc.)",
help="""Specify TYPE as either CR, CBL or COMET\n(as either ComicRack, ComicBookLover,\nor CoMet style tags, respectively).\nUse commas for multiple types.\nFor searching the metadata will use the first listed:\neg '-t cbl,cr' with no CBL tags, CR will be used if they exist\n\n""",
file=False,
)
parser.add_setting(
@ -206,7 +178,7 @@ def register_runtime(parser: settngs.Manager) -> None:
file=False,
)
parser.add_setting("--no-gui", action="store_true", help="Do not open the GUI, force the commandline", file=False)
parser.add_setting("files", nargs="*", default=[], file=False)
parser.add_setting("files", nargs="*", file=False)
def register_commands(parser: settngs.Manager) -> None:
@ -218,8 +190,7 @@ def register_commands(parser: settngs.Manager) -> None:
dest="command",
action="store_const",
const=Action.print,
default=Action.gui,
help="""Print out tag info from file. Specify type\n(via --type-read) to get only info of that tag type.\n\n""",
help="""Print out tag info from file. Specify type\n(via -t) to get only info of that tag type.\n\n""",
file=False,
)
parser.add_setting(
@ -228,16 +199,15 @@ def register_commands(parser: settngs.Manager) -> None:
dest="command",
action="store_const",
const=Action.delete,
help="Deletes the tag block of specified type (via --type-modify).\n",
help="Deletes the tag block of specified type (via -t).\n",
file=False,
)
parser.add_setting(
"-c",
"--copy",
type=metadata_type_single,
default=[],
metavar=f"{{{','.join(metadata_styles).upper()}}}",
help="Copy the specified source tag block to\ndestination style specified via --type-modify\n(potentially lossy operation).\n\n",
help="Copy the specified source tag block to\ndestination style specified via -t\n(potentially lossy operation).\n\n",
file=False,
)
parser.add_setting(
@ -246,7 +216,7 @@ def register_commands(parser: settngs.Manager) -> None:
dest="command",
action="store_const",
const=Action.save,
help="Save out tags as specified type (via --type-modify).\nMust specify also at least -o, -f, or -m.\n\n",
help="Save out tags as specified type (via -t).\nMust specify also at least -o, -f, or -m.\n\n",
file=False,
)
parser.add_setting(
@ -280,7 +250,6 @@ def register_commands(parser: settngs.Manager) -> None:
dest="command",
action="store_const",
const=Action.list_plugins,
default=Action.gui,
help="List the available plugins.\n\n",
file=False,
)
@ -315,9 +284,6 @@ def validate_commandline_settings(config: settngs.Config[ct_ns], parser: settngs
if config[0].Runtime_Options__json and config[0].Runtime_Options__interactive:
config[0].Runtime_Options__json = False
if config[0].Runtime_Options__type_read and not config[0].Runtime_Options__type_modify:
config[0].Runtime_Options__type_modify = config[0].Runtime_Options__type_read
if (
config[0].Commands__command not in (Action.save_config, Action.list_plugins)
and config[0].Runtime_Options__no_gui
@ -325,16 +291,16 @@ def validate_commandline_settings(config: settngs.Config[ct_ns], parser: settngs
):
parser.exit(message="Command requires at least one filename!\n", status=1)
if config[0].Commands__command == Action.delete and not config[0].Runtime_Options__type_modify:
parser.exit(message="Please specify the type to delete with --type-modify\n", status=1)
if config[0].Commands__command == Action.delete and not config[0].Runtime_Options__type:
parser.exit(message="Please specify the type to delete with -t\n", status=1)
if config[0].Commands__command == Action.save and not config[0].Runtime_Options__type_modify:
parser.exit(message="Please specify the type to save with --type-modify\n", status=1)
if config[0].Commands__command == Action.save and not config[0].Runtime_Options__type:
parser.exit(message="Please specify the type to save with -t\n", status=1)
if config[0].Commands__copy:
config[0].Commands__command = Action.copy
if not config[0].Runtime_Options__type_modify:
parser.exit(message="Please specify the type to copy to with --type-modify\n", status=1)
if not config[0].Runtime_Options__type:
parser.exit(message="Please specify the type to copy to with -t\n", status=1)
if config[0].Runtime_Options__recursive:
config[0].Runtime_Options__files = utils.get_recursive_filelist(config[0].Runtime_Options__files)

View File

@ -5,7 +5,6 @@ import uuid
import settngs
from comicapi import merge, utils
from comictaggerlib.ctsettings.settngs_namespace import SettngsNS as ct_ns
from comictaggerlib.defaults import DEFAULT_REPLACEMENTS, Replacement, Replacements
@ -13,6 +12,13 @@ from comictaggerlib.defaults import DEFAULT_REPLACEMENTS, Replacement, Replaceme
def general(parser: settngs.Manager) -> None:
# General Settings
parser.add_setting("check_for_new_version", default=False, cmdline=False)
parser.add_setting(
"--disable-cr",
default=False,
action=argparse.BooleanOptionalAction,
help="Disable the ComicRack metadata type",
)
parser.add_setting("use_short_metadata_names", default=False, action=argparse.BooleanOptionalAction, cmdline=False)
parser.add_setting(
"--prompt-on-save",
default=True,
@ -25,10 +31,7 @@ def internal(parser: settngs.Manager) -> None:
# automatic settings
parser.add_setting("install_id", default=uuid.uuid4().hex, cmdline=False)
parser.add_setting("save_data_style", default=["cbi"], cmdline=False)
parser.add_setting("load_data_style", default=["cbi"], cmdline=False)
parser.add_setting("load_data_overlay", default=merge.Mode.OVERLAY, cmdline=False, type=merge.Mode)
parser.add_setting("source_data_overlay", default=merge.Mode.OVERLAY, cmdline=False, type=merge.Mode)
parser.add_setting("overlay_merge_lists", default=True, cmdline=False, action=argparse.BooleanOptionalAction)
parser.add_setting("load_data_style", default="cbi", cmdline=False)
parser.add_setting("last_opened_folder", default="", cmdline=False)
parser.add_setting("window_width", default=0, cmdline=False)
parser.add_setting("window_height", default=0, cmdline=False)
@ -99,12 +102,10 @@ def dialog(parser: settngs.Manager) -> None:
def filename(parser: settngs.Manager) -> None:
# filename parsing settings
parser.add_setting(
"--filename-parser",
default=utils.Parser.ORIGINAL,
metavar=f"{{{','.join(utils.Parser)}}}",
type=utils.Parser,
choices=[p.value for p in utils.Parser],
help="Select the filename parser, defaults to original",
"--complicated-parser",
default=False,
action=argparse.BooleanOptionalAction,
help="Enables the new parser which tries to extract more information from filenames",
)
parser.add_setting(
"--remove-c2c",
@ -160,25 +161,17 @@ def talker(parser: settngs.Manager) -> None:
)
def md_options(parser: settngs.Manager) -> None:
def cbl(parser: settngs.Manager) -> None:
# CBL Transform settings
parser.add_setting("--cbl-assume-lone-credit-is-primary", default=False, action=argparse.BooleanOptionalAction)
parser.add_setting("--cbl-copy-characters-to-tags", default=False, action=argparse.BooleanOptionalAction)
parser.add_setting("--cbl-copy-teams-to-tags", default=False, action=argparse.BooleanOptionalAction)
parser.add_setting("--cbl-copy-locations-to-tags", default=False, action=argparse.BooleanOptionalAction)
parser.add_setting("--cbl-copy-storyarcs-to-tags", default=False, action=argparse.BooleanOptionalAction)
parser.add_setting("--cbl-copy-notes-to-comments", default=False, action=argparse.BooleanOptionalAction)
parser.add_setting("--cbl-copy-weblink-to-comments", default=False, action=argparse.BooleanOptionalAction)
parser.add_setting("--cbl-apply-transform-on-import", default=False, action=argparse.BooleanOptionalAction)
parser.add_setting("--cbl-apply-transform-on-bulk-operation", default=False, action=argparse.BooleanOptionalAction)
parser.add_setting("use_short_metadata_names", default=False, action=argparse.BooleanOptionalAction, cmdline=False)
parser.add_setting(
"--disable-cr",
default=False,
action=argparse.BooleanOptionalAction,
help="Disable the ComicRack metadata type",
)
parser.add_setting("--assume-lone-credit-is-primary", default=False, action=argparse.BooleanOptionalAction)
parser.add_setting("--copy-characters-to-tags", default=False, action=argparse.BooleanOptionalAction)
parser.add_setting("--copy-teams-to-tags", default=False, action=argparse.BooleanOptionalAction)
parser.add_setting("--copy-locations-to-tags", default=False, action=argparse.BooleanOptionalAction)
parser.add_setting("--copy-storyarcs-to-tags", default=False, action=argparse.BooleanOptionalAction)
parser.add_setting("--copy-notes-to-comments", default=False, action=argparse.BooleanOptionalAction)
parser.add_setting("--copy-weblink-to-comments", default=False, action=argparse.BooleanOptionalAction)
parser.add_setting("--apply-transform-on-import", default=False, action=argparse.BooleanOptionalAction)
parser.add_setting("--apply-transform-on-bulk-operation", default=False, action=argparse.BooleanOptionalAction)
def rename(parser: settngs.Manager) -> None:
@ -205,16 +198,11 @@ def rename(parser: settngs.Manager) -> None:
parser.add_setting("--dir", default="", help="The directory to move renamed files to")
parser.add_setting(
"--move",
dest="move_to_dir",
default=False,
action=argparse.BooleanOptionalAction,
help="Enables moving renamed files to a separate directory",
)
parser.add_setting(
"--only-move",
default=False,
action=argparse.BooleanOptionalAction,
help="Ignores the filename when moving renamed files to a separate directory",
)
parser.add_setting(
"--strict",
default=False,
@ -283,15 +271,6 @@ def migrate_settings(config: settngs.Config[ct_ns]) -> settngs.Config[ct_ns]:
else:
config[0].internal__save_data_style = ["cbi"]
load_style = config[0].internal__load_data_style
if not isinstance(load_style, list):
if isinstance(load_style, int) and load_style in (0, 1, 2):
config[0].internal__load_data_style = [original_types[load_style]]
elif isinstance(load_style, str):
config[0].internal__load_data_style = [load_style]
else:
config[0].internal__load_data_style = ["cbi"]
return config
@ -315,7 +294,7 @@ def register_file_settings(parser: settngs.Manager) -> None:
parser.add_group("Issue Identifier", identifier, False)
parser.add_group("Filename Parsing", filename, False)
parser.add_group("Sources", talker, False)
parser.add_group("Metadata Options", md_options, False)
parser.add_group("Comic Book Lover", cbl, False)
parser.add_group("File Rename", rename, False)
parser.add_group("Auto-Tag", autotag, False)
parser.add_group("General", general, False)

View File

@ -5,8 +5,6 @@ import typing
import settngs
import comicapi.genericmetadata
import comicapi.merge
import comicapi.utils
import comictaggerlib.ctsettings.types
import comictaggerlib.defaults
import comictaggerlib.resulttypes
@ -22,7 +20,7 @@ class SettngsNS(settngs.TypedNS):
Runtime_Options__abort_on_conflict: bool
Runtime_Options__delete_original: bool
Runtime_Options__parse_filename: bool
Runtime_Options__issue_id: str | None
Runtime_Options__issue_id: str
Runtime_Options__online: bool
Runtime_Options__metadata: comicapi.genericmetadata.GenericMetadata
Runtime_Options__interactive: bool
@ -35,21 +33,14 @@ class SettngsNS(settngs.TypedNS):
Runtime_Options__glob: bool
Runtime_Options__quiet: bool
Runtime_Options__json: bool
Runtime_Options__type_modify: list[str]
Runtime_Options__type_read: list[str]
Runtime_Options__read_style_overlay: comicapi.merge.Mode
Runtime_Options__source_overlay: comicapi.merge.Mode
Runtime_Options__overlay_merge_lists: bool
Runtime_Options__type: list[str]
Runtime_Options__overwrite: bool
Runtime_Options__no_gui: bool
Runtime_Options__files: list[str]
internal__install_id: str
internal__save_data_style: list[str]
internal__load_data_style: list[str]
internal__load_data_overlay: comicapi.merge.Mode
internal__source_data_overlay: comicapi.merge.Mode
internal__overlay_merge_lists: bool
internal__load_data_style: str
internal__last_opened_folder: str
internal__window_width: int
internal__window_height: int
@ -70,7 +61,7 @@ class SettngsNS(settngs.TypedNS):
Issue_Identifier__exact_series_matches_first: bool
Issue_Identifier__always_use_publisher_filter: bool
Filename_Parsing__filename_parser: comicapi.utils.Parser
Filename_Parsing__complicated_parser: bool
Filename_Parsing__remove_c2c: bool
Filename_Parsing__remove_fcbd: bool
Filename_Parsing__remove_publisher: bool
@ -81,25 +72,22 @@ class SettngsNS(settngs.TypedNS):
Sources__source: str
Sources__remove_html_tables: bool
Metadata_Options__cbl_assume_lone_credit_is_primary: bool
Metadata_Options__cbl_copy_characters_to_tags: bool
Metadata_Options__cbl_copy_teams_to_tags: bool
Metadata_Options__cbl_copy_locations_to_tags: bool
Metadata_Options__cbl_copy_storyarcs_to_tags: bool
Metadata_Options__cbl_copy_notes_to_comments: bool
Metadata_Options__cbl_copy_weblink_to_comments: bool
Metadata_Options__cbl_apply_transform_on_import: bool
Metadata_Options__cbl_apply_transform_on_bulk_operation: bool
Metadata_Options__use_short_metadata_names: bool
Metadata_Options__disable_cr: bool
Comic_Book_Lover__assume_lone_credit_is_primary: bool
Comic_Book_Lover__copy_characters_to_tags: bool
Comic_Book_Lover__copy_teams_to_tags: bool
Comic_Book_Lover__copy_locations_to_tags: bool
Comic_Book_Lover__copy_storyarcs_to_tags: bool
Comic_Book_Lover__copy_notes_to_comments: bool
Comic_Book_Lover__copy_weblink_to_comments: bool
Comic_Book_Lover__apply_transform_on_import: bool
Comic_Book_Lover__apply_transform_on_bulk_operation: bool
File_Rename__template: str
File_Rename__issue_number_padding: int
File_Rename__use_smart_string_cleanup: bool
File_Rename__auto_extension: bool
File_Rename__dir: str
File_Rename__move: bool
File_Rename__only_move: bool
File_Rename__move_to_dir: bool
File_Rename__strict: bool
File_Rename__replacements: comictaggerlib.defaults.Replacements
@ -110,6 +98,8 @@ class SettngsNS(settngs.TypedNS):
Auto_Tag__remove_archive_after_successful_match: bool
General__check_for_new_version: bool
General__disable_cr: bool
General__use_short_metadata_names: bool
General__prompt_on_save: bool
Dialog_Flags__show_disclaimer: bool
@ -118,8 +108,8 @@ class SettngsNS(settngs.TypedNS):
Archive__rar: str
Source_comicvine__comicvine_key: str | None
Source_comicvine__comicvine_url: str | None
Source_comicvine__comicvine_key: str
Source_comicvine__comicvine_url: str
Source_comicvine__cv_use_series_start_as_volume: bool
@ -135,7 +125,7 @@ class Runtime_Options(typing.TypedDict):
abort_on_conflict: bool
delete_original: bool
parse_filename: bool
issue_id: str | None
issue_id: str
online: bool
metadata: comicapi.genericmetadata.GenericMetadata
interactive: bool
@ -148,11 +138,7 @@ class Runtime_Options(typing.TypedDict):
glob: bool
quiet: bool
json: bool
type_modify: list[str]
type_read: list[str]
read_style_overlay: comicapi.merge.Mode
source_overlay: comicapi.merge.Mode
overlay_merge_lists: bool
type: list[str]
overwrite: bool
no_gui: bool
files: list[str]
@ -161,10 +147,7 @@ class Runtime_Options(typing.TypedDict):
class internal(typing.TypedDict):
install_id: str
save_data_style: list[str]
load_data_style: list[str]
load_data_overlay: comicapi.merge.Mode
source_data_overlay: comicapi.merge.Mode
overlay_merge_lists: bool
load_data_style: str
last_opened_folder: str
window_width: int
window_height: int
@ -189,7 +172,7 @@ class Issue_Identifier(typing.TypedDict):
class Filename_Parsing(typing.TypedDict):
filename_parser: comicapi.utils.Parser
complicated_parser: bool
remove_c2c: bool
remove_fcbd: bool
remove_publisher: bool
@ -203,18 +186,16 @@ class Sources(typing.TypedDict):
remove_html_tables: bool
class Metadata_Options(typing.TypedDict):
cbl_assume_lone_credit_is_primary: bool
cbl_copy_characters_to_tags: bool
cbl_copy_teams_to_tags: bool
cbl_copy_locations_to_tags: bool
cbl_copy_storyarcs_to_tags: bool
cbl_copy_notes_to_comments: bool
cbl_copy_weblink_to_comments: bool
cbl_apply_transform_on_import: bool
cbl_apply_transform_on_bulk_operation: bool
use_short_metadata_names: bool
disable_cr: bool
class Comic_Book_Lover(typing.TypedDict):
assume_lone_credit_is_primary: bool
copy_characters_to_tags: bool
copy_teams_to_tags: bool
copy_locations_to_tags: bool
copy_storyarcs_to_tags: bool
copy_notes_to_comments: bool
copy_weblink_to_comments: bool
apply_transform_on_import: bool
apply_transform_on_bulk_operation: bool
class File_Rename(typing.TypedDict):
@ -223,8 +204,7 @@ class File_Rename(typing.TypedDict):
use_smart_string_cleanup: bool
auto_extension: bool
dir: str
move: bool
only_move: bool
move_to_dir: bool
strict: bool
replacements: comictaggerlib.defaults.Replacements
@ -239,6 +219,8 @@ class Auto_Tag(typing.TypedDict):
class General(typing.TypedDict):
check_for_new_version: bool
disable_cr: bool
use_short_metadata_names: bool
prompt_on_save: bool
@ -253,8 +235,8 @@ class Archive(typing.TypedDict):
class Source_comicvine(typing.TypedDict):
comicvine_key: str | None
comicvine_url: str | None
comicvine_key: str
comicvine_url: str
cv_use_series_start_as_volume: bool
@ -267,7 +249,7 @@ SettngsDict = typing.TypedDict(
"Issue Identifier": Issue_Identifier,
"Filename Parsing": Filename_Parsing,
"Sources": Sources,
"Metadata Options": Metadata_Options,
"Comic Book Lover": Comic_Book_Lover,
"File Rename": File_Rename,
"Auto-Tag": Auto_Tag,
"General": General,

View File

@ -2,95 +2,12 @@ from __future__ import annotations
import argparse
import pathlib
import sys
import types
import typing
from collections.abc import Collection, Mapping
from typing import Any
import yaml
from appdirs import AppDirs
from comicapi import utils
from comicapi.comicarchive import metadata_styles
from comicapi.genericmetadata import REMOVE, GenericMetadata
if sys.version_info < (3, 10):
@typing.no_type_check
def get_type_hints(obj, globalns=None, localns=None, include_extras=False):
if getattr(obj, "__no_type_check__", None):
return {}
# Classes require a special treatment.
if isinstance(obj, type):
hints = {}
for base in reversed(obj.__mro__):
if globalns is None:
base_globals = getattr(sys.modules.get(base.__module__, None), "__dict__", {})
else:
base_globals = globalns
ann = base.__dict__.get("__annotations__", {})
if isinstance(ann, types.GetSetDescriptorType):
ann = {}
base_locals = dict(vars(base)) if localns is None else localns
if localns is None and globalns is None:
# This is surprising, but required. Before Python 3.10,
# get_type_hints only evaluated the globalns of
# a class. To maintain backwards compatibility, we reverse
# the globalns and localns order so that eval() looks into
# *base_globals* first rather than *base_locals*.
# This only affects ForwardRefs.
base_globals, base_locals = base_locals, base_globals
for name, value in ann.items():
if value is None:
value = type(None)
if isinstance(value, str):
if "|" in value:
value = "Union[" + value.replace(" |", ",") + "]"
value = typing.ForwardRef(value, is_argument=False, is_class=True)
value = typing._eval_type(value, base_globals, base_locals)
hints[name] = value
return hints if include_extras else {k: typing._strip_annotations(t) for k, t in hints.items()}
if globalns is None:
if isinstance(obj, types.ModuleType):
globalns = obj.__dict__
else:
nsobj = obj
# Find globalns for the unwrapped object.
while hasattr(nsobj, "__wrapped__"):
nsobj = nsobj.__wrapped__
globalns = getattr(nsobj, "__globals__", {})
if localns is None:
localns = globalns
elif localns is None:
localns = globalns
hints = getattr(obj, "__annotations__", None)
if hints is None:
# Return empty annotations for something that _could_ have them.
if isinstance(obj, typing._allowed_types):
return {}
else:
raise TypeError("{!r} is not a module, class, method, " "or function.".format(obj))
hints = dict(hints)
for name, value in hints.items():
if value is None:
value = type(None)
if isinstance(value, str):
if "|" in value:
value = "Union[" + value.replace(" |", ",") + "]"
# class-level forward refs were handled above, this must be either
# a module-level annotation or a function argument annotation
value = typing.ForwardRef(
value,
is_argument=not isinstance(obj, types.ModuleType),
is_class=False,
)
hints[name] = typing._eval_type(value, globalns, localns)
return hints if include_extras else {k: typing._strip_annotations(t) for k, t in hints.items()}
else:
from typing import get_type_hints
from comicapi.genericmetadata import GenericMetadata
class ComicTaggerPaths(AppDirs):
@ -167,78 +84,50 @@ def metadata_type(types: str) -> list[str]:
def parse_metadata_from_string(mdstr: str) -> GenericMetadata:
"""The metadata string is a comma separated list of name-value pairs
The names match the attributes of the internal metadata struct (for now)
The caret is the special "escape character", since it's not common in
natural language text
def get_type(key: str, tt: Any = get_type_hints(GenericMetadata)) -> Any:
t: Any = tt.get(key, None)
if t is None:
return None
if getattr(t, "__origin__", None) is typing.Union and len(t.__args__) == 2 and t.__args__[1] is type(None):
t = t.__args__[0]
elif isinstance(t, types.GenericAlias) and issubclass(t.mro()[0], Collection):
t = t.mro()[0], t.__args__[0]
example = "series=Kickers^, Inc. ,issue=1, year=1986"
"""
if isinstance(t, tuple) and issubclass(t[1], dict):
return (t[0], dict)
if isinstance(t, type) and issubclass(t, dict):
return dict
return t
def convert_value(t: type, value: Any) -> Any:
if not isinstance(value, t):
if isinstance(value, (Mapping)):
value = t(**value)
elif not isinstance(value, str) and isinstance(value, (Collection)):
value = t(*value)
else:
try:
if t is utils.Url and isinstance(value, str):
value = utils.parse_url(value)
else:
value = t(value)
except (ValueError, TypeError):
raise argparse.ArgumentTypeError(f"Invalid syntax for tag '{key}'")
return value
escaped_comma = "^,"
escaped_equals = "^="
replacement_token = "<_~_>"
md = GenericMetadata()
if not mdstr:
return md
if mdstr[0] == "@":
p = pathlib.Path(mdstr[1:])
if not p.is_file():
raise argparse.ArgumentTypeError("Invalid filepath")
mdstr = p.read_text()
if mdstr[0] != "{":
mdstr = "{" + mdstr + "}"
# First, replace escaped commas with with a unique token (to be changed back later)
mdstr = mdstr.replace(escaped_comma, replacement_token)
tmp_list = utils.split(mdstr, ",")
md_list = []
for item in tmp_list:
item = item.replace(replacement_token, ",")
md_list.append(item)
md_dict = yaml.safe_load(mdstr)
# Now build a nice dict from the list
md_dict = {}
for item in md_list:
# Make sure to fix any escaped equal signs
i = item.replace(escaped_equals, replacement_token)
key, _, value = i.partition("=")
value = value.replace(replacement_token, "=").strip()
key = key.strip()
if key.casefold() == "credit":
cred_attribs = utils.split(value, ":")
role = cred_attribs[0]
person = cred_attribs[1] if len(cred_attribs) > 1 else ""
primary = len(cred_attribs) > 2
md.add_credit(person.strip(), role.strip(), primary)
else:
md_dict[key] = value
empty = True
# Map the dict to the metadata object
for key, value in md_dict.items():
if hasattr(md, key):
t = get_type(key)
if value is None:
value = REMOVE
elif isinstance(t, tuple):
if value == "" or value is None:
value = t[0]()
else:
if isinstance(value, str):
values: list[Any] = value.split("::")
if not isinstance(value, Collection):
raise argparse.ArgumentTypeError(f"Invalid syntax for tag '{key}'")
values = list(value)
for idx, v in enumerate(values):
if not isinstance(v, t[1]):
values[idx] = convert_value(t[1], v)
value = t[0](values)
elif value is not None:
value = convert_value(t, value)
empty = False
setattr(md, key, value)
else:
if not hasattr(md, key):
raise argparse.ArgumentTypeError(f"'{key}' is not a valid tag name")
md.is_empty = empty
else:
md.is_empty = False
setattr(md, key, value)
return md

View File

@ -38,8 +38,8 @@ def get_rename_dir(ca: ComicArchive, rename_dir: str | pathlib.Path | None) -> p
folder = ca.path.parent.absolute()
if rename_dir is not None:
if isinstance(rename_dir, str):
rename_dir = pathlib.Path(rename_dir.strip())
folder = rename_dir.absolute()
rename_dir = rename_dir.strip()
folder = pathlib.Path(rename_dir).absolute()
return folder
@ -69,7 +69,7 @@ class MetadataFormatter(string.Formatter):
if conversion == "t":
return str(value).title()
if conversion == "j":
return ", ".join(list(str(v) for v in value))
return ", ".join(list(value))
return cast(str, super().convert_field(value, conversion))
def handle_replacements(self, string: str, replacements: list[Replacement]) -> str:
@ -192,12 +192,9 @@ class FileRenamer:
self.move = False
self.platform = platform
self.replacements = replacements
self.original_name = ""
self.move_only = False
def set_metadata(self, metadata: GenericMetadata, original_name: str) -> None:
def set_metadata(self, metadata: GenericMetadata) -> None:
self.metadata = metadata
self.original_name = original_name
def set_issue_zero_padding(self, count: int) -> None:
self.issue_zero_padding = count
@ -221,10 +218,6 @@ class FileRenamer:
fmt = MetadataFormatter(self.smart_cleanup, platform=self.platform, replacements=self.replacements)
md_dict = vars(md)
md_dict["web_link"] = ""
if md.web_links:
md_dict["web_link"] = md.web_links[0]
md_dict["issue"] = IssueString(md.issue).as_string(pad=self.issue_zero_padding)
for role in ["writer", "penciller", "inker", "colorist", "letterer", "cover artist", "editor"]:
md_dict[role] = md.get_primary_credit(role)
@ -243,9 +236,9 @@ class FileRenamer:
).strip()
new_name = os.path.join(new_name, new_basename)
if self.move_only:
new_folder = os.path.join(new_name, os.path.splitext(self.original_name)[0])
return new_folder + ext
new_name += ext
new_basename += ext
if self.move:
return new_name.strip() + ext
return new_basename.strip() + ext
return new_name.strip()
return new_basename.strip()

View File

@ -98,14 +98,10 @@ class FileSelectionList(QtWidgets.QWidget):
self.dirty_flag = modified
def select_all(self) -> None:
self.twList.setRangeSelected(
QtWidgets.QTableWidgetSelectionRange(0, 0, self.twList.rowCount() - 1, self.twList.columnCount() - 1), True
)
self.twList.setRangeSelected(QtWidgets.QTableWidgetSelectionRange(0, 0, self.twList.rowCount() - 1, 5), True)
def deselect_all(self) -> None:
self.twList.setRangeSelected(
QtWidgets.QTableWidgetSelectionRange(0, 0, self.twList.rowCount() - 1, self.twList.columnCount() - 1), False
)
self.twList.setRangeSelected(QtWidgets.QTableWidgetSelectionRange(0, 0, self.twList.rowCount() - 1, 5), False)
def remove_archive_list(self, ca_list: list[ComicArchive]) -> None:
self.twList.setSortingEnabled(False)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -24,14 +24,9 @@ try:
"""
if QtWidgets.QApplication.instance() is not None:
errorbox = QtWidgets.QMessageBox()
errorbox.setStandardButtons(
QtWidgets.QMessageBox.StandardButton.Abort | QtWidgets.QMessageBox.StandardButton.Ignore
)
errorbox.setText(log_msg)
if errorbox.exec() == QtWidgets.QMessageBox.StandardButton.Abort:
QtWidgets.QApplication.exit(1)
else:
logger.warning("Exception ignored")
errorbox.exec()
QtWidgets.QApplication.exit(1)
else:
logger.debug("No QApplication instance available.")

View File

@ -643,10 +643,7 @@ class IssueIdentifier:
)
final_cover_matching.remove(match)
if final_cover_matching:
best_score = final_cover_matching[0].distance
else:
best_score = 0
best_score = final_cover_matching[0].distance
if best_score >= self.min_score_thresh:
if len(final_cover_matching) == 1:
self.log_msg("No matching pages in the issue.")

View File

@ -252,7 +252,7 @@ class App:
# config already loaded
error = None
if self.config[0].Metadata_Options__disable_cr:
if self.config[0].General__disable_cr:
if "cr" in comicapi.comicarchive.metadata_styles:
del comicapi.comicarchive.metadata_styles["cr"]

View File

@ -1,41 +0,0 @@
from __future__ import annotations
from datetime import datetime
from comicapi import utils
from comicapi.genericmetadata import GenericMetadata
from comictaggerlib import ctversion
from comictaggerlib.cbltransformer import CBLTransformer
from comictaggerlib.ctsettings.settngs_namespace import SettngsNS
from comictalker.talker_utils import cleanup_html
def prepare_metadata(md: GenericMetadata, new_md: GenericMetadata, opts: SettngsNS) -> GenericMetadata:
if opts.Metadata_Options__cbl_apply_transform_on_import:
new_md = CBLTransformer(new_md, opts).apply()
final_md = md.copy()
if opts.Issue_Identifier__clear_metadata:
final_md = GenericMetadata()
final_md.overlay(new_md)
if final_md.tag_origin is not None:
notes = (
f"Tagged with ComicTagger {ctversion.version} using info from {final_md.tag_origin.name} on"
+ f" {datetime.now():%Y-%m-%d %H:%M:%S}. [Issue ID {final_md.issue_id}]"
)
else:
notes = (
f"Tagged with ComicTagger {ctversion.version} on"
+ f" {datetime.now():%Y-%m-%d %H:%M:%S}. "
+ (f"[Issue ID {final_md.issue_id}]" if final_md.issue_id else "")
)
if opts.Issue_Identifier__auto_imprint:
final_md.fix_publisher()
return final_md.replace(
is_empty=False,
notes=utils.combine_notes(final_md.notes, notes, "Tagged with ComicTagger"),
description=cleanup_html(final_md.description, opts.Sources__remove_html_tables) or None,
)

View File

@ -110,7 +110,6 @@ class PageListEditor(QtWidgets.QWidget):
self.leBookmark.editingFinished.connect(self.save_bookmark)
self.btnUp.clicked.connect(self.move_current_up)
self.btnDown.clicked.connect(self.move_current_down)
self.btnIdentifyScannerPage.clicked.connect(self.identify_scanner_page)
self.pre_move_row = -1
self.first_front_page: int | None = None
@ -136,21 +135,6 @@ class PageListEditor(QtWidgets.QWidget):
action_item.setShortcut(shortcut)
self.addAction(action_item)
def identify_scanner_page(self) -> None:
if self.comic_archive is None:
return
row = self.comic_archive.get_scanner_page_index()
if row is None:
return
page_dict: ImageMetadata = self.listWidget.item(row).data(QtCore.Qt.ItemDataRole.UserRole)
page_dict["type"] = PageType.Deleted
item = self.listWidget.item(row)
item.setData(QtCore.Qt.ItemDataRole.UserRole, page_dict)
item.setText(self.list_entry_text(page_dict))
self.change_page()
def select_page_type_item(self, idx: int) -> None:
if self.cbPageType.isEnabled() and self.listWidget.count() > 0:
self.cbPageType.setCurrentIndex(idx)
@ -248,14 +232,14 @@ class PageListEditor(QtWidgets.QWidget):
i = self.cbPageType.findData(pagetype)
self.cbPageType.setCurrentIndex(i)
self.chkDoublePage.setChecked("double_page" in self.listWidget.item(row).data(QtCore.Qt.ItemDataRole.UserRole))
self.chkDoublePage.setChecked("double_page" in self.listWidget.item(row).data(QtCore.Qt.UserRole)[0])
if "bookmark" in self.listWidget.item(row).data(QtCore.Qt.ItemDataRole.UserRole):
self.leBookmark.setText(self.listWidget.item(row).data(QtCore.Qt.ItemDataRole.UserRole)["bookmark"])
if "bookmark" in self.listWidget.item(row).data(QtCore.Qt.UserRole)[0]:
self.leBookmark.setText(self.listWidget.item(row).data(QtCore.Qt.UserRole)[0]["bookmark"])
else:
self.leBookmark.setText("")
idx = int(self.listWidget.item(row).data(QtCore.Qt.ItemDataRole.UserRole)["image_index"])
idx = int(self.listWidget.item(row).data(QtCore.Qt.ItemDataRole.UserRole)[0]["image_index"])
if self.comic_archive is not None:
self.pageWidget.set_archive(self.comic_archive, idx)
@ -264,7 +248,7 @@ class PageListEditor(QtWidgets.QWidget):
front_cover = 0
for i in range(self.listWidget.count()):
item = self.listWidget.item(i)
page_dict: ImageMetadata = item.data(QtCore.Qt.ItemDataRole.UserRole)
page_dict: ImageMetadata = item.data(QtCore.Qt.ItemDataRole.UserRole)[0]
if "type" in page_dict and page_dict["type"] == PageType.FrontCover:
front_cover = int(page_dict["image_index"])
break
@ -272,53 +256,51 @@ class PageListEditor(QtWidgets.QWidget):
def get_current_page_type(self) -> str:
row = self.listWidget.currentRow()
page_dict: ImageMetadata = self.listWidget.item(row).data(QtCore.Qt.ItemDataRole.UserRole)
page_dict: ImageMetadata = self.listWidget.item(row).data(QtCore.Qt.ItemDataRole.UserRole)[0]
if "type" in page_dict:
return page_dict["type"]
return ""
def set_current_page_type(self, t: str) -> None:
rows = self.listWidget.selectionModel().selectedRows()
for index in rows:
row = index.row()
page_dict: ImageMetadata = self.listWidget.item(row).data(QtCore.Qt.ItemDataRole.UserRole)
row = self.listWidget.currentRow()
page_dict: ImageMetadata = self.listWidget.item(row).data(QtCore.Qt.ItemDataRole.UserRole)[0]
if t == "":
if "type" in page_dict:
del page_dict["type"]
else:
page_dict["type"] = t
if t == "":
if "type" in page_dict:
del page_dict["type"]
else:
page_dict["type"] = t
item = self.listWidget.item(row)
item.setData(QtCore.Qt.ItemDataRole.UserRole, page_dict)
item.setText(self.list_entry_text(page_dict))
item = self.listWidget.item(row)
# wrap the dict in a tuple to keep from being converted to QtWidgets.QStrings
item.setData(QtCore.Qt.ItemDataRole.UserRole, (page_dict,))
item.setText(self.list_entry_text(page_dict))
def toggle_double_page(self) -> None:
rows = self.listWidget.selectionModel().selectedRows()
for index in rows:
row = index.row()
page_dict: ImageMetadata = self.listWidget.item(row).data(QtCore.Qt.ItemDataRole.UserRole)
row = self.listWidget.currentRow()
page_dict: ImageMetadata = self.listWidget.item(row).data(QtCore.Qt.UserRole)[0]
cbx = self.sender()
cbx = self.sender()
if isinstance(cbx, QtWidgets.QCheckBox) and cbx.isChecked():
if "double_page" not in page_dict:
page_dict["double_page"] = True
self.modified.emit()
elif "double_page" in page_dict:
del page_dict["double_page"]
if isinstance(cbx, QtWidgets.QCheckBox) and cbx.isChecked():
if "double_page" not in page_dict:
page_dict["double_page"] = True
self.modified.emit()
elif "double_page" in page_dict:
del page_dict["double_page"]
self.modified.emit()
item = self.listWidget.item(row)
item.setData(QtCore.Qt.ItemDataRole.UserRole, page_dict)
item.setText(self.list_entry_text(page_dict))
item = self.listWidget.item(row)
# wrap the dict in a tuple to keep from being converted to QStrings
item.setData(QtCore.Qt.UserRole, (page_dict,))
item.setText(self.list_entry_text(page_dict))
self.listWidget.setFocus()
def save_bookmark(self) -> None:
row = self.listWidget.currentRow()
page_dict: ImageMetadata = self.listWidget.item(row).data(QtCore.Qt.ItemDataRole.UserRole)
page_dict: ImageMetadata = self.listWidget.item(row).data(QtCore.Qt.UserRole)[0]
current_bookmark = ""
if "bookmark" in page_dict:
@ -334,7 +316,8 @@ class PageListEditor(QtWidgets.QWidget):
self.modified.emit()
item = self.listWidget.item(row)
item.setData(QtCore.Qt.ItemDataRole.UserRole, page_dict)
# wrap the dict in a tuple to keep from being converted to QStrings
item.setData(QtCore.Qt.UserRole, (page_dict,))
item.setText(self.list_entry_text(page_dict))
self.listWidget.setFocus()
@ -354,7 +337,8 @@ class PageListEditor(QtWidgets.QWidget):
self.listWidget.clear()
for p in pages_list:
item = QtWidgets.QListWidgetItem(self.list_entry_text(p))
item.setData(QtCore.Qt.ItemDataRole.UserRole, p)
# wrap the dict in a tuple to keep from being converted to QtWidgets.QStrings
item.setData(QtCore.Qt.ItemDataRole.UserRole, (p,))
self.listWidget.addItem(item)
self.first_front_page = self.get_first_front_cover()
@ -378,7 +362,7 @@ class PageListEditor(QtWidgets.QWidget):
page_list = []
for i in range(self.listWidget.count()):
item = self.listWidget.item(i)
page_list.append(item.data(QtCore.Qt.ItemDataRole.UserRole))
page_list.append(item.data(QtCore.Qt.ItemDataRole.UserRole)[0])
return page_list
def emit_front_cover_change(self) -> None:

View File

@ -39,7 +39,7 @@ class RenameWindow(QtWidgets.QDialog):
self,
parent: QtWidgets.QWidget,
comic_archive_list: list[ComicArchive],
load_data_styles: list[str],
data_style: str,
config: settngs.Config[ct_ns],
talkers: dict[str, ComicTalker],
) -> None:
@ -48,9 +48,7 @@ class RenameWindow(QtWidgets.QDialog):
with (ui_path / "renamewindow.ui").open(encoding="utf-8") as uifile:
uic.loadUi(uifile, self)
self.label.setText(
f"Preview (based on {', '.join(metadata_styles[style].name() for style in load_data_styles)} tags):"
)
self.label.setText(f"Preview (based on {metadata_styles[data_style].name()} tags):")
self.setWindowFlags(
QtCore.Qt.WindowType(
@ -63,7 +61,7 @@ class RenameWindow(QtWidgets.QDialog):
self.config = config
self.talkers = talkers
self.comic_archive_list = comic_archive_list
self.load_data_styles = load_data_styles
self.data_style = data_style
self.rename_list: list[str] = []
self.btnSettings.clicked.connect(self.modify_settings)
@ -72,36 +70,27 @@ class RenameWindow(QtWidgets.QDialog):
self.do_preview()
def config_renamer(self, ca: ComicArchive, md: GenericMetadata = GenericMetadata()) -> str:
def config_renamer(self, ca: ComicArchive, md: GenericMetadata | None = None) -> str:
self.renamer.set_template(self.config[0].File_Rename__template)
self.renamer.set_issue_zero_padding(self.config[0].File_Rename__issue_number_padding)
self.renamer.set_smart_cleanup(self.config[0].File_Rename__use_smart_string_cleanup)
self.renamer.replacements = self.config[0].File_Rename__replacements
self.renamer.move_only = self.config[0].File_Rename__only_move
new_ext = ca.path.suffix # default
if self.config[0].File_Rename__auto_extension:
new_ext = ca.extension()
if md is None or md.is_empty:
md, error = self.parent().overlay_ca_read_style(self.load_data_styles, ca)
if error is not None:
logger.error("Failed to load metadata for %s: %s", ca.path, error)
QtWidgets.QMessageBox.warning(
self,
"Read Failed!",
f"One or more of the read styles failed to load for {ca.path}, check log for details",
)
if md is None:
md = ca.read_metadata(self.data_style)
if md.is_empty:
md = ca.metadata_from_filename(
self.config[0].Filename_Parsing__filename_parser,
self.config[0].Filename_Parsing__complicated_parser,
self.config[0].Filename_Parsing__remove_c2c,
self.config[0].Filename_Parsing__remove_fcbd,
self.config[0].Filename_Parsing__remove_publisher,
)
self.renamer.set_metadata(md, ca.path.name)
self.renamer.move = self.config[0].File_Rename__move
self.renamer.set_metadata(md)
self.renamer.move = self.config[0].File_Rename__move_to_dir
return new_ext
def do_preview(self) -> None:
@ -203,7 +192,7 @@ class RenameWindow(QtWidgets.QDialog):
folder = get_rename_dir(
comic[0],
self.config[0].File_Rename__dir if self.config[0].File_Rename__move else None,
self.config[0].File_Rename__dir if self.config[0].File_Rename__move_to_dir else None,
)
full_path = folder / comic[1]

View File

@ -2,11 +2,51 @@ from __future__ import annotations
import dataclasses
import pathlib
from enum import auto
import sys
from enum import Enum, auto
from typing import Any
from comicapi import utils
from comicapi.genericmetadata import GenericMetadata
if sys.version_info < (3, 11):
class StrEnum(str, Enum):
"""
Enum where members are also (and must be) strings
"""
def __new__(cls, *values: Any) -> Any:
"values must already be of type `str`"
if len(values) > 3:
raise TypeError(f"too many arguments for str(): {values!r}")
if len(values) == 1:
# it must be a string
if not isinstance(values[0], str):
raise TypeError(f"{values[0]!r} is not a string")
if len(values) >= 2:
# check that encoding argument is a string
if not isinstance(values[1], str):
raise TypeError(f"encoding must be a string, not {values[1]!r}")
if len(values) == 3:
# check that errors argument is a string
if not isinstance(values[2], str):
raise TypeError("errors must be a string, not %r" % (values[2]))
value = str(*values)
member = str.__new__(cls, value)
member._value_ = value
return member
@staticmethod
def _generate_next_value_(name: str, start: int, count: int, last_values: Any) -> str:
"""
Return the lower-cased version of the member name.
"""
return name.lower()
else:
from enum import StrEnum
@dataclasses.dataclass
class IssueResult:
@ -29,8 +69,7 @@ class IssueResult:
return f"series: {self.series}; series id: {self.series_id}; issue number: {self.issue_number}; issue id: {self.issue_id}; published: {self.month} {self.year}"
class Action(utils.StrEnum):
gui = auto()
class Action(StrEnum):
print = auto()
delete = auto()
copy = auto()
@ -41,14 +80,14 @@ class Action(utils.StrEnum):
list_plugins = auto()
class MatchStatus(utils.StrEnum):
class MatchStatus(StrEnum):
good_match = auto()
no_match = auto()
multiple_match = auto()
low_confidence_match = auto()
class Status(utils.StrEnum):
class Status(StrEnum):
success = auto()
match_failure = auto()
write_failure = auto()

View File

@ -247,6 +247,11 @@ class SeriesSelectionWindow(QtWidgets.QDialog):
md.year = self.year
md.issue_count = self.issue_count
# self.ii.set_additional_metadata(md)
# self.ii.only_use_additional_meta_data = True
# self.ii.cover_page_index = int(self.cover_index_list[0])
self.id_thread = IdentifyThread(self.ii, self.comic_archive, md)
self.id_thread.identifyComplete.connect(self.identify_complete)
self.id_thread.identifyLogMsg.connect(self.log_id_output)

View File

@ -28,7 +28,7 @@ import settngs
from PyQt5 import QtCore, QtGui, QtWidgets, uic
import comictaggerlib.ui.talkeruigenerator
from comicapi import merge, utils
from comicapi import utils
from comicapi.archivers.archiver import Archiver
from comicapi.genericmetadata import md_test
from comictaggerlib import ctsettings
@ -192,11 +192,6 @@ class SettingsWindow(QtWidgets.QDialog):
self.sources = comictaggerlib.ui.talkeruigenerator.generate_source_option_tabs(
self.tComicTalkers, self.config, self.talkers
)
self.cbFilenameParser.clear()
self.cbFilenameParser.addItems(utils.Parser)
for mode in merge.Mode:
self.cbxOverlayReadStyle.addItem(mode.name.capitalize().replace("_", " "), mode.value)
self.cbxOverlaySource.addItem(mode.name.capitalize().replace("_", " "), mode.value)
self.connect_signals()
self.settings_to_form()
self.rename_test()
@ -213,9 +208,8 @@ class SettingsWindow(QtWidgets.QDialog):
self.btnResetSettings.clicked.connect(self.reset_settings)
self.btnTemplateHelp.clicked.connect(self.show_template_help)
self.cbxMoveFiles.clicked.connect(self.dir_test)
self.cbxMoveOnly.clicked.connect(self.move_only_clicked)
self.leDirectory.textEdited.connect(self.dir_test)
self.cbFilenameParser.currentIndexChanged.connect(self.switch_parser)
self.cbxComplicatedParser.clicked.connect(self.switch_parser)
self.btnAddLiteralReplacement.clicked.connect(self.addLiteralReplacement)
self.btnAddValueReplacement.clicked.connect(self.addValueReplacement)
@ -224,7 +218,6 @@ class SettingsWindow(QtWidgets.QDialog):
self.leRenameTemplate.textEdited.connect(self.rename_test)
self.cbxMoveFiles.clicked.connect(self.rename_test)
self.cbxMoveOnly.clicked.connect(self.rename_test)
self.cbxRenameStrict.clicked.connect(self.rename_test)
self.cbxSmartCleanup.clicked.connect(self.rename_test)
self.cbxChangeExtension.clicked.connect(self.rename_test)
@ -251,9 +244,8 @@ class SettingsWindow(QtWidgets.QDialog):
self.btnResetSettings.clicked.disconnect()
self.btnTemplateHelp.clicked.disconnect()
self.cbxChangeExtension.clicked.disconnect()
self.cbFilenameParser.currentIndexChanged.disconnect()
self.cbxComplicatedParser.clicked.disconnect()
self.cbxMoveFiles.clicked.disconnect()
self.cbxMoveOnly.clicked.disconnect()
self.cbxRenameStrict.clicked.disconnect()
self.cbxSmartCleanup.clicked.disconnect()
self.leDirectory.textEdited.disconnect()
@ -281,10 +273,9 @@ class SettingsWindow(QtWidgets.QDialog):
self._filename_parser_test(self.leFilenameParserTest.text())
def _filename_parser_test(self, filename: str) -> None:
self.cbFilenameParser: QtWidgets.QComboBox
filename_info = utils.parse_filename(
filename=filename,
parser=utils.Parser(self.cbFilenameParser.currentText()),
complicated_parser=self.cbxComplicatedParser.isChecked(),
remove_c2c=self.cbxRemoveC2C.isChecked(),
remove_fcbd=self.cbxRemoveFCBD.isChecked(),
remove_publisher=self.cbxRemovePublisher.isChecked(),
@ -342,31 +333,19 @@ class SettingsWindow(QtWidgets.QDialog):
def rename_test(self, *args: Any, **kwargs: Any) -> None:
self._rename_test(self.leRenameTemplate.text())
def move_only_clicked(self, *args: Any, **kwargs: Any) -> None:
if self.cbxMoveOnly.isChecked():
self.cbxMoveFiles.setEnabled(False)
self.cbxMoveFiles.setChecked(True)
else:
self.cbxMoveFiles.setEnabled(True)
self.dir_test()
def dir_test(self) -> None:
self.lblDir.setText(
str(pathlib.Path(self.leDirectory.text().strip()).resolve())
if self.cbxMoveFiles.isChecked() or self.cbxMoveOnly.isChecked()
else ""
str(pathlib.Path(self.leDirectory.text().strip()).resolve()) if self.cbxMoveFiles.isChecked() else ""
)
def _rename_test(self, template: str) -> None:
if not str(self.leIssueNumPadding.text()).isdigit():
self.leIssueNumPadding.setText("0")
fr = FileRenamer(
None,
md_test,
platform="universal" if self.cbxRenameStrict.isChecked() else "auto",
replacements=self.get_replacements(),
)
fr.set_metadata(md_test, "cory doctorow #1.cbz")
fr.move_only = self.cbxMoveOnly.isChecked()
fr.move = self.cbxMoveFiles.isChecked()
fr.set_template(template)
fr.set_issue_zero_padding(int(self.leIssueNumPadding.text()))
@ -379,14 +358,11 @@ class SettingsWindow(QtWidgets.QDialog):
self.lblRenameTest.setText(str(e))
def switch_parser(self) -> None:
currentParser = utils.Parser(self.cbFilenameParser.currentText())
complicated = self.cbxComplicatedParser.isChecked()
complicated = currentParser == utils.Parser.COMPLICATED
self.cbxRemoveC2C.setEnabled(complicated)
self.cbxRemoveFCBD.setEnabled(complicated)
self.cbxRemovePublisher.setEnabled(complicated)
self.cbxProtofoliusIssueNumberScheme.setEnabled(complicated)
self.cbxAllowIssueStartWithLetter.setEnabled(complicated)
self.filename_parser_test()
def settings_to_form(self) -> None:
@ -402,8 +378,9 @@ class SettingsWindow(QtWidgets.QDialog):
self.tePublisherFilter.setPlainText("\n".join(self.config[0].Issue_Identifier__publisher_filter))
self.cbxCheckForNewVersion.setChecked(self.config[0].General__check_for_new_version)
self.cbxShortMetadataNames.setChecked(self.config[0].General__use_short_metadata_names)
self.cbFilenameParser.setCurrentText(self.config[0].Filename_Parsing__filename_parser)
self.cbxComplicatedParser.setChecked(self.config[0].Filename_Parsing__complicated_parser)
self.cbxRemoveC2C.setChecked(self.config[0].Filename_Parsing__remove_c2c)
self.cbxRemoveFCBD.setChecked(self.config[0].Filename_Parsing__remove_fcbd)
self.cbxRemovePublisher.setChecked(self.config[0].Filename_Parsing__remove_publisher)
@ -419,33 +396,23 @@ class SettingsWindow(QtWidgets.QDialog):
self.cbxExactMatches.setChecked(self.config[0].Issue_Identifier__exact_series_matches_first)
self.cbxClearFormBeforePopulating.setChecked(self.config[0].Issue_Identifier__clear_metadata)
self.cbxAssumeLoneCreditIsPrimary.setChecked(self.config[0].Metadata_Options__cbl_assume_lone_credit_is_primary)
self.cbxCopyCharactersToTags.setChecked(self.config[0].Metadata_Options__cbl_copy_characters_to_tags)
self.cbxCopyTeamsToTags.setChecked(self.config[0].Metadata_Options__cbl_copy_teams_to_tags)
self.cbxCopyLocationsToTags.setChecked(self.config[0].Metadata_Options__cbl_copy_locations_to_tags)
self.cbxCopyStoryArcsToTags.setChecked(self.config[0].Metadata_Options__cbl_copy_storyarcs_to_tags)
self.cbxCopyNotesToComments.setChecked(self.config[0].Metadata_Options__cbl_copy_notes_to_comments)
self.cbxCopyWebLinkToComments.setChecked(self.config[0].Metadata_Options__cbl_copy_weblink_to_comments)
self.cbxApplyCBLTransformOnCVIMport.setChecked(self.config[0].Metadata_Options__cbl_apply_transform_on_import)
self.cbxAssumeLoneCreditIsPrimary.setChecked(self.config[0].Comic_Book_Lover__assume_lone_credit_is_primary)
self.cbxCopyCharactersToTags.setChecked(self.config[0].Comic_Book_Lover__copy_characters_to_tags)
self.cbxCopyTeamsToTags.setChecked(self.config[0].Comic_Book_Lover__copy_teams_to_tags)
self.cbxCopyLocationsToTags.setChecked(self.config[0].Comic_Book_Lover__copy_locations_to_tags)
self.cbxCopyStoryArcsToTags.setChecked(self.config[0].Comic_Book_Lover__copy_storyarcs_to_tags)
self.cbxCopyNotesToComments.setChecked(self.config[0].Comic_Book_Lover__copy_notes_to_comments)
self.cbxCopyWebLinkToComments.setChecked(self.config[0].Comic_Book_Lover__copy_weblink_to_comments)
self.cbxApplyCBLTransformOnCVIMport.setChecked(self.config[0].Comic_Book_Lover__apply_transform_on_import)
self.cbxApplyCBLTransformOnBatchOperation.setChecked(
self.config[0].Metadata_Options__cbl_apply_transform_on_bulk_operation
self.config[0].Comic_Book_Lover__apply_transform_on_bulk_operation
)
self.cbxOverlayReadStyle.setCurrentIndex(
self.cbxOverlayReadStyle.findData(self.config[0].internal__load_data_overlay.value)
)
self.cbxOverlaySource.setCurrentIndex(
self.cbxOverlaySource.findData(self.config[0].internal__source_data_overlay.value)
)
self.cbxOverlayMergeLists.setChecked(self.config[0].internal__overlay_merge_lists)
self.cbxShortMetadataNames.setChecked(self.config[0].Metadata_Options__use_short_metadata_names)
self.cbxDisableCR.setChecked(self.config[0].Metadata_Options__disable_cr)
self.leRenameTemplate.setText(self.config[0].File_Rename__template)
self.leIssueNumPadding.setText(str(self.config[0].File_Rename__issue_number_padding))
self.cbxSmartCleanup.setChecked(self.config[0].File_Rename__use_smart_string_cleanup)
self.cbxChangeExtension.setChecked(self.config[0].File_Rename__auto_extension)
self.cbxMoveFiles.setChecked(self.config[0].File_Rename__move)
self.cbxMoveOnly.setChecked(self.config[0].File_Rename__only_move)
self.cbxMoveFiles.setChecked(self.config[0].File_Rename__move_to_dir)
self.leDirectory.setText(self.config[0].File_Rename__dir)
self.cbxRenameStrict.setChecked(self.config[0].File_Rename__strict)
@ -530,11 +497,17 @@ class SettingsWindow(QtWidgets.QDialog):
self.config[0].General__check_for_new_version = self.cbxCheckForNewVersion.isChecked()
# Update metadata style names if required
if self.cbxShortMetadataNames.isChecked() != self.config[0].General__use_short_metadata_names:
self.config[0].General__use_short_metadata_names = self.cbxShortMetadataNames.isChecked()
self.parent().populate_style_names()
self.parent().adjust_save_style_combo()
self.config[0].Issue_Identifier__series_match_identify_thresh = self.sbNameMatchIdentifyThresh.value()
self.config[0].Issue_Identifier__series_match_search_thresh = self.sbNameMatchSearchThresh.value()
self.config[0].Issue_Identifier__publisher_filter = utils.split(self.tePublisherFilter.toPlainText(), "\n")
self.config[0].Filename_Parsing__filename_parser = utils.Parser(self.cbFilenameParser.currentText())
self.config[0].Filename_Parsing__complicated_parser = self.cbxComplicatedParser.isChecked()
self.config[0].Filename_Parsing__remove_c2c = self.cbxRemoveC2C.isChecked()
self.config[0].Filename_Parsing__remove_fcbd = self.cbxRemoveFCBD.isChecked()
self.config[0].Filename_Parsing__remove_publisher = self.cbxRemovePublisher.isChecked()
@ -548,36 +521,23 @@ class SettingsWindow(QtWidgets.QDialog):
self.config[0].Issue_Identifier__exact_series_matches_first = self.cbxExactMatches.isChecked()
self.config[0].Issue_Identifier__clear_metadata = self.cbxClearFormBeforePopulating.isChecked()
self.config[0].Metadata_Options__cbl_assume_lone_credit_is_primary = (
self.cbxAssumeLoneCreditIsPrimary.isChecked()
)
self.config[0].Metadata_Options__cbl_copy_characters_to_tags = self.cbxCopyCharactersToTags.isChecked()
self.config[0].Metadata_Options__cbl_copy_teams_to_tags = self.cbxCopyTeamsToTags.isChecked()
self.config[0].Metadata_Options__cbl_copy_locations_to_tags = self.cbxCopyLocationsToTags.isChecked()
self.config[0].Metadata_Options__cbl_copy_storyarcs_to_tags = self.cbxCopyStoryArcsToTags.isChecked()
self.config[0].Metadata_Options__cbl_copy_notes_to_comments = self.cbxCopyNotesToComments.isChecked()
self.config[0].Metadata_Options__cbl_copy_weblink_to_comments = self.cbxCopyWebLinkToComments.isChecked()
self.config[0].Metadata_Options__cbl_apply_transform_on_import = self.cbxApplyCBLTransformOnCVIMport.isChecked()
self.config.values.Metadata_Options__cbl_apply_transform_on_bulk_operation = (
self.config[0].Comic_Book_Lover__assume_lone_credit_is_primary = self.cbxAssumeLoneCreditIsPrimary.isChecked()
self.config[0].Comic_Book_Lover__copy_characters_to_tags = self.cbxCopyCharactersToTags.isChecked()
self.config[0].Comic_Book_Lover__copy_teams_to_tags = self.cbxCopyTeamsToTags.isChecked()
self.config[0].Comic_Book_Lover__copy_locations_to_tags = self.cbxCopyLocationsToTags.isChecked()
self.config[0].Comic_Book_Lover__copy_storyarcs_to_tags = self.cbxCopyStoryArcsToTags.isChecked()
self.config[0].Comic_Book_Lover__copy_notes_to_comments = self.cbxCopyNotesToComments.isChecked()
self.config[0].Comic_Book_Lover__copy_weblink_to_comments = self.cbxCopyWebLinkToComments.isChecked()
self.config[0].Comic_Book_Lover__apply_transform_on_import = self.cbxApplyCBLTransformOnCVIMport.isChecked()
self.config.values.Comic_Book_Lover__apply_transform_on_bulk_operation = (
self.cbxApplyCBLTransformOnBatchOperation.isChecked()
)
self.config[0].internal__load_data_overlay = merge.Mode[self.cbxOverlayReadStyle.currentData().upper()]
self.config[0].internal__source_data_overlay = merge.Mode[self.cbxOverlaySource.currentData().upper()]
self.config[0].internal__overlay_merge_lists = self.cbxOverlayMergeLists.isChecked()
self.config[0].Metadata_Options__disable_cr = self.cbxDisableCR.isChecked()
# Update metadata style names if required
if self.config[0].Metadata_Options__use_short_metadata_names != self.cbxShortMetadataNames.isChecked():
self.config[0].Metadata_Options__use_short_metadata_names = self.cbxShortMetadataNames.isChecked()
self.parent().populate_style_names()
self.parent().adjust_save_style_combo()
self.config[0].File_Rename__template = str(self.leRenameTemplate.text())
self.config[0].File_Rename__issue_number_padding = int(self.leIssueNumPadding.text())
self.config[0].File_Rename__use_smart_string_cleanup = self.cbxSmartCleanup.isChecked()
self.config[0].File_Rename__auto_extension = self.cbxChangeExtension.isChecked()
self.config[0].File_Rename__move = self.cbxMoveFiles.isChecked()
self.config[0].File_Rename__only_move = self.cbxMoveOnly.isChecked()
self.config[0].File_Rename__move_to_dir = self.cbxMoveFiles.isChecked()
self.config[0].File_Rename__dir = self.leDirectory.text()
self.config[0].File_Rename__strict = self.cbxRenameStrict.isChecked()

View File

@ -25,13 +25,14 @@ import platform
import re
import sys
import webbrowser
from typing import Any, Callable, cast
from datetime import datetime
from typing import Any, Callable
from urllib.parse import urlparse
import natsort
import settngs
from PyQt5 import QtCore, QtGui, QtNetwork, QtWidgets, uic
import comicapi.merge
import comictaggerlib.ui
from comicapi import utils
from comicapi.comicarchive import ComicArchive, metadata_styles
@ -52,18 +53,18 @@ from comictaggerlib.fileselectionlist import FileSelectionList
from comictaggerlib.graphics import graphics_path
from comictaggerlib.issueidentifier import IssueIdentifier
from comictaggerlib.logwindow import LogWindow
from comictaggerlib.md import prepare_metadata
from comictaggerlib.optionalmsgdialog import OptionalMessageDialog
from comictaggerlib.pagebrowser import PageBrowserWindow
from comictaggerlib.pagelisteditor import PageListEditor
from comictaggerlib.renamewindow import RenameWindow
from comictaggerlib.resulttypes import Action, MatchStatus, OnlineMatchResults, Result, Status
from comictaggerlib.resulttypes import Action, IssueResult, MatchStatus, OnlineMatchResults, Result, Status
from comictaggerlib.seriesselectionwindow import SeriesSelectionWindow
from comictaggerlib.settingswindow import SettingsWindow
from comictaggerlib.ui import ui_path
from comictaggerlib.ui.qtutils import center_window_on_parent, enable_widget, reduce_widget_font_size
from comictaggerlib.versionchecker import VersionChecker
from comictalker.comictalker import ComicTalker, TalkerError
from comictalker.talker_utils import cleanup_html
logger = logging.getLogger(__name__)
@ -111,7 +112,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
"alternate_count": self.leAltIssueCount,
"imprint": self.leImprint,
"notes": self.teNotes,
"web_links": (self.leWebLink, self.btnOpenWebLink, self.btnAddWebLink, self.btnRemoveWebLink),
"web_link": self.leWebLink,
"format": self.cbFormat,
"manga": self.cbManga,
"black_and_white": self.cbBW,
@ -123,7 +124,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
"characters": self.teCharacters,
"teams": self.teTeams,
"locations": self.teLocations,
"credits": (self.twCredits, self.btnAddCredit, self.btnEditCredit, self.btnRemoveCredit),
"credits": [self.twCredits, self.btnAddCredit, self.btnEditCredit, self.btnRemoveCredit],
"credits.person": 2,
"credits.role": 1,
"credits.primary": 0,
@ -212,28 +213,18 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.setWindowIcon(QtGui.QIcon(str(graphics_path / "app.png")))
# respect the command line option tag type
if config[0].Runtime_Options__type_modify:
config[0].internal__save_data_style = config[0].Runtime_Options__type_modify
if config[0].Runtime_Options__type_read:
config[0].internal__load_data_style = config[0].Runtime_Options__type_read
# Respect command line overlay settings
if config[0].Runtime_Options__read_style_overlay:
config[0].internal__load_data_overlay = config[0].Runtime_Options__read_style_overlay
if config[0].Runtime_Options__source_overlay:
config[0].internal__source_data_overlay = config[0].Runtime_Options__source_overlay
if isinstance(config[0].Runtime_Options__overlay_merge_lists, bool):
config[0].internal__overlay_merge_lists = config[0].Runtime_Options__overlay_merge_lists
if config[0].Runtime_Options__type and isinstance(config[0].Runtime_Options__type[0], str):
# respect the command line option tag type
config[0].internal__save_data_style = config[0].Runtime_Options__type
config[0].internal__load_data_style = config[0].Runtime_Options__type[0]
for style in config[0].internal__save_data_style:
if style not in metadata_styles:
config[0].internal__save_data_style.remove(style)
for style in config[0].internal__load_data_style:
if style not in metadata_styles:
config[0].internal__load_data_style.remove(style)
if config[0].internal__load_data_style not in metadata_styles:
config[0].internal__load_data_style = list(metadata_styles.keys())[0]
self.save_data_styles: list[str] = config[0].internal__save_data_style
self.load_data_styles: list[str] = config[0].internal__load_data_style
self.load_data_style: str = config[0].internal__load_data_style
self.setAcceptDrops(True)
self.view_tag_actions, self.remove_tag_actions = self.tag_actions()
@ -282,7 +273,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.cbMaturityRating.lineEdit().setAcceptDrops(False)
# hook up the callbacks
self.cbLoadDataStyle.dropdownClosed.connect(self.set_load_data_style)
self.cbLoadDataStyle.currentIndexChanged.connect(self.set_load_data_style)
self.cbSaveDataStyle.itemChecked.connect(self.set_save_data_style)
self.cbx_sources.currentIndexChanged.connect(self.set_source)
self.btnEditCredit.clicked.connect(self.edit_credit)
@ -444,7 +435,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.actionAutoTag.triggered.connect(self.auto_tag)
self.actionCopyTags.setShortcut("Ctrl+C")
self.actionCopyTags.setStatusTip("Copy one tag style tags to enabled modify style(s)")
self.actionCopyTags.setStatusTip("Copy one tag style to another")
self.actionCopyTags.triggered.connect(self.copy_tags)
self.actionRemoveAuto.setShortcut("Ctrl+D")
@ -541,31 +532,6 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.toolBar.addAction(self.actionPageBrowser)
self.toolBar.addAction(self.actionAutoImprint)
self.leWebLink.addAction(self.actionAddWebLink)
self.leWebLink.addAction(self.actionRemoveWebLink)
self.actionAddWebLink.triggered.connect(self.add_weblink_item)
self.actionRemoveWebLink.triggered.connect(self.remove_weblink_item)
def add_weblink_item(self, url: str = "") -> None:
item = ""
if isinstance(url, str):
item = url
self.leWebLink.addItem(item)
self.leWebLink.item(self.leWebLink.count() - 1).setFlags(
QtCore.Qt.ItemFlag.ItemIsEditable
| QtCore.Qt.ItemFlag.ItemIsEnabled
| QtCore.Qt.ItemFlag.ItemIsDragEnabled
| QtCore.Qt.ItemFlag.ItemIsSelectable
)
self.leWebLink.item(self.leWebLink.count() - 1).setSelected(True)
if not url:
self.leWebLink.editItem(self.leWebLink.item(self.leWebLink.count() - 1))
def remove_weblink_item(self) -> None:
item = self.leWebLink.takeItem(self.leWebLink.currentRow())
del item
def repackage_archive(self) -> None:
ca_list = self.fileSelectionList.get_selected_archive_list()
non_zip_count = 0
@ -818,8 +784,6 @@ class TaggerWindow(QtWidgets.QMainWindow):
widget.currentIndexChanged.connect(self.set_dirty_flag)
if isinstance(widget, QtWidgets.QCheckBox):
widget.stateChanged.connect(self.set_dirty_flag)
if isinstance(widget, QtWidgets.QListWidget):
widget.itemChanged.connect(self.set_dirty_flag)
# recursive call on children
for child in widget.children():
@ -846,8 +810,6 @@ class TaggerWindow(QtWidgets.QMainWindow):
widget.setChecked(False)
if isinstance(widget, QtWidgets.QTableWidget):
widget.setRowCount(0)
if isinstance(widget, QtWidgets.QListWidget):
widget.clear()
# recursive call on children
for child in widget.children():
@ -858,10 +820,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
def metadata_to_form(self) -> None:
def assign_text(field: QtWidgets.QLineEdit | QtWidgets.QTextEdit, value: Any) -> None:
if value is not None:
if isinstance(field, QtWidgets.QTextEdit) and False:
field.setPlainText(str(value))
else:
field.setText(str(value))
field.setText(str(value))
md = self.metadata
@ -885,9 +844,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
assign_text(self.leAltSeries, md.alternate_series)
assign_text(self.leAltIssueNum, md.alternate_number)
assign_text(self.leAltIssueCount, md.alternate_count)
self.leWebLink.clear()
for u in md.web_links:
self.add_weblink_item(u.url)
assign_text(self.leWebLink, md.web_link)
assign_text(self.teCharacters, "\n".join(md.characters))
assign_text(self.teTeams, "\n".join(md.teams))
assign_text(self.teLocations, "\n".join(md.locations))
@ -942,10 +899,12 @@ class TaggerWindow(QtWidgets.QMainWindow):
for row, credit in enumerate(md.credits):
# if the role-person pair already exists, just skip adding it to the list
if self.is_dupe_credit(credit.role.title(), credit.person):
if self.is_dupe_credit(credit["role"].title(), credit["person"]):
continue
self.add_new_credit_entry(row, credit.role.title(), credit.person, credit.primary)
self.add_new_credit_entry(
row, credit["role"].title(), credit["person"], (credit["primary"] if "primary" in credit else False)
)
self.twCredits.setSortingEnabled(True)
self.update_metadata_credit_colors()
@ -1008,7 +967,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
md.scan_info = utils.xlate(self.leScanInfo.text())
md.series_groups = utils.split(self.leSeriesGroup.text(), ",")
md.alternate_series = self.leAltSeries.text()
md.web_links = [utils.parse_url(self.leWebLink.item(i).text()) for i in range(self.leWebLink.count())]
md.web_link = utils.xlate(self.leWebLink.text())
md.characters = set(utils.split(self.teCharacters.toPlainText(), "\n"))
md.teams = set(utils.split(self.teTeams.toPlainText(), "\n"))
md.locations = set(utils.split(self.teLocations.toPlainText(), "\n"))
@ -1045,7 +1004,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
# copy the form onto metadata object
self.form_to_metadata()
new_metadata = self.comic_archive.metadata_from_filename(
self.config[0].Filename_Parsing__filename_parser,
self.config[0].Filename_Parsing__complicated_parser,
self.config[0].Filename_Parsing__remove_c2c,
self.config[0].Filename_Parsing__remove_fcbd,
self.config[0].Filename_Parsing__remove_publisher,
@ -1053,7 +1012,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.config[0].Filename_Parsing__allow_issue_start_with_letter,
self.config[0].Filename_Parsing__protofolius_issue_number_scheme,
)
self.metadata.overlay(new_metadata, mode=comicapi.merge.Mode.OVERLAY, merge_lists=False)
self.metadata.overlay(new_metadata)
self.metadata_to_form()
def use_filename_split(self) -> None:
@ -1158,18 +1117,33 @@ class TaggerWindow(QtWidgets.QMainWindow):
except TalkerError as e:
QtWidgets.QApplication.restoreOverrideCursor()
QtWidgets.QMessageBox.critical(self, f"{e.source} {e.code_name} Error", f"{e}")
return
QtWidgets.QApplication.restoreOverrideCursor()
else:
QtWidgets.QApplication.restoreOverrideCursor()
if new_metadata is not None:
if self.config[0].Comic_Book_Lover__apply_transform_on_import:
new_metadata = CBLTransformer(new_metadata, self.config[0]).apply()
if new_metadata is None or new_metadata.is_empty:
QtWidgets.QMessageBox.critical(
self, "Search", f"Could not find an issue {selector.issue_number} for that series"
)
return
if self.config[0].Issue_Identifier__clear_metadata:
self.clear_form()
self.metadata = prepare_metadata(self.metadata, new_metadata, self.config[0])
# Now push the new combined data into the edit controls
self.metadata_to_form()
notes = (
f"Tagged with ComicTagger {ctversion.version} using info from {self.current_talker().name} on"
f" {datetime.now():%Y-%m-%d %H:%M:%S}. [Issue ID {new_metadata.issue_id}]"
)
self.metadata.overlay(
new_metadata.replace(
notes=utils.combine_notes(self.metadata.notes, notes, "Tagged with ComicTagger"),
description=cleanup_html(
new_metadata.description, self.config[0].Sources__remove_html_tables
),
)
)
# Now push the new combined data into the edit controls
self.metadata_to_form()
else:
QtWidgets.QMessageBox.critical(
self, "Search", f"Could not find an issue {selector.issue_number} for that series"
)
def commit_metadata(self) -> None:
if self.metadata is not None and self.comic_archive is not None:
@ -1197,7 +1171,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
failed_style = metadata_styles[style].name()
break
self.comic_archive.load_cache(set(metadata_styles))
self.comic_archive.load_cache(list(metadata_styles))
QtWidgets.QApplication.restoreOverrideCursor()
if failed_style:
@ -1210,37 +1184,26 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.clear_dirty_flag()
self.update_info_box()
self.update_menus()
# Only try to read if write was successful
self.metadata, error = self.overlay_ca_read_style(self.load_data_styles, self.comic_archive)
if error is not None:
QtWidgets.QMessageBox.warning(
self,
"Read Failed!",
f"One or more of the read styles failed to load for {self.comic_archive.path}, check log for details",
)
logger.error("Failed to load metadata for %s: %s", self.ca.path, error)
self.fileSelectionList.update_current_row()
self.metadata = self.comic_archive.read_metadata(self.load_data_style)
self.update_ui_for_archive()
else:
QtWidgets.QMessageBox.information(self, "Whoops!", "No data to commit!")
def set_load_data_style(self, load_data_styles: list[str]) -> None:
"""Should only be called from the combobox signal"""
def set_load_data_style(self, s: str) -> None:
if self.dirty_flag_verification(
"Change Tag Read Style",
"If you change read tag style(s) now, data in the form will be lost. Are you sure?",
"Change Tag Read Style", "If you change read tag style now, data in the form will be lost. Are you sure?"
):
self.load_data_styles = list(reversed(load_data_styles))
self.config[0].internal__load_data_style = self.load_data_styles
self.load_data_style = self.cbLoadDataStyle.itemData(s)
self.config[0].internal__load_data_style = self.load_data_style
self.update_menus()
if self.comic_archive is not None:
self.load_archive(self.comic_archive)
else:
self.cbLoadDataStyle.itemChanged.disconnect()
self.cbLoadDataStyle.currentIndexChanged.disconnect(self.set_load_data_style)
self.adjust_load_style_combo()
self.cbLoadDataStyle.itemChanged.connect(self.set_load_data_style)
self.cbLoadDataStyle.currentIndexChanged.connect(self.set_load_data_style)
def set_save_data_style(self) -> None:
self.save_data_styles = self.cbSaveDataStyle.currentData()
@ -1380,17 +1343,14 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.set_dirty_flag()
def open_web_link(self) -> None:
row = self.leWebLink.currentRow()
if row < 0:
if self.leWebLink.count() < 1:
return
row = 0
web_link = self.leWebLink.item(row).text()
try:
utils.parse_url(web_link)
webbrowser.open_new_tab(web_link)
except utils.LocationParseError:
QtWidgets.QMessageBox.warning(self, "Web Link", "Web Link is invalid.")
if self.leWebLink is not None:
web_link = self.leWebLink.text().strip()
try:
result = urlparse(web_link)
all([result.scheme in ["http", "https"], result.netloc])
webbrowser.open_new_tab(web_link)
except ValueError:
QtWidgets.QMessageBox.warning(self, self.tr("Web Link"), self.tr("Web Link is invalid."))
def show_settings(self) -> None:
settingswin = SettingsWindow(self, self.config, self.talkers)
@ -1412,38 +1372,28 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.cbx_sources.setCurrentIndex(self.cbx_sources.findData(self.config[0].Sources__source))
def adjust_load_style_combo(self) -> None:
"""Select the enabled styles. Since metadata is merged in an overlay fashion the last item in the list takes priority. We reverse the order for display to the user"""
unchecked = set(metadata_styles.keys()) - set(self.load_data_styles)
for i, style in enumerate(reversed(self.load_data_styles)):
item_idx = self.cbLoadDataStyle.findData(style)
self.cbLoadDataStyle.setItemChecked(item_idx, True)
# Order matters, move items to list order
if item_idx != i:
self.cbLoadDataStyle.moveItem(item_idx, row=i)
for style in unchecked:
self.cbLoadDataStyle.setItemChecked(self.cbLoadDataStyle.findData(style), False)
# select the current style
self.cbLoadDataStyle.setCurrentIndex(self.cbLoadDataStyle.findData(self.load_data_style))
def adjust_save_style_combo(self) -> None:
# select the current style
unchecked = set(metadata_styles.keys()) - set(self.save_data_styles)
for style in self.save_data_styles:
self.cbSaveDataStyle.setItemChecked(self.cbSaveDataStyle.findData(style), True)
self.cbSaveDataStyle.setItemChecked(self.cbLoadDataStyle.findData(style), True)
for style in unchecked:
self.cbSaveDataStyle.setItemChecked(self.cbSaveDataStyle.findData(style), False)
self.cbSaveDataStyle.setItemChecked(self.cbLoadDataStyle.findData(style), False)
self.update_metadata_style_tweaks()
def populate_style_names(self) -> None:
# First clear all entries (called from settingswindow.py)
self.cbSaveDataStyle.clear()
self.cbLoadDataStyle.clear()
# Add the entries to the tag style combobox
for style in metadata_styles.values():
if self.config[0].Metadata_Options__use_short_metadata_names:
self.cbLoadDataStyle.addItem(style.name(), style.short_name)
if self.config[0].General__use_short_metadata_names:
self.cbSaveDataStyle.addItem(style.short_name.upper(), style.short_name)
self.cbLoadDataStyle.addItem(style.short_name.upper(), style.short_name)
else:
self.cbSaveDataStyle.addItem(style.name(), style.short_name)
self.cbLoadDataStyle.addItem(style.name(), style.short_name)
def populate_combo_boxes(self) -> None:
self.populate_style_names()
@ -1610,7 +1560,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
# Abandon any further tag removals to prevent any greater damage to archive
break
ca.reset_cache()
ca.load_cache(set(metadata_styles))
ca.load_cache(list(metadata_styles))
progdialog.hide()
QtCore.QCoreApplication.processEvents()
@ -1634,12 +1584,12 @@ class TaggerWindow(QtWidgets.QMainWindow):
ca_list = self.fileSelectionList.get_selected_archive_list()
has_src_count = 0
src_styles: list[str] = self.load_data_styles
dest_styles: list[str] = self.save_data_styles
src_style = self.load_data_style
dest_styles = self.save_data_styles
if len(src_styles) == 1 and src_styles[0] in dest_styles:
# Remove the read style from the write style
dest_styles.remove(src_styles[0])
# Remove the read style from the write style
if src_style in dest_styles:
dest_styles.remove(src_style)
if not dest_styles:
QtWidgets.QMessageBox.information(
@ -1648,16 +1598,12 @@ class TaggerWindow(QtWidgets.QMainWindow):
return
for ca in ca_list:
for style in src_styles:
if ca.has_metadata(style):
has_src_count += 1
continue
if ca.has_metadata(src_style):
has_src_count += 1
if has_src_count == 0:
QtWidgets.QMessageBox.information(
self,
"Copy Tags",
f"No archives with {', '.join([metadata_styles[style].name() for style in src_styles])} tags selected!",
self, "Copy Tags", f"No archives with {metadata_styles[src_style].name()} tags selected!"
)
return
@ -1670,9 +1616,8 @@ class TaggerWindow(QtWidgets.QMainWindow):
reply = QtWidgets.QMessageBox.question(
self,
"Copy Tags",
f"Are you sure you wish to copy the combined (with overlay order) tags of "
f"{', '.join([metadata_styles[style].name() for style in src_styles])} "
f"to {', '.join([metadata_styles[style].name() for style in dest_styles])} tags in "
f"Are you sure you wish to copy the {metadata_styles[src_style].name()} "
f"tags to {', '.join([metadata_styles[style].name() for style in dest_styles])} tags in "
f"{has_src_count} archive(s)?",
QtWidgets.QMessageBox.StandardButton.Yes,
QtWidgets.QMessageBox.StandardButton.No,
@ -1690,11 +1635,10 @@ class TaggerWindow(QtWidgets.QMainWindow):
success_count = 0
for prog_idx, ca in enumerate(ca_list, 1):
ca_saved = False
md, error = self.overlay_ca_read_style(src_styles, ca)
if error is not None:
failed_list.append(ca.path)
continue
if md.is_empty:
if ca.has_metadata(src_style) and ca.is_writable():
md = ca.read_metadata(src_style)
else:
continue
for style in dest_styles:
@ -1708,7 +1652,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
center_window_on_parent(prog_dialog)
QtCore.QCoreApplication.processEvents()
if style == "cbi" and self.config[0].Metadata_Options__cbl_apply_transform_on_bulk_operation:
if style == "cbi" and self.config[0].Comic_Book_Lover__apply_transform_on_bulk_operation:
md = CBLTransformer(md, self.config[0]).apply()
if ca.write_metadata(md, style):
@ -1719,7 +1663,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
failed_list.append(ca.path)
ca.reset_cache()
ca.load_cache({*self.load_data_styles, *self.save_data_styles})
ca.load_cache([self.load_data_style, *self.save_data_styles])
prog_dialog.hide()
QtCore.QCoreApplication.processEvents()
@ -1738,6 +1682,24 @@ class TaggerWindow(QtWidgets.QMainWindow):
dlg.setWindowTitle("Tag Copy Summary")
dlg.exec()
def actual_issue_data_fetch(self, match: IssueResult) -> GenericMetadata:
# now get the particular issue data OR series data
ct_md = GenericMetadata()
QtWidgets.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.CursorShape.WaitCursor))
try:
ct_md = self.current_talker().fetch_comic_data(match.issue_id)
except TalkerError:
logger.exception("Save aborted.")
if not ct_md.is_empty:
if self.config[0].Comic_Book_Lover__apply_transform_on_import:
ct_md = CBLTransformer(ct_md, self.config[0]).apply()
QtWidgets.QApplication.restoreOverrideCursor()
return ct_md
def auto_tag_log(self, text: str) -> None:
if self.atprogdialog is not None:
self.atprogdialog.textEdit.append(text.rstrip())
@ -1763,19 +1725,14 @@ class TaggerWindow(QtWidgets.QMainWindow):
ii = IssueIdentifier(ca, self.config[0], self.current_talker())
# read in metadata, and parse file name if not there
md, error = self.overlay_ca_read_style(self.load_data_styles, ca)
if error is not None:
QtWidgets.QMessageBox.warning(
self,
"Aborting...",
f"One or more of the read styles failed to load for {ca.path}. Aborting to prevent any possible further damage. Check log for details.",
)
logger.error("Failed to load metadata for %s: %s", self.ca.path, error)
return False, match_results
try:
md = ca.read_metadata(self.load_data_style)
except Exception as e:
md = GenericMetadata()
logger.error("Failed to load metadata for %s: %s", ca.path, e)
if md.is_empty:
md = ca.metadata_from_filename(
self.config[0].Filename_Parsing__filename_parser,
self.config[0].Filename_Parsing__complicated_parser,
self.config[0].Filename_Parsing__remove_c2c,
self.config[0].Filename_Parsing__remove_fcbd,
self.config[0].Filename_Parsing__remove_publisher,
@ -1802,8 +1759,10 @@ class TaggerWindow(QtWidgets.QMainWindow):
md.issue = "1"
else:
md.issue = utils.xlate(md.volume)
# ii.set_additional_metadata(md)
# ii.only_use_additional_meta_data = True
ii.set_output_function(self.auto_tag_log)
# ii.cover_page_index = md.get_cover_page_index_list()[0]
if self.atprogdialog is not None:
ii.set_cover_url_callback(self.atprogdialog.set_test_image)
ii.set_name_series_match_threshold(dlg.name_length_match_tolerance)
@ -1880,18 +1839,8 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.auto_tag_log("Online search: Low confidence match, but saving anyways, as indicated...\n")
# now get the particular issue data
QtWidgets.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.CursorShape.WaitCursor))
try:
ct_md = self.current_talker().fetch_comic_data(matches[0].issue_id)
except TalkerError:
logger.exception("Save aborted.")
return False, match_results
QtWidgets.QApplication.restoreOverrideCursor()
if ct_md is None or ct_md.is_empty:
ct_md = self.actual_issue_data_fetch(matches[0])
if ct_md is None:
match_results.fetch_data_failures.append(
Result(
Action.save,
@ -1903,11 +1852,17 @@ class TaggerWindow(QtWidgets.QMainWindow):
)
if ct_md is not None:
temp_opts = cast(ct_ns, settngs.get_namespace(self.config, True, True, True, False)[0])
if dlg.cbxRemoveMetadata.isChecked():
temp_opts.Issue_Identifier__clear_metadata
md = ct_md
else:
notes = (
f"Tagged with ComicTagger {ctversion.version} using info from {self.current_talker().name} on"
f" {datetime.now():%Y-%m-%d %H:%M:%S}. [Issue ID {ct_md.issue_id}]"
)
md.overlay(ct_md.replace(notes=utils.combine_notes(md.notes, notes, "Tagged with ComicTagger")))
md = prepare_metadata(md, ct_md, temp_opts)
if self.config[0].Issue_Identifier__auto_imprint:
md.fix_publisher()
res = Result(
Action.save,
@ -1929,7 +1884,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
match_results.write_failures.append(res)
ca.reset_cache()
ca.load_cache({*self.load_data_styles, *self.save_data_styles})
ca.load_cache([self.load_data_style] + self.save_data_styles)
return success, match_results
@ -1978,7 +1933,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.auto_tag_log(f"Auto-Tagging {prog_idx} of {len(ca_list)}\n")
self.auto_tag_log(f"{ca.path}\n")
try:
cover_idx = ca.read_metadata(self.load_data_styles[0]).get_cover_page_index_list()[0]
cover_idx = ca.read_metadata(self.load_data_style).get_cover_page_index_list()[0]
except Exception as e:
cover_idx = 0
logger.error("Failed to load metadata for %s: %s", ca.path, e)
@ -2049,7 +2004,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
self,
match_results.multiple_matches,
styles,
lambda match: self.current_talker().fetch_comic_data(match.issue_id),
self.actual_issue_data_fetch,
self.config[0],
self.current_talker(),
)
@ -2173,7 +2128,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
if self.dirty_flag_verification(
"File Rename", "If you rename files now, unsaved data in the form will be lost. Are you sure?"
):
dlg = RenameWindow(self, ca_list, self.load_data_styles, self.config, self.talkers)
dlg = RenameWindow(self, ca_list, self.load_data_style, self.config, self.talkers)
dlg.setModal(True)
if dlg.exec() and self.comic_archive is not None:
self.fileSelectionList.update_selected_rows()
@ -2182,7 +2137,6 @@ class TaggerWindow(QtWidgets.QMainWindow):
def load_archive(self, comic_archive: ComicArchive) -> None:
self.comic_archive = None
self.clear_form()
self.metadata = GenericMetadata()
if not os.path.exists(comic_archive.path):
self.fileSelectionList.dirty_flag = False
@ -2192,32 +2146,15 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.config[0].internal__last_opened_folder = os.path.abspath(os.path.split(comic_archive.path)[0])
self.comic_archive = comic_archive
self.metadata, error = self.overlay_ca_read_style(self.load_data_styles, self.comic_archive)
if error is not None:
logger.error("Failed to load metadata for %s: %s", self.comic_archive.path, error)
self.exception(f"Failed to load metadata for {self.comic_archive.path}, see log for details\n\n")
try:
self.metadata = self.comic_archive.read_metadata(self.load_data_style)
except Exception as e:
logger.error("Failed to load metadata for %s: %s", self.comic_archive.path, e)
self.exception(f"Failed to load metadata for {self.comic_archive.path}:\n\n{e}")
self.metadata = GenericMetadata()
self.update_ui_for_archive()
def overlay_ca_read_style(
self, load_data_styles: list[str], ca: ComicArchive
) -> tuple[GenericMetadata, Exception | None]:
md = GenericMetadata()
error = None
try:
for style in load_data_styles:
metadata = ca.read_metadata(style)
md.overlay(
metadata,
mode=self.config[0].internal__load_data_overlay,
merge_lists=self.config[0].internal__overlay_merge_lists,
)
except Exception as e:
error = e
return md, error
def file_list_cleared(self) -> None:
self.reset_app()

View File

@ -2,21 +2,10 @@
from __future__ import annotations
from enum import auto
from sys import platform
from typing import Any
from PyQt5 import QtGui, QtWidgets
from PyQt5.QtCore import QEvent, QModelIndex, QPoint, QRect, QSize, Qt, pyqtSignal
from comicapi.utils import StrEnum
from comictaggerlib.graphics import graphics_path
class ClickedButtonEnum(StrEnum):
up = auto()
down = auto()
main = auto()
from PyQt5.QtCore import QEvent, QRect, Qt, pyqtSignal
# Multiselect combobox from: https://gis.stackexchange.com/a/351152 (with custom changes)
@ -123,309 +112,3 @@ class CheckableComboBox(QtWidgets.QComboBox):
self.setItemChecked(index, False)
else:
self.setItemChecked(index, True)
# Inspiration from https://github.com/marcel-goldschen-ohm/ModelViewPyQt and https://github.com/zxt50330/qitemdelegate-example
class ReadStyleItemDelegate(QtWidgets.QStyledItemDelegate):
buttonClicked = pyqtSignal(QModelIndex, ClickedButtonEnum)
def __init__(self, parent: QtWidgets.QWidget):
super().__init__()
self.combobox = parent
self.down_icon = QtGui.QImage(str(graphics_path / "down.png"))
self.up_icon = QtGui.QImage(str(graphics_path / "up.png"))
self.button_width = self.down_icon.width()
self.button_padding = 5
# Tooltip messages
self.item_help: str = ""
self.up_help: str = ""
self.down_help: str = ""
# Connect the signal to a slot in the delegate
self.combobox.itemClicked.connect(self.itemClicked)
def paint(self, painter: QtGui.QPainter, option: QtWidgets.QStyleOptionViewItem, index: QModelIndex) -> None:
options = QtWidgets.QStyleOptionViewItem(option)
self.initStyleOption(options, index)
style = self.combobox.style()
# Draw background with the same color as other widgets
palette = self.combobox.palette()
background_color = palette.color(QtGui.QPalette.Window)
painter.fillRect(options.rect, background_color)
style.drawPrimitive(QtWidgets.QStyle.PE_PanelItemViewItem, options, painter, self.combobox)
painter.save()
# Checkbox drawing logic
checked = index.data(Qt.CheckStateRole)
opts = QtWidgets.QStyleOptionButton()
opts.state |= QtWidgets.QStyle.State_Active
opts.rect = self.getCheckBoxRect(options)
opts.state |= QtWidgets.QStyle.State_ReadOnly
if checked:
opts.state |= QtWidgets.QStyle.State_On
style.drawPrimitive(
QtWidgets.QStyle.PrimitiveElement.PE_IndicatorMenuCheckMark, opts, painter, self.combobox
)
else:
opts.state |= QtWidgets.QStyle.State_Off
if platform != "darwin":
style.drawControl(QtWidgets.QStyle.CE_CheckBox, opts, painter, self.combobox)
label = index.data(Qt.DisplayRole)
rectangle = options.rect
rectangle.setX(opts.rect.width() + 10)
painter.drawText(rectangle, Qt.AlignVCenter, label)
# Draw buttons
if checked and (options.state & QtWidgets.QStyle.State_Selected):
up_rect = self._button_up_rect(options.rect)
down_rect = self._button_down_rect(options.rect)
painter.drawImage(up_rect, self.up_icon)
painter.drawImage(down_rect, self.down_icon)
painter.restore()
def _button_up_rect(self, rect: QRect) -> QRect:
return QRect(
self.combobox.view().width() - (self.button_width * 2) - (self.button_padding * 2),
rect.top() + (rect.height() - self.button_width) // 2,
self.button_width,
self.button_width,
)
def _button_down_rect(self, rect: QRect = QRect(10, 1, 12, 12)) -> QRect:
return QRect(
self.combobox.view().width() - self.button_padding - self.button_width,
rect.top() + (rect.height() - self.button_width) // 2,
self.button_width,
self.button_width,
)
def getCheckBoxRect(self, option: QtWidgets.QStyleOptionViewItem) -> QRect:
# Get size of a standard checkbox.
opts = QtWidgets.QStyleOptionButton()
style = option.widget.style()
checkBoxRect = style.subElementRect(QtWidgets.QStyle.SE_CheckBoxIndicator, opts, None)
y = option.rect.y()
h = option.rect.height()
checkBoxTopLeftCorner = QPoint(5, int(y + h / 2 - checkBoxRect.height() / 2))
return QRect(checkBoxTopLeftCorner, checkBoxRect.size())
def itemClicked(self, index: QModelIndex, pos: QPoint) -> None:
item_rect = self.combobox.view().visualRect(index)
checked = index.data(Qt.CheckStateRole)
button_up_rect = self._button_up_rect(item_rect)
button_down_rect = self._button_down_rect(item_rect)
if checked and button_up_rect.contains(pos):
self.buttonClicked.emit(index, ClickedButtonEnum.up)
elif checked and button_down_rect.contains(pos):
self.buttonClicked.emit(index, ClickedButtonEnum.down)
else:
self.buttonClicked.emit(index, ClickedButtonEnum.main)
def setToolTip(self, item: str = "", up: str = "", down: str = "") -> None:
if item:
self.item_help = item
if up:
self.up_help = up
if down:
self.down_help = down
def helpEvent(
self,
event: QtGui.QHelpEvent,
view: QtWidgets.QAbstractItemView,
option: QtWidgets.QStyleOptionViewItem,
index: QModelIndex,
) -> bool:
item_rect = view.visualRect(index)
button_up_rect = self._button_up_rect(item_rect)
button_down_rect = self._button_down_rect(item_rect)
checked = index.data(Qt.CheckStateRole)
if checked == Qt.Checked and button_up_rect.contains(event.pos()):
QtWidgets.QToolTip.showText(event.globalPos(), self.up_help, self.combobox, QRect(), 3000)
elif checked == Qt.Checked and button_down_rect.contains(event.pos()):
QtWidgets.QToolTip.showText(event.globalPos(), self.down_help, self.combobox, QRect(), 3000)
else:
QtWidgets.QToolTip.showText(event.globalPos(), self.item_help, self.combobox, QRect(), 3000)
return True
def sizeHint(self, option: QtWidgets.QStyleOptionViewItem, index: QModelIndex) -> QSize:
# Reimpliment standard combobox sizeHint. Only height is used by view, width is ignored
menu_option = QtWidgets.QStyleOptionMenuItem()
return self.combobox.style().sizeFromContents(
QtWidgets.QStyle.ContentsType.CT_MenuItem, menu_option, option.rect.size(), self.combobox
)
# Multiselect combobox from: https://gis.stackexchange.com/a/351152 (with custom changes)
class CheckableOrderComboBox(QtWidgets.QComboBox):
itemClicked = pyqtSignal(QModelIndex, QPoint)
dropdownClosed = pyqtSignal(list)
def __init__(self, *args: Any, **kwargs: Any):
super().__init__(*args, **kwargs)
itemDelegate = ReadStyleItemDelegate(self)
itemDelegate.setToolTip(
"Select which read style(s) to use", "Move item up in priority", "Move item down in priority"
)
self.setItemDelegate(itemDelegate)
# Prevent popup from closing when clicking on an item
self.view().viewport().installEventFilter(self)
# Go on a bit of a merry-go-round with the signals to avoid custom model/view
self.itemDelegate().buttonClicked.connect(self.buttonClicked)
# Keeps track of when the combobox list is shown
self.justShown = False
def buttonClicked(self, index: QModelIndex, button: ClickedButtonEnum) -> None:
if button == ClickedButtonEnum.up:
self.moveItem(index.row(), up=True)
elif button == ClickedButtonEnum.down:
self.moveItem(index.row(), up=False)
else:
self.toggleItem(index.row())
def resizeEvent(self, event: Any) -> None:
# Recompute text to elide as needed
super().resizeEvent(event)
self._updateText()
def eventFilter(self, obj: Any, event: Any) -> bool:
# Allow events before the combobox list is shown
if obj == self.view().viewport():
# We record that the combobox list has been shown
if event.type() == QEvent.Show:
self.justShown = True
# We record that the combobox list has hidden,
# this will happen if the user does not make a selection
# but clicks outside of the combobox list or presses escape
if event.type() == QEvent.Hide:
self._updateText()
self.justShown = False
# Reverse as the display order is in "priority" order for the user whereas overlay requires reversed
self.dropdownClosed.emit(self.currentData())
# QEvent.MouseButtonPress is inconsistent on activation because double clicks are a thing
if event.type() == QEvent.MouseButtonRelease:
# If self.justShown is true it means that they clicked on the combobox to change the checked items
# This is standard behavior (on macos) but I think it is surprising when it has a multiple select
if self.justShown:
self.justShown = False
return True
# Find the current index and item
index = self.view().indexAt(event.pos())
if index.isValid():
self.itemClicked.emit(index, event.pos())
return True
return False
def currentData(self) -> list[Any]:
# Return the list of all checked items data
res = []
for i in range(self.count()):
item = self.model().item(i)
if item.checkState() == Qt.Checked:
res.append(self.itemData(i))
return res
def addItem(self, text: str, data: Any = None) -> None:
super().addItem(text, data)
# Need to enable the checkboxes and require one checked item
# Expected that state of *all* checkboxes will be set ('adjust_save_style_combo' in taggerwindow.py)
if self.count() == 1:
self.model().item(0).setCheckState(Qt.CheckState.Checked)
# Add room for "move" arrows
text_width = self.fontMetrics().width(text)
checkbox_width = 40
total_width = text_width + checkbox_width + (self.itemDelegate().button_width * 2)
if total_width > self.view().minimumWidth():
self.view().setMinimumWidth(total_width)
def moveItem(self, index: int, up: bool = False, row: int | None = None) -> None:
"""'Move' an item. Really swap the data and titles around on the two items"""
if row is None:
adjust = -1 if up else 1
row = index + adjust
# TODO Disable buttons at top and bottom. Do a check here for now
if up and index == 0:
return
if up is False and row == self.count():
return
# Grab values for the rows to swap
cur_data = self.model().item(index).data(Qt.UserRole)
cur_title = self.model().item(index).data(Qt.DisplayRole)
cur_state = self.model().item(index).data(Qt.CheckStateRole)
swap_data = self.model().item(row).data(Qt.UserRole)
swap_title = self.model().item(row).data(Qt.DisplayRole)
swap_state = self.model().item(row).checkState()
self.model().item(row).setData(cur_data, Qt.UserRole)
self.model().item(row).setCheckState(cur_state)
self.model().item(row).setText(cur_title)
self.model().item(index).setData(swap_data, Qt.UserRole)
self.model().item(index).setCheckState(swap_state)
self.model().item(index).setText(swap_title)
def _updateText(self) -> None:
texts = []
for i in range(self.count()):
item = self.model().item(i)
if item.checkState() == Qt.Checked:
texts.append(item.text())
text = ", ".join(texts)
# Compute elided text (with "...")
# The QStyleOptionComboBox is needed for the call to subControlRect
so = QtWidgets.QStyleOptionComboBox()
# init with the current widget
so.initFrom(self)
# Ask the style for the size of the text field
rect = self.style().subControlRect(QtWidgets.QStyle.CC_ComboBox, so, QtWidgets.QStyle.SC_ComboBoxEditField)
# Compute the elided text
elidedText = self.fontMetrics().elidedText(text, Qt.ElideRight, rect.width())
# This CheckableComboBox does not use the index, so we clear it and set the placeholder text
self.setCurrentIndex(-1)
self.setPlaceholderText(elidedText)
def setItemChecked(self, index: Any, state: bool) -> None:
qt_state = Qt.Checked if state else Qt.Unchecked
item = self.model().item(index)
current = self.currentData()
# If we have at least one item checked emit itemChecked with the current check state and update text
# Require at least one item to be checked and provide a tooltip
if len(current) == 1 and not state and item.checkState() == Qt.Checked:
QtWidgets.QToolTip.showText(QtGui.QCursor.pos(), self.toolTip(), self, QRect(), 3000)
return
if len(current) > 0:
item.setCheckState(qt_state)
self._updateText()
def toggleItem(self, index: int) -> None:
if self.model().item(index).checkState() == Qt.Checked:
self.setItemChecked(index, False)
else:
self.setItemChecked(index, True)

View File

@ -28,9 +28,6 @@
<height>16777215</height>
</size>
</property>
<property name="horizontalScrollBarPolicy">
<enum>Qt::ScrollBarAlwaysOff</enum>
</property>
<property name="dragEnabled">
<bool>true</bool>
</property>
@ -91,10 +88,7 @@
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<layout class="QGridLayout" name="gridLayout_1">
<item row="1" column="1">
<widget class="QComboBox" name="cbPageType"/>
</item>
<item row="1" column="0">
<item row="0" column="0">
<widget class="QLabel" name="lblPageType">
<property name="text">
<string>Page Type:</string>
@ -104,20 +98,16 @@
</property>
</widget>
</item>
<item row="1" column="2">
<item row="0" column="1">
<widget class="QComboBox" name="cbPageType"/>
</item>
<item row="0" column="2">
<widget class="QCheckBox" name="chkDoublePage">
<property name="text">
<string>&amp;Double Page?</string>
</property>
</widget>
</item>
<item row="0" column="0" colspan="3">
<widget class="QPushButton" name="btnIdentifyScannerPage">
<property name="text">
<string>Identify Scanner page</string>
</property>
</widget>
</item>
</layout>
</item>
<item>

View File

@ -6,7 +6,6 @@ import io
import logging
import traceback
import webbrowser
from collections.abc import Sequence
from PyQt5.QtCore import QUrl
from PyQt5.QtWidgets import QWidget
@ -156,7 +155,7 @@ if qt_available:
active_palette = None
def enable_widget(widget: QtWidgets.QWidget | list[QtWidgets.QWidget], enable: bool) -> None:
if isinstance(widget, Sequence):
if isinstance(widget, list):
for w in widget:
_enable_widget(w, enable)
else:
@ -215,8 +214,6 @@ if qt_available:
widget.setReadOnly(True)
widget.setPalette(inactive_palette[0])
elif isinstance(widget, QtWidgets.QListWidget):
inactive_palette = palettes()
widget.setPalette(inactive_palette[0])
widget.setMovement(QtWidgets.QListWidget.Static)
def replaceWidget(

View File

@ -7,7 +7,7 @@
<x>0</x>
<y>0</y>
<width>703</width>
<height>597</height>
<height>574</height>
</rect>
</property>
<property name="windowTitle">
@ -28,7 +28,7 @@
</sizepolicy>
</property>
<property name="currentIndex">
<number>4</number>
<number>0</number>
</property>
<widget class="QWidget" name="tGeneral">
<attribute name="title">
@ -48,7 +48,43 @@
</property>
</widget>
</item>
<item row="2" column="1">
<item row="1" column="0">
<widget class="QCheckBox" name="cbxShortMetadataNames">
<property name="toolTip">
<string>Use the short name for the metadata styles (CBI, CR, etc.)</string>
</property>
<property name="text">
<string>Use &quot;short&quot; names for metadata styles</string>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QPushButton" name="btnResetSettings">
<property name="sizePolicy">
<sizepolicy hsizetype="Maximum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Default Settings</string>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QPushButton" name="btnClearCache">
<property name="sizePolicy">
<sizepolicy hsizetype="Maximum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Clear Cache</string>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QLabel" name="lblDefaultSettings">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
@ -62,12 +98,9 @@
<property name="wordWrap">
<bool>true</bool>
</property>
<property name="buddy">
<cstring>btnResetSettings</cstring>
</property>
</widget>
</item>
<item row="3" column="1">
<item row="4" column="1">
<widget class="QLabel" name="label_2">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
@ -81,41 +114,12 @@
<property name="wordWrap">
<bool>true</bool>
</property>
<property name="buddy">
<cstring>btnClearCache</cstring>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QCheckBox" name="cbxPromptOnSave">
<property name="text">
<string>Prompts the user to confirm saving tags</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QPushButton" name="btnResetSettings">
<property name="sizePolicy">
<sizepolicy hsizetype="Maximum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<widget class="QCheckBox" name="cbxPromptOnSave">
<property name="text">
<string>Default Settings</string>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QPushButton" name="btnClearCache">
<property name="sizePolicy">
<sizepolicy hsizetype="Maximum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Clear Cache</string>
<string>Prompts the user to confirm saving tags</string>
</property>
</widget>
</item>
@ -169,9 +173,6 @@
<property name="text">
<string>Default Name Match Ratio Threshold: Search:</string>
</property>
<property name="buddy">
<cstring>sbNameMatchSearchThresh</cstring>
</property>
</widget>
</item>
<item row="1" column="0">
@ -182,9 +183,6 @@
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
<property name="buddy">
<cstring>sbNameMatchIdentifyThresh</cstring>
</property>
</widget>
</item>
<item row="2" column="0">
@ -192,9 +190,6 @@
<property name="text">
<string>Always use Publisher Filter on &quot;manual&quot; searches:</string>
</property>
<property name="buddy">
<cstring>cbxUseFilter</cstring>
</property>
</widget>
</item>
<item row="2" column="1">
@ -212,9 +207,6 @@
<property name="text">
<string>Publisher Filter:</string>
</property>
<property name="buddy">
<cstring>tePublisherFilter</cstring>
</property>
</widget>
</item>
<item row="3" column="1">
@ -316,19 +308,9 @@
<widget class="QGroupBox" name="groupBox_2">
<layout class="QVBoxLayout" name="verticalLayout_7">
<item>
<widget class="QLabel" name="lblFilenamearser">
<widget class="QCheckBox" name="cbxComplicatedParser">
<property name="text">
<string>Select the filename parser</string>
</property>
<property name="buddy">
<cstring>cbFilenameParser</cstring>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="cbFilenameParser">
<property name="insertPolicy">
<enum>QComboBox::NoInsert</enum>
<string>Use &quot;Complicated&quot; Parser</string>
</property>
</widget>
</item>
@ -417,124 +399,19 @@
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_4"/>
</widget>
<widget class="QWidget" name="tMDOptions">
<widget class="QWidget" name="tMDType">
<attribute name="title">
<string>Metadata Options</string>
<string>Metadata Types</string>
</attribute>
<layout class="QGridLayout" name="gridLayout_6">
<item row="0" column="0" rowspan="4">
<widget class="QGroupBox" name="groupBox_5">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
<item row="1" column="0">
<widget class="QCheckBox" name="cbxApplyCBLTransformOnBatchOperation">
<property name="text">
<string>Apply CBL Transforms on Batch Copy Operations to CBL Tags</string>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>0</height>
</size>
</property>
<property name="title">
<string>General</string>
</property>
<layout class="QGridLayout" name="gridLayout_7">
<property name="leftMargin">
<number>6</number>
</property>
<property name="topMargin">
<number>6</number>
</property>
<property name="rightMargin">
<number>6</number>
</property>
<property name="bottomMargin">
<number>6</number>
</property>
<property name="spacing">
<number>6</number>
</property>
<item row="0" column="0">
<widget class="QCheckBox" name="cbxShortMetadataNames">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="toolTip">
<string>Use the short name for the metadata styles (CBI, CR, etc.)</string>
</property>
<property name="text">
<string>Use &quot;short&quot; names for metadata styles</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QCheckBox" name="cbxDisableCR">
<property name="toolTip">
<string>Useful if you use the CIX metadata type</string>
</property>
<property name="text">
<string>Disable ComicRack Metadata Type</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item row="5" column="0" rowspan="2">
<widget class="QGroupBox" name="groupBox_4">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>80</height>
</size>
</property>
<property name="title">
<string>CBL Options</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_11">
<property name="spacing">
<number>6</number>
</property>
<property name="leftMargin">
<number>6</number>
</property>
<property name="topMargin">
<number>6</number>
</property>
<property name="rightMargin">
<number>6</number>
</property>
<property name="bottomMargin">
<number>6</number>
</property>
<item>
<widget class="QCheckBox" name="cbxApplyCBLTransformOnCVIMport">
<property name="text">
<string>Apply CBL Transforms on ComicVine Import</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="cbxApplyCBLTransformOnBatchOperation">
<property name="text">
<string>Apply CBL Transforms on Batch Copy Operations to CBL Tags</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item row="7" column="0">
<item row="4" column="0">
<widget class="QGroupBox" name="groupBox">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="MinimumExpanding">
@ -545,359 +422,99 @@
<property name="title">
<string>CBL Transforms</string>
</property>
<layout class="QGridLayout" name="gridLayout_9">
<property name="leftMargin">
<number>6</number>
<widget class="QWidget" name="layoutWidget">
<property name="geometry">
<rect>
<x>11</x>
<y>21</y>
<width>251</width>
<height>206</height>
</rect>
</property>
<property name="topMargin">
<number>6</number>
</property>
<property name="rightMargin">
<number>6</number>
</property>
<property name="bottomMargin">
<number>6</number>
</property>
<property name="spacing">
<number>6</number>
</property>
<item row="1" column="1">
<widget class="QCheckBox" name="cbxCopyNotesToComments">
<property name="text">
<string>Copy Notes to Comments</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QCheckBox" name="cbxCopyWebLinkToComments">
<property name="text">
<string>Copy Web Link to Comments</string>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QCheckBox" name="cbxCopyLocationsToTags">
<property name="text">
<string>Copy Locations to Generic Tags</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QCheckBox" name="cbxAssumeLoneCreditIsPrimary">
<property name="text">
<string>Assume Lone Credit Is Primary</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QCheckBox" name="cbxCopyStoryArcsToTags">
<property name="text">
<string>Copy Story Arcs to Generic Tags</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QCheckBox" name="cbxCopyCharactersToTags">
<property name="text">
<string>Copy Characters to Generic Tags</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QCheckBox" name="cbxCopyTeamsToTags">
<property name="text">
<string>Copy Teams to Generic Tags</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item row="4" column="0">
<widget class="QGroupBox" name="groupBox_6">
<property name="minimumSize">
<size>
<width>0</width>
<height>165</height>
</size>
</property>
<property name="title">
<string>Overlay</string>
</property>
<layout class="QGridLayout" name="gridLayout_10">
<property name="leftMargin">
<number>6</number>
</property>
<property name="topMargin">
<number>6</number>
</property>
<property name="rightMargin">
<number>6</number>
</property>
<property name="bottomMargin">
<number>6</number>
</property>
<property name="spacing">
<number>6</number>
</property>
<item row="1" column="0">
<widget class="QLabel" name="lblOverlaySource">
<property name="toolTip">
<string>The operation to perform when overlaying source data (Comic Vine, Metron, GCD, etc.)</string>
</property>
<property name="text">
<string>Data Source</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
<property name="margin">
<number>6</number>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="lblOverlayReadStyle">
<property name="toolTip">
<string>The operation to perform when overlaying read styles (ComicRack, ComicBookInfo, etc.)</string>
</property>
<property name="text">
<string>Read Style</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
<property name="margin">
<number>6</number>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QComboBox" name="cbxOverlaySource">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>16777215</height>
</size>
</property>
<property name="toolTip">
<string>The operation to perform when overlaying source data (Comic Vine, Metron, GCD, etc.)</string>
</property>
</widget>
</item>
<item row="0" column="2" rowspan="3">
<widget class="QTabWidget" name="tabWidgetOverlay">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Maximum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>200</height>
</size>
</property>
<property name="tabPosition">
<enum>QTabWidget::North</enum>
</property>
<property name="currentIndex">
<number>0</number>
</property>
<property name="documentMode">
<bool>true</bool>
</property>
<property name="tabBarAutoHide">
<bool>false</bool>
</property>
<widget class="QWidget" name="tOverlay">
<property name="maximumSize">
<size>
<width>16777215</width>
<height>16777215</height>
</size>
<layout class="QGridLayout" name="gridLayout_7">
<item row="3" column="0">
<widget class="QCheckBox" name="cbxCopyLocationsToTags">
<property name="text">
<string>Copy Locations to Generic Tags</string>
</property>
<attribute name="title">
<string>Overlay</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_5">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QPlainTextEdit" name="overlayTextEdit">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>1</horstretch>
<verstretch>1</verstretch>
</sizepolicy>
</property>
<property name="undoRedoEnabled">
<bool>false</bool>
</property>
<property name="plainText">
<string>Overlays all the (non-list) non-empty values of the new metadata (e.g. Comic Vine) on top of the current metadata.
Example:
(Series=batman, Issue=1, Tags=[batman,joker,robin])
+ (Series=Batman, Publisher=DC Comics, Tags=[mystery,action])
= (Series=Batman, Issue=1, Publisher=DC Comics, Tags=[mystery,action])</string>
</property>
<property name="textInteractionFlags">
<set>Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse</set>
</property>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="tAdd">
<attribute name="title">
<string>Add Missing</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_9">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QPlainTextEdit" name="addTextEdit">
<property name="plainText">
<string>Adds any (non-list) metadata that is present in the new metadata (e.g. Comic Vine) but is missing in the current metadata.
Example:
(Series=batman, Issue=1)
+ (Series=Superman, Issue=10, Publisher=DC Comics)
= (Series=batman, Issue=1, Publisher=DC Comics)</string>
</property>
<property name="textInteractionFlags">
<set>Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse</set>
</property>
</widget>
</item>
</layout>
</item>
<item row="0" column="0">
<widget class="QCheckBox" name="cbxAssumeLoneCreditIsPrimary">
<property name="text">
<string>Assume Lone Credit Is Primary</string>
</property>
</widget>
<widget class="QWidget" name="tCombine">
<attribute name="title">
<string>Lists</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_10">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QPlainTextEdit" name="combineTextEdit">
<property name="plainText">
<string>All lists (tags, characters, genres, credits, etc.) are merged or replaced via the &quot;Merge Lists&quot; check box. Merge will replace duplicates within the list with the &quot;new&quot; data.
Example Merge:
(Tags=[batman,joker,robin])
+ (Tags=[mystery,action])
= (Tags=[batman,joker,robin,mystery,action])
Example Replace:
(Tags=[batman,joker,robin])
+ (Tags=[mystery,action])
= (Tags=[mystery,action])</string>
</property>
<property name="textInteractionFlags">
<set>Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse</set>
</property>
</widget>
</item>
</layout>
</item>
<item row="1" column="0">
<widget class="QCheckBox" name="cbxCopyCharactersToTags">
<property name="text">
<string>Copy Characters to Generic Tags</string>
</property>
</widget>
</widget>
</item>
<item row="0" column="1">
<widget class="QComboBox" name="cbxOverlayReadStyle">
<property name="enabled">
<bool>true</bool>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>16777215</height>
</size>
</property>
<property name="toolTip">
<string>The operation to perform when overlaying read styles (ComicRack, ComicBookInfo, etc.)</string>
</property>
</widget>
</item>
<item row="2" column="0" colspan="2">
<widget class="QCheckBox" name="cbxOverlayMergeLists">
<property name="toolTip">
<string>Merge lists (characters, tags, locations, etc.) together or the &quot;new&quot; list replaces the old</string>
</property>
<property name="text">
<string>Merge Lists</string>
</property>
</widget>
</item>
</layout>
</item>
<item row="2" column="0">
<widget class="QCheckBox" name="cbxCopyTeamsToTags">
<property name="text">
<string>Copy Teams to Generic Tags</string>
</property>
</widget>
</item>
<item row="5" column="0">
<widget class="QCheckBox" name="cbxCopyNotesToComments">
<property name="text">
<string>Copy Notes to Comments</string>
</property>
</widget>
</item>
<item row="6" column="0">
<widget class="QCheckBox" name="cbxCopyWebLinkToComments">
<property name="text">
<string>Copy Web Link to Comments</string>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QCheckBox" name="cbxCopyStoryArcsToTags">
<property name="text">
<string>Copy Story Arcs to Generic Tags</string>
</property>
</widget>
</item>
</layout>
</widget>
</widget>
</item>
<item row="8" column="0">
<item row="3" column="0">
<spacer name="verticalSpacer_2">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Fixed</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
<height>10</height>
</size>
</property>
</spacer>
</item>
<item row="0" column="0">
<widget class="QCheckBox" name="cbxApplyCBLTransformOnCVIMport">
<property name="text">
<string>Apply CBL Transforms on ComicVine Import</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QCheckBox" name="cbxDisableCR">
<property name="text">
<string>Disable ComicRack Metadata Type (useful if you use the CIX metadata type)</string>
</property>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="tRename">
@ -929,9 +546,6 @@ Example Replace:
<property name="text">
<string>Template:</string>
</property>
<property name="buddy">
<cstring>leRenameTemplate</cstring>
</property>
</widget>
</item>
<item row="1" column="1">
@ -962,9 +576,6 @@ Example Replace:
<property name="text">
<string>Issue # Zero Padding</string>
</property>
<property name="buddy">
<cstring>leIssueNumPadding</cstring>
</property>
</widget>
</item>
<item row="3" column="1">
@ -1013,20 +624,17 @@ Example Replace:
</property>
</widget>
</item>
<item row="11" column="0">
<item row="9" column="0">
<widget class="QLabel" name="lblDirectory">
<property name="text">
<string>Destination Directory:</string>
</property>
<property name="buddy">
<cstring>leDirectory</cstring>
</property>
</widget>
</item>
<item row="11" column="1">
<item row="9" column="1">
<widget class="QLineEdit" name="leDirectory"/>
</item>
<item row="9" column="0">
<item row="8" column="0">
<widget class="QCheckBox" name="cbxRenameStrict">
<property name="toolTip">
<string>If checked will ensure reserved characters and filenames are removed for all Operating Systems.&lt;br/&gt;By default only removes restricted characters and filenames for the current Operating System.</string>
@ -1036,16 +644,9 @@ Example Replace:
</property>
</widget>
</item>
<item row="12" column="1">
<item row="10" column="1">
<widget class="QLabel" name="lblDir"/>
</item>
<item row="8" column="0">
<widget class="QCheckBox" name="cbxMoveOnly">
<property name="text">
<string>Only Move files when renaming</string>
</property>
</widget>
</item>
</layout>
</item>
<item row="3" column="0">
@ -1133,9 +734,6 @@ Example Replace:
<property name="text">
<string>Value Text Replacements</string>
</property>
<property name="buddy">
<cstring>twValueReplacements</cstring>
</property>
</widget>
</item>
<item row="1" column="0" colspan="2">
@ -1143,9 +741,6 @@ Example Replace:
<property name="text">
<string>Literal Text Replacements</string>
</property>
<property name="buddy">
<cstring>twLiteralReplacements</cstring>
</property>
</widget>
</item>
</layout>
@ -1181,9 +776,6 @@ Example Replace:
<property name="text">
<string>RAR program</string>
</property>
<property name="buddy">
<cstring>leRarExePath</cstring>
</property>
</widget>
</item>
<item row="1" column="1">

View File

@ -7,7 +7,7 @@
<x>0</x>
<y>0</y>
<width>1096</width>
<height>660</height>
<height>658</height>
</rect>
</property>
<property name="sizePolicy">
@ -76,11 +76,7 @@
<widget class="QComboBox" name="cbx_sources"/>
</item>
<item row="1" column="1">
<widget class="CheckableOrderComboBox" name="cbLoadDataStyle">
<property name="toolTip">
<string>At least one read style must be selected</string>
</property>
</widget>
<widget class="QComboBox" name="cbLoadDataStyle"/>
</item>
<item row="2" column="1">
<widget class="CheckableComboBox" name="cbSaveDataStyle"/>
@ -962,32 +958,10 @@
</item>
<item row="2" column="1">
<layout class="QGridLayout" name="gridLayout_7">
<item row="0" column="0" rowspan="3">
<widget class="QListWidget" name="leWebLink">
<property name="contextMenuPolicy">
<enum>Qt::ActionsContextMenu</enum>
</property>
<property name="dragEnabled">
<bool>true</bool>
</property>
<property name="dragDropMode">
<enum>QAbstractItemView::DropOnly</enum>
</property>
<property name="defaultDropAction">
<enum>Qt::MoveAction</enum>
</property>
<property name="alternatingRowColors">
<bool>true</bool>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::SingleSelection</enum>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QPushButton" name="btnRemoveWebLink">
<property name="text">
<string>Delete Item</string>
<item row="0" column="0">
<widget class="QLineEdit" name="leWebLink">
<property name="acceptDrops">
<bool>false</bool>
</property>
</widget>
</item>
@ -1007,13 +981,6 @@
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QPushButton" name="btnAddWebLink">
<property name="text">
<string>Add Item</string>
</property>
</widget>
</item>
</layout>
</item>
<item row="4" column="0">
@ -1220,7 +1187,7 @@
<x>0</x>
<y>0</y>
<width>1096</width>
<height>30</height>
<height>28</height>
</rect>
</property>
<widget class="QMenu" name="menuComicTagger">
@ -1510,16 +1477,6 @@
<string>Open Folder as Comic</string>
</property>
</action>
<action name="actionAddWebLink">
<property name="text">
<string>Add Item</string>
</property>
</action>
<action name="actionRemoveWebLink">
<property name="text">
<string>Remove Web Link</string>
</property>
</action>
</widget>
<layoutdefault spacing="6" margin="11"/>
<customwidgets>
@ -1528,45 +1485,7 @@
<extends>QComboBox</extends>
<header>comictaggerlib.ui.customwidgets</header>
</customwidget>
<customwidget>
<class>CheckableOrderComboBox</class>
<extends>QComboBox</extends>
<header>comictaggerlib.ui.customwidgets</header>
</customwidget>
</customwidgets>
<resources/>
<connections>
<connection>
<sender>btnAddWebLink</sender>
<signal>clicked()</signal>
<receiver>actionAddWebLink</receiver>
<slot>trigger()</slot>
<hints>
<hint type="sourcelabel">
<x>900</x>
<y>536</y>
</hint>
<hint type="destinationlabel">
<x>-1</x>
<y>-1</y>
</hint>
</hints>
</connection>
<connection>
<sender>btnRemoveWebLink</sender>
<signal>clicked()</signal>
<receiver>actionRemoveWebLink</receiver>
<slot>trigger()</slot>
<hints>
<hint type="sourcelabel">
<x>900</x>
<y>576</y>
</hint>
<hint type="destinationlabel">
<x>-1</x>
<y>-1</y>
</hint>
</hints>
</connection>
</connections>
<connections/>
</ui>

View File

@ -37,8 +37,6 @@ def cleanup_html(string: str | None, remove_html_tables: bool = False) -> str:
"""Cleans HTML code from any text. Will remove any HTML tables with remove_html_tables"""
if string is None:
return ""
if "<" not in string:
return string
from bs4 import BeautifulSoup
# find any tables

View File

@ -33,7 +33,6 @@ from typing_extensions import Required, TypedDict
from comicapi import utils
from comicapi.genericmetadata import ComicSeries, GenericMetadata, TagOrigin
from comicapi.issuestring import IssueString
from comicapi.utils import LocationParseError, parse_url
from comictalker import talker_utils
from comictalker.comiccacher import ComicCacher, Issue, Series
from comictalker.comictalker import ComicTalker, TalkerDataError, TalkerNetworkError
@ -644,15 +643,10 @@ class ComicVineTalker(ComicTalker):
format=utils.xlate(series.format),
volume_count=utils.xlate_int(series.count_of_volumes),
title=utils.xlate(issue.get("name")),
web_link=utils.xlate(issue.get("site_detail_url")),
series=utils.xlate(series.name),
series_aliases=series.aliases,
)
url = utils.xlate(issue.get("site_detail_url"))
if url:
try:
md.web_links = [parse_url(url)]
except LocationParseError:
...
if issue.get("image") is None:
md._cover_image = ""
else:

View File

@ -37,7 +37,6 @@ install_requires =
appdirs==1.4.4
beautifulsoup4>=4.1
chardet>=5.1.0,<6
comicfn2dict>=0.2.1
importlib-metadata>=3.3.0
isocodes>=2023.11.26
natsort>=8.1.0
@ -45,10 +44,9 @@ install_requires =
pathvalidate
pillow>=9.1.0,<10
pyrate-limiter>=2.6,<3
pyyaml
rapidfuzz>=2.12.0
requests==2.*
settngs==0.10.2
settngs==0.10.0
text2digits
typing-extensions>=4.3.0
wordninja
@ -88,7 +86,7 @@ QTW =
all =
PyQt5
PyQtWebEngine
comicinfoxml>=0.2.0
comicinfoxml
gcd-talker>=0.1.0
metron-talker>=0.1.5
pillow-avif-plugin>=1.4.1
@ -98,7 +96,7 @@ all =
avif =
pillow-avif-plugin>=1.4.1
cix =
comicinfoxml>=0.2.0
comicinfoxml
gcd =
gcd-talker>=0.1.0
metron =
@ -304,7 +302,7 @@ deps =
[flake8]
max-line-length = 120
extend-ignore = E203, E501, A003, A005, T202, E701
extend-ignore = E203, E501, A003, T202, E701
extend-exclude = venv, scripts, build, dist, comictaggerlib/ctversion.py
per-file-ignores =
comictaggerlib/cli.py: T20
@ -318,7 +316,6 @@ disallow_incomplete_defs = true
disallow_untyped_defs = true
warn_redundant_casts = true
warn_unused_ignores = true
disable_error_code = import-untyped
[mypy-testing.*]
disallow_untyped_defs = false

View File

@ -55,142 +55,25 @@ select_details = [
# Used to test GenericMetadata.overlay
metadata = [
(
comicapi.genericmetadata.md_test.copy(),
comicapi.genericmetadata.GenericMetadata(series="test", issue="2", title="never"),
comicapi.genericmetadata.md_test.replace(series="test", issue="2", title="never"),
),
(
comicapi.genericmetadata.md_test.copy(),
comicapi.genericmetadata.GenericMetadata(series="", issue="2", title="never"),
comicapi.genericmetadata.md_test.replace(series=None, issue="2", title="never"),
),
(
comicapi.genericmetadata.GenericMetadata(series="", issue="", title="never"),
comicapi.genericmetadata.md_test.replace(series=None, issue=None, title="never"),
),
(
comicapi.genericmetadata.GenericMetadata(series="", issue=None, title="never"),
comicapi.genericmetadata.md_test.replace(series=None, issue="1", title="never"),
),
(
comicapi.genericmetadata.GenericMetadata(),
comicapi.genericmetadata.md_test.copy(),
),
(
comicapi.genericmetadata.GenericMetadata(
credits=[
comicapi.genericmetadata.Credit(person="test", role="writer", primary=False),
comicapi.genericmetadata.Credit(person="test", role="artist", primary=True),
],
),
comicapi.genericmetadata.GenericMetadata(
credits=[
comicapi.genericmetadata.Credit(person="", role="writer", primary=False),
comicapi.genericmetadata.Credit(person="test2", role="inker", primary=False),
]
),
comicapi.genericmetadata.GenericMetadata(
credits=[
comicapi.genericmetadata.Credit(person="test", role="writer", primary=False),
comicapi.genericmetadata.Credit(person="test", role="artist", primary=True),
comicapi.genericmetadata.Credit(person="test2", role="inker", primary=False),
]
),
),
]
metadata_add = [
(
comicapi.genericmetadata.GenericMetadata(
series="test",
title="test",
issue="1",
volume_count=1,
page_count=3,
day=3,
genres={"test", "test2"},
story_arcs=["arc"],
characters=set(),
credits=[
comicapi.genericmetadata.Credit(person="test", role="writer", primary=False),
comicapi.genericmetadata.Credit(person="test", role="artist", primary=True),
],
),
comicapi.genericmetadata.GenericMetadata(
series="test2",
title="",
issue_count=2,
page_count=2,
day=0,
genres={"fake"},
characters={"bob", "fred"},
scan_info="nothing",
credits=[
comicapi.genericmetadata.Credit(person="Bob", role="writer", primary=False),
comicapi.genericmetadata.Credit(person="test", role="artist", primary=True),
],
),
comicapi.genericmetadata.GenericMetadata(
series="test",
title="test",
issue="1",
issue_count=2,
volume_count=1,
day=3,
page_count=3,
genres={"fake", "test", "test2"},
story_arcs=["arc"],
characters={"bob", "fred"},
scan_info="nothing",
credits=[
comicapi.genericmetadata.Credit(person="test", role="writer", primary=False),
comicapi.genericmetadata.Credit(person="test", role="artist", primary=True),
comicapi.genericmetadata.Credit(person="Bob", role="writer", primary=False),
],
),
),
]
metadata_combine = [
(
comicapi.genericmetadata.GenericMetadata(
series="test",
title="test",
issue="1",
volume_count=1,
page_count=3,
day=3,
genres={"test", "test2"},
story_arcs=["arc"],
characters=set(),
web_links=[comicapi.genericmetadata.parse_url("https://my.comics.here.com")],
),
comicapi.genericmetadata.GenericMetadata(
series="test2",
title="",
issue_count=2,
page_count=2,
day=0,
genres={"fake"},
characters={"bob", "fred"},
scan_info="nothing",
web_links=[comicapi.genericmetadata.parse_url("https://my.comics.here.com")],
),
comicapi.genericmetadata.GenericMetadata(
series="test2",
title="test",
issue="1",
issue_count=2,
volume_count=1,
day=0,
page_count=2,
genres={"fake", "test", "test2"},
story_arcs=["arc"],
characters={"bob", "fred"},
scan_info="nothing",
web_links=[comicapi.genericmetadata.parse_url("https://my.comics.here.com")],
),
),
(
comicapi.genericmetadata.GenericMetadata(characters={"Macintosh", "Søren Kierkegaard", "Barry"}),
comicapi.genericmetadata.GenericMetadata(characters={"MacIntosh", "Soren Kierkegaard"}),
comicapi.genericmetadata.GenericMetadata(
characters={"MacIntosh", "Soren Kierkegaard", "Søren Kierkegaard", "Barry"}
),
),
(
comicapi.genericmetadata.GenericMetadata(story_arcs=["arc 1", "arc2", "arc 3"]),
comicapi.genericmetadata.GenericMetadata(story_arcs=["Arc 1", "Arc2"]),
comicapi.genericmetadata.GenericMetadata(story_arcs=["Arc 1", "Arc2", "arc 3"]),
),
]
metadata_keys = [
@ -215,10 +98,7 @@ credits = [
(comicapi.genericmetadata.md_test, "writeR", "Dara Naraghi"),
(
comicapi.genericmetadata.md_test.replace(
credits=[
comicapi.genericmetadata.Credit(person="Dara Naraghi", role="writer"),
comicapi.genericmetadata.Credit(person="Dara Naraghi", role="writer"),
]
credits=[{"person": "Dara Naraghi", "role": "writer"}, {"person": "Dara Naraghi", "role": "writer"}]
),
"writeR",
"Dara Naraghi",
@ -255,30 +135,3 @@ all_seed_imprints = {
all_seed_imprints["Marvel"].update(additional_seed_imprints["Marvel"])
conflicting_seed_imprints = {"Marvel": {"test": "Never"}}
metadata_prepared = (
(
(comicapi.genericmetadata.GenericMetadata(), comicapi.genericmetadata.GenericMetadata()),
comicapi.genericmetadata.GenericMetadata(notes="Tagged with ComicTagger 1.3.2a5 on 2022-04-16 15:52:26."),
),
(
(comicapi.genericmetadata.GenericMetadata(issue_id="123"), comicapi.genericmetadata.GenericMetadata()),
comicapi.genericmetadata.GenericMetadata(
issue_id="123", notes="Tagged with ComicTagger 1.3.2a5 on 2022-04-16 15:52:26. [Issue ID 123]"
),
),
(
(
comicapi.genericmetadata.GenericMetadata(
issue_id="123", tag_origin=comicapi.genericmetadata.TagOrigin("SOURCE", "Source")
),
comicapi.genericmetadata.GenericMetadata(),
),
comicapi.genericmetadata.GenericMetadata(
issue_id="123",
tag_origin=comicapi.genericmetadata.TagOrigin("SOURCE", "Source"),
notes="Tagged with ComicTagger 1.3.2a5 using info from Source on 2022-04-16 15:52:26. [Issue ID 123]",
),
),
)

View File

@ -185,7 +185,7 @@ comic_issue_result = comicapi.genericmetadata.GenericMetadata(
issue=cv_issue_result["results"]["issue_number"],
volume=None,
title=cv_issue_result["results"]["name"],
web_links=[comicapi.genericmetadata.parse_url(cv_issue_result["results"]["site_detail_url"])],
web_link=cv_issue_result["results"]["site_detail_url"],
)
cv_md = comicapi.genericmetadata.GenericMetadata(
@ -213,7 +213,7 @@ cv_md = comicapi.genericmetadata.GenericMetadata(
alternate_count=None,
imprint=None,
notes=None,
web_links=[comicapi.genericmetadata.parse_url(cv_issue_result["results"]["site_detail_url"])],
web_link=cv_issue_result["results"]["site_detail_url"],
format=None,
manga=None,
black_and_white=None,

View File

@ -438,21 +438,6 @@ names: list[tuple[str, str, dict[str, str | bool], tuple[bool, bool]]] = [
},
(False, False),
),
(
"Blue Beetle #½.cbr",
"½",
{
"archive": "cbr",
"issue": "½",
"series": "Blue Beetle",
"title": "",
"volume": "",
"year": "",
"remainder": "",
"issue_count": "",
},
(False, True),
),
(
"Monster Island vol. 2 #2.cbz",
"Example from userguide",
@ -1047,11 +1032,10 @@ for p in names:
)
)
file_renames = [
rnames = [
(
"{series!c} {price} {year}", # Capitalize
False,
False,
"universal",
"Cory doctorow's futuristic tales of the here and now 2007.cbz",
does_not_raise(),
@ -1059,7 +1043,6 @@ file_renames = [
(
"{series!t} {price} {year}", # Title Case
False,
False,
"universal",
"Cory Doctorow'S Futuristic Tales Of The Here And Now 2007.cbz",
does_not_raise(),
@ -1067,7 +1050,6 @@ file_renames = [
(
"{series!S} {price} {year}", # Swap Case
False,
False,
"universal",
"cORY dOCTOROW'S fUTURISTIC tALES OF THE hERE AND nOW 2007.cbz",
does_not_raise(),
@ -1075,7 +1057,6 @@ file_renames = [
(
"{title!l} {price} {year}", # Lowercase
False,
False,
"universal",
"anda's game 2007.cbz",
does_not_raise(),
@ -1083,7 +1064,6 @@ file_renames = [
(
"{title!u} {price} {year}", # Upper Case
False,
False,
"universal",
"ANDA'S GAME 2007.cbz",
does_not_raise(),
@ -1091,7 +1071,6 @@ file_renames = [
(
"{title} {price} {year+}", # Empty alternate value
False,
False,
"universal",
"Anda's Game.cbz",
does_not_raise(),
@ -1099,7 +1078,6 @@ file_renames = [
(
"{title} {price} {year+year!u}", # Alternate value Upper Case
False,
False,
"universal",
"Anda's Game YEAR.cbz",
does_not_raise(),
@ -1107,7 +1085,6 @@ file_renames = [
(
"{title} {price} {year+year}", # Alternate Value
False,
False,
"universal",
"Anda's Game year.cbz",
does_not_raise(),
@ -1115,7 +1092,6 @@ file_renames = [
(
"{title} {price-0} {year}", # Default value
False,
False,
"universal",
"Anda's Game 0 2007.cbz",
does_not_raise(),
@ -1123,7 +1099,6 @@ file_renames = [
(
"{title} {price+0} {year}", # Alternate Value
False,
False,
"universal",
"Anda's Game 2007.cbz",
does_not_raise(),
@ -1131,7 +1106,6 @@ file_renames = [
(
"{series} #{issue} - {title} ({year}) ({price})", # price should be none
False,
False,
"universal",
"Cory Doctorow's Futuristic Tales of the Here and Now #001 - Anda's Game (2007).cbz",
does_not_raise(),
@ -1139,7 +1113,6 @@ file_renames = [
(
"{series} #{issue} - {title} {volume:02} ({year})", # Ensure format specifier works
False,
False,
"universal",
"Cory Doctorow's Futuristic Tales of the Here and Now #001 - Anda's Game 01 (2007).cbz",
does_not_raise(),
@ -1147,7 +1120,6 @@ file_renames = [
(
"{series} #{issue} - {title} ({year})({price})", # price should be none, test no space between ')('
False,
False,
"universal",
"Cory Doctorow's Futuristic Tales of the Here and Now #001 - Anda's Game (2007).cbz",
does_not_raise(),
@ -1155,7 +1127,6 @@ file_renames = [
(
"{series} #{issue} - {title} ({year}) ({price})", # price should be none, test double space ') ('
False,
False,
"universal",
"Cory Doctorow's Futuristic Tales of the Here and Now #001 - Anda's Game (2007).cbz",
does_not_raise(),
@ -1163,7 +1134,6 @@ file_renames = [
(
"{series} #{issue} - {title} ({year})",
False,
False,
"universal",
"Cory Doctorow's Futuristic Tales of the Here and Now #001 - Anda's Game (2007).cbz",
does_not_raise(),
@ -1171,7 +1141,6 @@ file_renames = [
(
"{title} {web_link}", # Ensure colon is replaced in metadata
False,
False,
"universal",
"Anda's Game https---comicvine.gamespot.com-cory-doctorows-futuristic-tales-of-the-here-and-no-4000-140529-.cbz",
does_not_raise(),
@ -1179,15 +1148,6 @@ file_renames = [
(
"{title} {web_link}", # Ensure slashes are replaced in metadata on linux/macos
False,
False,
"Linux",
"Anda's Game https:--comicvine.gamespot.com-cory-doctorows-futuristic-tales-of-the-here-and-no-4000-140529-.cbz",
does_not_raise(),
),
(
"{title} {web_links!j}", # Test that join forces str conversion
False,
False,
"Linux",
"Anda's Game https:--comicvine.gamespot.com-cory-doctorows-futuristic-tales-of-the-here-and-no-4000-140529-.cbz",
does_not_raise(),
@ -1195,7 +1155,6 @@ file_renames = [
(
"{series}:{title} #{issue} ({year})", # on windows the ':' is replaced
False,
False,
"universal",
"Cory Doctorow's Futuristic Tales of the Here and Now-Anda's Game #001 (2007).cbz",
does_not_raise(),
@ -1203,7 +1162,6 @@ file_renames = [
(
"{series}: {title} #{issue} ({year})", # on windows the ':' is replaced
False,
False,
"universal",
"Cory Doctorow's Futuristic Tales of the Here and Now - Anda's Game #001 (2007).cbz",
does_not_raise(),
@ -1211,7 +1169,6 @@ file_renames = [
(
"{series}: {title} #{issue} ({year})", # on linux the ':' is preserved
False,
False,
"Linux",
"Cory Doctorow's Futuristic Tales of the Here and Now: Anda's Game #001 (2007).cbz",
does_not_raise(),
@ -1219,7 +1176,6 @@ file_renames = [
(
"{publisher}/ {series} #{issue} - {title} ({year})", # leading whitespace is removed when moving
True,
False,
"universal",
"IDW Publishing/Cory Doctorow's Futuristic Tales of the Here and Now #001 - Anda's Game (2007).cbz",
does_not_raise(),
@ -1227,7 +1183,6 @@ file_renames = [
(
"{publisher}/ {series} #{issue} - {title} ({year})", # leading whitespace is removed when only renaming
False,
False,
"universal",
"Cory Doctorow's Futuristic Tales of the Here and Now #001 - Anda's Game (2007).cbz",
does_not_raise(),
@ -1235,7 +1190,6 @@ file_renames = [
(
r"{publisher}\ {series} #{issue} - {title} ({year})", # backslashes separate directories
False,
False,
"Linux",
"Cory Doctorow's Futuristic Tales of the Here and Now #001 - Anda's Game (2007).cbz",
does_not_raise(),
@ -1243,7 +1197,6 @@ file_renames = [
(
"{series} # {issue} - {title} ({year})", # double spaces are reduced to one
False,
False,
"universal",
"Cory Doctorow's Futuristic Tales of the Here and Now # 001 - Anda's Game (2007).cbz",
does_not_raise(),
@ -1251,7 +1204,6 @@ file_renames = [
(
"{series} #{issue} - {locations!j} ({year})",
False,
False,
"universal",
"Cory Doctorow's Futuristic Tales of the Here and Now #001 - lonely cottage (2007).cbz",
does_not_raise(),
@ -1259,7 +1211,6 @@ file_renames = [
(
"{series} #{issue} - {title} - {WriteR}, {EDITOR} ({year})", # fields are case in-sensitive
False,
False,
"universal",
"Cory Doctorow's Futuristic Tales of the Here and Now #001 - Anda's Game - Dara Naraghi, Ted Adams (2007).cbz",
does_not_raise(),
@ -1267,7 +1218,6 @@ file_renames = [
(
"{series} v{price} #{issue} ({year})", # Remove previous text if value is ""
False,
False,
"universal",
"Cory Doctorow's Futuristic Tales of the Here and Now #001 (2007).cbz",
does_not_raise(),
@ -1275,7 +1225,6 @@ file_renames = [
(
"{series} {price} #{issue} ({year})", # Ensure that a single space remains
False,
False,
"universal",
"Cory Doctorow's Futuristic Tales of the Here and Now #001 (2007).cbz",
does_not_raise(),
@ -1283,7 +1232,6 @@ file_renames = [
(
"{series} - {title}{price} #{issue} ({year})", # Ensure removal before None values only impacts literal text
False,
False,
"universal",
"Cory Doctorow's Futuristic Tales of the Here and Now - Anda's Game #001 (2007).cbz",
does_not_raise(),
@ -1291,7 +1239,6 @@ file_renames = [
(
"{series} - {title} {test} #{issue} ({year})", # Test non-existent key
False,
False,
"universal",
"Cory Doctorow's Futuristic Tales of the Here and Now - Anda's Game {test} #001 (2007).cbz",
does_not_raise(),
@ -1299,7 +1246,6 @@ file_renames = [
(
"{series} - {title} #{issue} ({year} {price})", # Test null value in parenthesis with a non-null value
False,
False,
"universal",
"Cory Doctorow's Futuristic Tales of the Here and Now - Anda's Game #001 (2007).cbz",
does_not_raise(),
@ -1307,7 +1253,6 @@ file_renames = [
(
"{series} - {title} #{issue} (of {price})", # null value with literal text in parenthesis
False,
False,
"universal",
"Cory Doctorow's Futuristic Tales of the Here and Now - Anda's Game #001.cbz",
does_not_raise(),
@ -1315,24 +1260,15 @@ file_renames = [
(
"{series} - {title} {1} #{issue} ({year})", # Test numeric key
False,
False,
"universal",
"Cory Doctorow's Futuristic Tales of the Here and Now - Anda's Game {test} #001 (2007).cbz",
pytest.raises(ValueError),
),
(
"{series} - {title} #{issue} ({year})",
False,
True,
"universal",
"Cory Doctorow's Futuristic Tales of the Here and Now - Anda's Game #001 (2007)/cory doctorow #1.cbz",
does_not_raise(),
),
]
folder_names = [
(None, lambda: pathlib.Path(str(cbz_path)).parent.absolute()),
("", lambda: pathlib.Path(os.getcwd())),
("test", lambda: (pathlib.Path(os.getcwd()) / "test")),
(pathlib.Path(os.getcwd()) / "test", lambda: pathlib.Path(os.getcwd()) / "test"),
rfnames = [
(None, lambda x: x.path.parent.absolute()),
("", lambda x: pathlib.Path(os.getcwd())),
("test", lambda x: (pathlib.Path(os.getcwd()) / "test")),
(pathlib.Path(os.getcwd()) / "test", lambda x: pathlib.Path(os.getcwd()) / "test"),
]

View File

@ -139,7 +139,7 @@ def mock_now(monkeypatch):
def now(cls):
return cls.time
monkeypatch.setattr(comictaggerlib.md, "datetime", mydatetime)
monkeypatch.setattr(comictaggerlib.cli, "datetime", mydatetime)
@pytest.fixture

View File

@ -7,13 +7,13 @@ import comictaggerlib.ctsettings.types
md_strings = (
("", comicapi.genericmetadata.md_test.replace()),
("year:", comicapi.genericmetadata.md_test.replace(year=None)),
("year: 2009", comicapi.genericmetadata.md_test.replace(year=2009)),
("series:", comicapi.genericmetadata.md_test.replace(series=None)),
("series_aliases:", comicapi.genericmetadata.md_test.replace(series_aliases=set())),
("black_and_white:", comicapi.genericmetadata.md_test.replace(black_and_white=None)),
("credits:", comicapi.genericmetadata.md_test.replace(credits=[])),
("story_arcs:", comicapi.genericmetadata.md_test.replace(story_arcs=[])),
("year=", comicapi.genericmetadata.md_test.replace(year=None)),
("year=2009", comicapi.genericmetadata.md_test.replace(year="2009")),
("series=", comicapi.genericmetadata.md_test.replace(series=None)),
("series_aliases=", comicapi.genericmetadata.md_test.replace(series_aliases=set())),
("black_and_white=", comicapi.genericmetadata.md_test.replace(black_and_white=None)),
("credits=", comicapi.genericmetadata.md_test.replace(credits=[])),
("story_arcs=", comicapi.genericmetadata.md_test.replace(story_arcs=[])),
)

View File

@ -1,12 +1,9 @@
from __future__ import annotations
import textwrap
import pytest
import comicapi.genericmetadata
import comicapi.merge
import testing.comicdata
from testing.comicdata import credits, metadata
def test_apply_default_page_list(tmp_path):
@ -18,25 +15,13 @@ def test_apply_default_page_list(tmp_path):
assert isinstance(md.pages[0]["image_index"], int)
@pytest.mark.parametrize("md, new, expected", testing.comicdata.metadata)
def test_metadata_overlay(md, new, expected):
md.overlay(new, comicapi.merge.Mode.OVERLAY)
@pytest.mark.parametrize("replaced, expected", metadata)
def test_metadata_overlay(md: comicapi.genericmetadata.GenericMetadata, replaced, expected):
md.overlay(replaced)
assert md == expected
@pytest.mark.parametrize("md, new, expected", testing.comicdata.metadata_add)
def test_metadata_overlay_add_missing(md, new, expected):
md.overlay(new, comicapi.merge.Mode.ADD_MISSING, True)
assert md == expected
@pytest.mark.parametrize("md, new, expected", testing.comicdata.metadata_combine)
def test_metadata_overlay_combine(md, new, expected):
md.overlay(new, comicapi.merge.Mode.OVERLAY, True)
assert md == expected
def test_add_credit():
md = comicapi.genericmetadata.GenericMetadata()
@ -52,49 +37,6 @@ def test_add_credit_primary():
assert md.credits == [comicapi.genericmetadata.Credit(person="test", role="writer", primary=True)]
@pytest.mark.parametrize("md, role, expected", testing.comicdata.credits)
@pytest.mark.parametrize("md, role, expected", credits)
def test_get_primary_credit(md, role, expected):
assert md.get_primary_credit(role) == expected
def test_str(md):
expected = textwrap.dedent(
"""\
series: Cory Doctorow's Futuristic Tales of the Here and Now
issue: 1
issue_count: 6
title: Anda's Game
publisher: IDW Publishing
year: 2007
month: 10
day: 1
volume: 1
genres: Sci-Fi
language: en
critical_rating: 3.0
alternate_series: Tales
alternate_number: 2
alternate_count: 7
imprint: craphound.com
web_links: ['https://comicvine.gamespot.com/cory-doctorows-futuristic-tales-of-the-here-and-no/4000-140529/']
format: Series
manga: No
maturity_rating: Everyone 10+
story_arcs: ['Here and Now']
series_groups: ['Futuristic Tales']
scan_info: (CC BY-NC-SA 3.0)
characters: Anda
teams: Fahrenheit
locations: lonely cottage
description: For 12-year-old Anda, getting paid real money to kill the characters of players who were cheating in her favorite online computer game was a win-win situation. Until she found out who was paying her, and what those characters meant to the livelihood of children around the world.
notes: Tagged with ComicTagger 1.3.2a5 using info from Comic Vine on 2022-04-16 15:52:26. [Issue ID 140529]
credit: Writer: Dara Naraghi
credit: Penciller: Esteve Polls
credit: Inker: Esteve Polls
credit: Letterer: Neil Uyetake
credit: Cover: Sam Kieth
credit: Editor: Ted Adams
"""
)
assert str(md) == expected

View File

@ -39,9 +39,8 @@ def test_save(
config[0].Runtime_Options__online = True
# Use the temporary comic we created
config[0].Runtime_Options__files = [tmp_comic.path]
# Read and save ComicRack tags
config[0].Runtime_Options__type_read = ["cr"]
config[0].Runtime_Options__type_modify = ["cr"]
# Save ComicRack tags
config[0].Runtime_Options__type = ["cr"]
# Search using the correct series since we just put the wrong series name in the CBZ
config[0].Runtime_Options__metadata = comicapi.genericmetadata.GenericMetadata(series=md_saved.series)
# Run ComicTagger
@ -54,11 +53,11 @@ def test_save(
# unrelated to comicvine need to be re-worked
md_saved.credits.insert(
1,
comicapi.genericmetadata.Credit(
person="Esteve Polls",
primary=False,
role="Writer",
),
{
"person": "Esteve Polls",
"primary": False,
"role": "Writer",
},
)
# Validate that we got the correct metadata back
@ -90,7 +89,7 @@ def test_delete(
# Use the temporary comic we created
config[0].Runtime_Options__files = [tmp_comic.path]
# Delete ComicRack tags
config[0].Runtime_Options__type_modify = ["cr"]
config[0].Runtime_Options__type = ["cr"]
# Run ComicTagger
CLI(config[0], talkers).run()

View File

@ -4,8 +4,6 @@ import pytest
from importlib_metadata import entry_points
import comicapi.genericmetadata
import testing.comicdata
from comictaggerlib.md import prepare_metadata
metadata_styles = []
@ -40,9 +38,3 @@ def test_metadata(mock_version, tmp_comic, md_saved, metadata):
written_metadata = written_metadata.get_clean_metadata(*supported_attributes)
assert written_metadata == md
@pytest.mark.parametrize("metadata, expected", testing.comicdata.metadata_prepared)
def test_prepare_metadata(mock_version, mock_now, config, metadata, expected):
new_md = prepare_metadata(metadata[0], metadata[1], config[0])
assert new_md == expected

View File

@ -6,20 +6,18 @@ import pytest
from comicapi.genericmetadata import md_test
from comictaggerlib import filerenamer
from testing.filenames import file_renames, folder_names
from testing.filenames import rfnames, rnames
@pytest.mark.parametrize("template, move, move_only, platform, expected, exception", file_renames)
def test_rename(template, move, move_only, platform, expected, exception):
fr = filerenamer.FileRenamer(None, platform=platform)
fr.set_metadata(md_test, "cory doctorow #1.cbz")
@pytest.mark.parametrize("template, move, platform, expected, exception", rnames)
def test_rename(template, platform, move, expected, exception):
fr = filerenamer.FileRenamer(md_test, platform=platform)
fr.move = move
fr.move_only = move_only
fr.set_template(template)
with exception:
assert str(pathlib.PureWindowsPath(fr.determine_name(".cbz"))) == str(pathlib.PureWindowsPath(expected))
@pytest.mark.parametrize("inp, result", folder_names)
@pytest.mark.parametrize("inp, result", rfnames)
def test_get_rename_dir(inp, result, cbz):
assert result() == filerenamer.get_rename_dir(cbz, inp)
assert result(cbz) == filerenamer.get_rename_dir(cbz, inp)