Compare commits
93 Commits
956c383e5f
...
2897611006
Author | SHA1 | Date | |
---|---|---|---|
|
2897611006 | ||
|
250d777159 | ||
|
6c3b63abd9 | ||
|
bada694fd4 | ||
|
a40438d38c | ||
|
3d443e0908 | ||
|
b761763c4c | ||
|
71b79bdc91 | ||
|
2faac18597 | ||
|
e9a592df50 | ||
|
94b94b76dc | ||
|
62240bf2f4 | ||
|
ffb4efbcd7 | ||
|
b2f95faac4 | ||
|
93be16f7eb | ||
|
8b0683f67c | ||
|
851339d4e3 | ||
|
5cf54ab511 | ||
|
384ac5e33a | ||
|
7271caccc9 | ||
|
0c9e846bfb | ||
|
a2a57b6da0 | ||
|
35ec334c28 | ||
|
7383b18924 | ||
|
e0f1f7c356 | ||
|
6b8b961ff7 | ||
|
4c6a1d3215 | ||
|
64dbf9e981 | ||
|
27e3803414 | ||
|
591b6bcc44 | ||
|
6ac2e32612 | ||
|
887c383229 | ||
|
64c909facb | ||
|
23ceda33bd | ||
|
7e63070f13 | ||
|
247ee01d6e | ||
|
f61b91acd6 | ||
|
6951113717 | ||
|
73269c7c9d | ||
|
f00cd1568c | ||
|
f9d79ead9d | ||
|
c01d6aaa3a | ||
|
dd8767ad81 | ||
|
0bbdaa96cf | ||
|
96bbbe51e7 | ||
|
16088aec72 | ||
|
199167c50b | ||
|
9359cd877d | ||
|
003b68b3d3 | ||
|
29dc7ad830 | ||
|
770cce5ac0 | ||
|
235e62814f | ||
|
cd2d40a379 | ||
|
d63123b77b | ||
|
8b4bf8d51f | ||
|
d98f815ce0 | ||
|
787f3e8ea1 | ||
|
064795fac9 | ||
|
9208a80ab0 | ||
|
a681abb854 | ||
|
996397b9d5 | ||
|
8fb180390d | ||
|
c311b8e351 | ||
|
af059b8775 | ||
|
de3a9352ea | ||
|
d104ae1e8e | ||
|
88c2980e5d | ||
|
8bcd51f49b | ||
|
de084ffff9 | ||
|
eb6c2ed72b | ||
|
c99b691041 | ||
|
48fd1c2897 | ||
|
37c809db2a | ||
|
51db3e1249 | ||
|
c99f3fa083 | ||
|
6f3a5a8860 | ||
|
ebd99cb144 | ||
|
b1a9b0b016 | ||
|
0929a6678b | ||
|
69824412ce | ||
|
0d9756f8b0 | ||
|
244cd9101d | ||
|
3df263858d | ||
|
b45c39043b | ||
|
9eae71fb62 | ||
|
9a95adf47d | ||
|
5155762711 | ||
|
ff2547e7f2 | ||
|
163cf44751 | ||
|
3c3700838b | ||
|
05423c8270 | ||
|
b2d3869488 | ||
|
44e9a47a8b |
18
.github/workflows/build.yaml
vendored
18
.github/workflows/build.yaml
vendored
@ -1,7 +1,6 @@
|
||||
name: CI
|
||||
|
||||
env:
|
||||
PKG_CONFIG_PATH: /usr/local/opt/icu4c/lib/pkgconfig
|
||||
LC_COLLATE: en_US.UTF-8
|
||||
on:
|
||||
pull_request:
|
||||
@ -23,12 +22,12 @@ jobs:
|
||||
os: [ubuntu-latest]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v3
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
@ -51,12 +50,12 @@ jobs:
|
||||
os: [ubuntu-latest, macos-11, windows-latest]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v3
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
@ -66,21 +65,20 @@ jobs:
|
||||
|
||||
- name: Install macos dependencies
|
||||
run: |
|
||||
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"
|
||||
brew upgrade icu4c pkg-config || brew install icu4c pkg-config
|
||||
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
|
||||
|
2
.github/workflows/contributions.yaml
vendored
2
.github/workflows/contributions.yaml
vendored
@ -23,7 +23,7 @@ jobs:
|
||||
use_username: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
16
.github/workflows/package.yaml
vendored
16
.github/workflows/package.yaml
vendored
@ -1,7 +1,6 @@
|
||||
name: Package
|
||||
|
||||
env:
|
||||
PKG_CONFIG_PATH: /usr/local/opt/icu4c/lib/pkgconfig
|
||||
LC_COLLATE: en_US.UTF-8
|
||||
on:
|
||||
push:
|
||||
@ -18,12 +17,12 @@ jobs:
|
||||
os: [ubuntu-latest, macos-11, windows-latest]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v3
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
@ -33,22 +32,21 @@ jobs:
|
||||
|
||||
- name: Install macos dependencies
|
||||
run: |
|
||||
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"
|
||||
brew upgrade && brew install icu4c pkg-config
|
||||
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 }}
|
||||
@ -61,7 +59,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@v1
|
||||
uses: softprops/action-gh-release@v2
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
name: "${{ env.release_name }}"
|
||||
|
@ -1,7 +1,7 @@
|
||||
exclude: ^scripts
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.5.0
|
||||
rev: v4.6.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.1
|
||||
rev: v3.15.2
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [--py39-plus]
|
||||
- repo: https://github.com/PyCQA/autoflake
|
||||
rev: v2.3.0
|
||||
rev: v2.3.1
|
||||
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.2.0
|
||||
rev: 24.4.2
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: https://github.com/PyCQA/flake8
|
||||
rev: 7.0.0
|
||||
hooks:
|
||||
- id: flake8
|
||||
additional_dependencies: [flake8-encodings, flake8-builtins, flake8-length, flake8-print, flake8-no-nested-comprehensions]
|
||||
additional_dependencies: [flake8-encodings, flake8-builtins, flake8-print, flake8-no-nested-comprehensions]
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.8.0
|
||||
rev: v1.10.0
|
||||
hooks:
|
||||
- id: mypy
|
||||
additional_dependencies: [types-setuptools, types-requests, settngs>=0.10.0]
|
||||
|
468
comicapi/_url.py
Normal file
468
comicapi/_url.py
Normal file
@ -0,0 +1,468 @@
|
||||
# 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")
|
@ -24,7 +24,7 @@ import pathlib
|
||||
import shutil
|
||||
import sys
|
||||
import traceback
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import Iterable
|
||||
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: Sequence[EntryPoint] = tuple()) -> None:
|
||||
def load_archive_plugins(local_plugins: Iterable[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: Sequence[EntryPoint] = tuple()) -> None:
|
||||
archivers.extend(builtin)
|
||||
|
||||
|
||||
def load_metadata_plugins(version: str = f"ComicAPI/{version}", local_plugins: Sequence[EntryPoint] = tuple()) -> None:
|
||||
def load_metadata_plugins(version: str = f"ComicAPI/{version}", local_plugins: Iterable[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: list[str]) -> None:
|
||||
def load_cache(self, style_list: Iterable[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,
|
||||
complicated_parser: bool = False,
|
||||
parser: utils.Parser = utils.Parser.ORIGINAL,
|
||||
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,
|
||||
complicated_parser=complicated_parser,
|
||||
parser=parser,
|
||||
remove_c2c=remove_c2c,
|
||||
remove_fcbd=remove_fcbd,
|
||||
remove_publisher=remove_publisher,
|
||||
|
@ -146,7 +146,8 @@ class Lexer:
|
||||
return False
|
||||
|
||||
# AcceptRun consumes a run of runes from the valid set.
|
||||
def accept_run(self, valid: str | Callable[[str], bool]) -> None:
|
||||
def accept_run(self, valid: str | Callable[[str], bool]) -> bool:
|
||||
initial = self.pos
|
||||
if isinstance(valid, str):
|
||||
while self.get() in valid:
|
||||
continue
|
||||
@ -155,11 +156,13 @@ class Lexer:
|
||||
continue
|
||||
|
||||
self.backup()
|
||||
return initial != self.pos
|
||||
|
||||
def scan_number(self) -> bool:
|
||||
digits = "0123456789.,"
|
||||
|
||||
self.accept_run(digits)
|
||||
if not self.accept_run(lambda x: x.isnumeric() or x in digits):
|
||||
return False
|
||||
if self.input[self.pos] == ".":
|
||||
self.backup()
|
||||
self.accept_run(str.isalpha)
|
||||
@ -217,7 +220,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().isdigit() or lex.peek() in "-+.":
|
||||
elif lex.peek().isnumeric() or lex.peek() in "-+.":
|
||||
return lex_issue_number
|
||||
lex.emit(ItemType.Symbol)
|
||||
elif is_operator(r):
|
||||
@ -344,7 +347,7 @@ def lex_number(lex: Lexer) -> LexerFunc | None:
|
||||
|
||||
if lex.input[lex.start] == "#":
|
||||
lex.emit(ItemType.IssueNumber)
|
||||
elif not lex.input[lex.pos].isdigit():
|
||||
elif not lex.input[lex.pos].isnumeric():
|
||||
# Assume that 80th is just text and not a number
|
||||
lex.emit(ItemType.Text)
|
||||
else:
|
||||
|
@ -25,15 +25,23 @@ import copy
|
||||
import dataclasses
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from typing import Any, TypedDict
|
||||
from typing import TYPE_CHECKING, Any, TypedDict, Union, overload
|
||||
|
||||
from typing_extensions import NamedTuple, Required
|
||||
|
||||
from comicapi import utils
|
||||
from comicapi import merge, utils
|
||||
from comicapi._url import Url, parse_url
|
||||
from comicapi.utils import norm_fold
|
||||
|
||||
if TYPE_CHECKING:
|
||||
Union
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
REMOVE = object()
|
||||
|
||||
|
||||
class PageType:
|
||||
"""
|
||||
These page info classes are exactly the same as the CIX scheme, since
|
||||
@ -64,10 +72,7 @@ class ImageMetadata(TypedDict, total=False):
|
||||
width: str
|
||||
|
||||
|
||||
class Credit(TypedDict):
|
||||
person: str
|
||||
role: str
|
||||
primary: bool
|
||||
Credit = merge.Credit
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
@ -133,7 +138,7 @@ class GenericMetadata:
|
||||
year: int | None = None
|
||||
language: str | None = None # 2 letter iso code
|
||||
country: str | None = None
|
||||
web_link: str | None = None
|
||||
web_links: list[Url] = dataclasses.field(default_factory=list)
|
||||
format: str | None = None
|
||||
manga: str | None = None
|
||||
black_and_white: bool | None = None
|
||||
@ -202,96 +207,87 @@ class GenericMetadata:
|
||||
new_md.__post_init__()
|
||||
return new_md
|
||||
|
||||
def overlay(self, new_md: GenericMetadata) -> None:
|
||||
"""Overlay a metadata object on this one
|
||||
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"""
|
||||
|
||||
That is, when the new object has non-None values, over-write them
|
||||
to this one.
|
||||
"""
|
||||
attribute_merge = merge.attribute[mode]
|
||||
list_merge = merge.lists[mode]
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
if not new_md.is_empty:
|
||||
self.is_empty = False
|
||||
|
||||
assign("tag_origin", new_md.tag_origin)
|
||||
assign("issue_id", new_md.issue_id)
|
||||
assign("series_id", new_md.series_id)
|
||||
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("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 = assign(self.series, new_md.series)
|
||||
|
||||
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.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("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.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("tags", new_md.tags)
|
||||
assign("pages", new_md.pages)
|
||||
assign("page_count", new_md.page_count)
|
||||
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("characters", new_md.characters)
|
||||
assign("teams", new_md.teams)
|
||||
assign("locations", new_md.locations)
|
||||
self.overlay_credits(new_md.credits)
|
||||
self.tags = assign_list(self.tags, new_md.tags)
|
||||
|
||||
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.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)]
|
||||
|
||||
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.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)
|
||||
|
||||
# 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)
|
||||
self.pages = assign_list(self.pages, new_md.pages)
|
||||
self.page_count = assign(self.page_count, new_md.page_count)
|
||||
|
||||
def apply_default_page_list(self, page_list: Sequence[str]) -> None:
|
||||
# generate a default page list, with the first page marked as the cover
|
||||
@ -336,15 +332,35 @@ class GenericMetadata:
|
||||
|
||||
return coverlist
|
||||
|
||||
def add_credit(self, person: str, role: str, primary: bool = False) -> None:
|
||||
credit = Credit(person=person, role=role, primary=primary)
|
||||
@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)
|
||||
|
||||
# look to see if it's not already there...
|
||||
found = False
|
||||
for c in self.credits:
|
||||
if c["person"].casefold() == person.casefold() and c["role"].casefold() == role.casefold():
|
||||
if norm_fold(c.person) == person and norm_fold(c.role) == role:
|
||||
# no need to add it. just adjust the "primary" flag as needed
|
||||
c["primary"] = primary
|
||||
c.primary = c.primary or primary
|
||||
found = True
|
||||
break
|
||||
|
||||
@ -354,12 +370,10 @@ class GenericMetadata:
|
||||
def get_primary_credit(self, role: str) -> str:
|
||||
primary = ""
|
||||
for credit in self.credits:
|
||||
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"]
|
||||
if (primary == "" and credit.role.casefold() == role.casefold()) or (
|
||||
credit.role.casefold() == role.casefold() and credit.primary
|
||||
):
|
||||
primary = credit["person"]
|
||||
primary = credit.person
|
||||
return primary
|
||||
|
||||
def __str__(self) -> str:
|
||||
@ -374,56 +388,53 @@ class GenericMetadata:
|
||||
elif val is not None:
|
||||
vals.append((tag, val))
|
||||
|
||||
def add_attr_string(tag: str) -> None:
|
||||
add_string(tag, getattr(self, tag))
|
||||
|
||||
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("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_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_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)
|
||||
|
||||
add_attr_string("price")
|
||||
add_attr_string("is_version_of")
|
||||
add_attr_string("rights")
|
||||
add_attr_string("identifier")
|
||||
add_attr_string("last_mark")
|
||||
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)
|
||||
|
||||
if self.black_and_white:
|
||||
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("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_string("characters", ", ".join(self.characters))
|
||||
add_string("teams", ", ".join(self.teams))
|
||||
add_string("locations", ", ".join(self.locations))
|
||||
add_attr_string("description")
|
||||
add_attr_string("notes")
|
||||
add_string("description", self.description)
|
||||
add_string("notes", self.notes)
|
||||
|
||||
add_string("tags", ", ".join(self.tags))
|
||||
|
||||
for c in self.credits:
|
||||
primary = ""
|
||||
if "primary" in c and c["primary"]:
|
||||
if 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
|
||||
@ -487,7 +498,9 @@ 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_link="https://comicvine.gamespot.com/cory-doctorows-futuristic-tales-of-the-here-and-no/4000-140529/",
|
||||
web_links=[
|
||||
parse_url("https://comicvine.gamespot.com/cory-doctorows-futuristic-tales-of-the-here-and-no/4000-140529/")
|
||||
],
|
||||
format="Series",
|
||||
manga="No",
|
||||
black_and_white=None,
|
||||
@ -551,3 +564,15 @@ md_test: GenericMetadata = GenericMetadata(
|
||||
last_mark=None,
|
||||
_cover_image=None,
|
||||
)
|
||||
|
||||
|
||||
__all__ = (
|
||||
"Url",
|
||||
"parse_url",
|
||||
"PageType",
|
||||
"ImageMetadata",
|
||||
"Credit",
|
||||
"ComicSeries",
|
||||
"TagOrigin",
|
||||
"GenericMetadata",
|
||||
)
|
||||
|
105
comicapi/merge.py
Normal file
105
comicapi/merge.py
Normal file
@ -0,0 +1,105 @@
|
||||
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),
|
||||
},
|
||||
)
|
@ -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)
|
||||
|
||||
|
@ -47,6 +47,12 @@ _CBILiteralType = Literal[
|
||||
]
|
||||
|
||||
|
||||
class credit(TypedDict):
|
||||
person: str
|
||||
role: str
|
||||
primary: bool
|
||||
|
||||
|
||||
class _ComicBookInfoJson(TypedDict, total=False):
|
||||
series: str
|
||||
title: str
|
||||
@ -61,7 +67,7 @@ class _ComicBookInfoJson(TypedDict, total=False):
|
||||
genre: str
|
||||
language: str
|
||||
country: str
|
||||
credits: list[Credit]
|
||||
credits: list[credit]
|
||||
tags: list[str]
|
||||
comments: str
|
||||
|
||||
@ -217,7 +223,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", metadata.credits)
|
||||
assign("credits", [credit(person=c.person, role=c.role, primary=c.primary) for c in metadata.credits])
|
||||
assign("tags", list(metadata.tags))
|
||||
|
||||
return cbi_container
|
||||
|
@ -57,7 +57,7 @@ class ComicRack(Metadata):
|
||||
"month",
|
||||
"year",
|
||||
"language",
|
||||
"web_link",
|
||||
"web_links",
|
||||
"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", md.web_link)
|
||||
assign("Web", " ".join(u.url for u in md.web_links))
|
||||
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_link = utils.xlate(get("Web"))
|
||||
md.web_links = utils.split_urls(utils.xlate(get("Web")))
|
||||
md.format = utils.xlate(get("Format"))
|
||||
md.manga = utils.xlate(get("Manga"))
|
||||
md.maturity_rating = utils.xlate(get("AgeRating"))
|
||||
|
@ -20,15 +20,23 @@ 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
|
||||
|
||||
@ -37,9 +45,55 @@ 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
|
||||
|
||||
@ -67,7 +121,7 @@ def os_sorted(lst: Iterable[T]) -> Iterable[T]:
|
||||
|
||||
def parse_filename(
|
||||
filename: str,
|
||||
complicated_parser: bool = False,
|
||||
parser: Parser = Parser.ORIGINAL,
|
||||
remove_c2c: bool = False,
|
||||
remove_fcbd: bool = False,
|
||||
remove_publisher: bool = False,
|
||||
@ -99,7 +153,25 @@ def parse_filename(
|
||||
filename, ext = os.path.splitext(filename)
|
||||
filename = " ".join(wordninja.split(filename)) + ext
|
||||
|
||||
if complicated_parser:
|
||||
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:
|
||||
lex = filenamelexer.Lex(filename, allow_issue_start_with_letter)
|
||||
p = filenameparser.Parse(
|
||||
lex.items,
|
||||
@ -108,7 +180,26 @@ def parse_filename(
|
||||
remove_publisher=remove_publisher,
|
||||
protofolius_issue_number_scheme=protofolius_issue_number_scheme,
|
||||
)
|
||||
return p.filename_info
|
||||
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", ""),
|
||||
)
|
||||
else:
|
||||
fnp = filenameparser.FileNameParser()
|
||||
fnp.parse_filename(filename)
|
||||
@ -129,7 +220,12 @@ def parse_filename(
|
||||
year=fnp.year,
|
||||
format="",
|
||||
)
|
||||
return fni
|
||||
return fni
|
||||
|
||||
|
||||
def norm_fold(string: str) -> str:
|
||||
"""Normalise and casefold string"""
|
||||
return unicodedata.normalize("NFKD", string).casefold()
|
||||
|
||||
|
||||
def combine_notes(existing_notes: str | None, new_notes: str | None, split: str) -> str:
|
||||
@ -283,6 +379,24 @@ 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 = [
|
||||
|
@ -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
|
||||
from comictalker.comictalker import ComicTalker, TalkerError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -39,7 +39,7 @@ class AutoTagMatchWindow(QtWidgets.QDialog):
|
||||
self,
|
||||
parent: QtWidgets.QWidget,
|
||||
match_set_list: list[Result],
|
||||
styles: list[str],
|
||||
load_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 = styles
|
||||
self._styles = load_styles
|
||||
self.fetch_func = fetch_func
|
||||
|
||||
self.current_match_set_idx = 0
|
||||
@ -229,24 +229,40 @@ 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__complicated_parser,
|
||||
self.config.Filename_Parsing__filename_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
|
||||
self.current_match_set.md = ct_md = self.fetch_func(match)
|
||||
if ct_md is None:
|
||||
|
||||
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:
|
||||
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)
|
||||
md.overlay(ct_md, self.config.internal__source_data_overlay, self.config.internal__overlay_merge_lists)
|
||||
for style in self._styles:
|
||||
success = ca.write_metadata(md, style)
|
||||
QtWidgets.QApplication.restoreOverrideCursor()
|
||||
@ -258,4 +274,4 @@ class AutoTagMatchWindow(QtWidgets.QDialog):
|
||||
)
|
||||
break
|
||||
|
||||
ca.load_cache(list(metadata_styles))
|
||||
ca.reset_cache()
|
||||
|
@ -26,24 +26,24 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class CBLTransformer:
|
||||
def __init__(self, metadata: GenericMetadata, config: ct_ns) -> None:
|
||||
self.metadata = metadata
|
||||
self.metadata = metadata.copy()
|
||||
self.config = config
|
||||
|
||||
def apply(self) -> GenericMetadata:
|
||||
if self.config.Comic_Book_Lover__assume_lone_credit_is_primary:
|
||||
if self.config.Metadata_Options__cbl_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.Comic_Book_Lover__copy_characters_to_tags:
|
||||
if self.config.Metadata_Options__cbl_copy_characters_to_tags:
|
||||
self.metadata.tags.update(x for x in self.metadata.characters)
|
||||
|
||||
if self.config.Comic_Book_Lover__copy_teams_to_tags:
|
||||
if self.config.Metadata_Options__cbl_copy_teams_to_tags:
|
||||
self.metadata.tags.update(x for x in self.metadata.teams)
|
||||
|
||||
if self.config.Comic_Book_Lover__copy_locations_to_tags:
|
||||
if self.config.Metadata_Options__cbl_copy_locations_to_tags:
|
||||
self.metadata.tags.update(x for x in self.metadata.locations)
|
||||
|
||||
if self.config.Comic_Book_Lover__copy_storyarcs_to_tags:
|
||||
if self.config.Metadata_Options__cbl_copy_storyarcs_to_tags:
|
||||
self.metadata.tags.update(x for x in self.metadata.story_arcs)
|
||||
|
||||
if self.config.Comic_Book_Lover__copy_notes_to_comments:
|
||||
if self.config.Metadata_Options__cbl_copy_notes_to_comments:
|
||||
if self.metadata.notes is not None:
|
||||
if self.metadata.description is None:
|
||||
self.metadata.description = ""
|
||||
@ -77,13 +77,14 @@ class CBLTransformer:
|
||||
if self.metadata.notes not in self.metadata.description:
|
||||
self.metadata.description += self.metadata.notes
|
||||
|
||||
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 = ""
|
||||
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 = ""
|
||||
else:
|
||||
self.metadata.description += "\n\n"
|
||||
if self.metadata.web_link not in self.metadata.description:
|
||||
self.metadata.description += self.metadata.web_link
|
||||
temp_desc += "\n\n"
|
||||
if web_link.url and web_link.url not in temp_desc:
|
||||
self.metadata.description = temp_desc + web_link.url
|
||||
|
||||
return self.metadata
|
||||
|
@ -24,22 +24,20 @@ import os
|
||||
import pathlib
|
||||
import sys
|
||||
from collections.abc import Collection
|
||||
from datetime import datetime
|
||||
from typing import Any, TextIO
|
||||
|
||||
from comicapi import utils
|
||||
from comicapi import merge, 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__)
|
||||
|
||||
@ -104,7 +102,8 @@ class CLI:
|
||||
self.batch_mode = len(self.config.Runtime_Options__files) > 1
|
||||
|
||||
for f in self.config.Runtime_Options__files:
|
||||
results.append(self.process_file_cli(self.config.Commands__command, f, match_results))
|
||||
res, match_results = self.process_file_cli(self.config.Commands__command, f, match_results)
|
||||
results.append(res)
|
||||
if results[-1].status != Status.success:
|
||||
return_code = 3
|
||||
if self.config.Runtime_Options__json:
|
||||
@ -128,14 +127,14 @@ class CLI:
|
||||
logger.exception(f"Error retrieving issue details. Save aborted.\n{e}")
|
||||
return GenericMetadata()
|
||||
|
||||
if self.config.Comic_Book_Lover__apply_transform_on_import:
|
||||
if self.config.Metadata_Options__cbl_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:
|
||||
for style in self.config.Runtime_Options__type_modify:
|
||||
# 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())
|
||||
@ -180,19 +179,8 @@ 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")))
|
||||
|
||||
if self.config.Issue_Identifier__auto_imprint:
|
||||
md.fix_publisher()
|
||||
|
||||
match_set.md = md
|
||||
match_set.md = prepare_metadata(md, ct_md, self.config)
|
||||
|
||||
self.actual_metadata_save(ca, md)
|
||||
|
||||
@ -250,7 +238,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__complicated_parser,
|
||||
self.config.Filename_Parsing__filename_parser,
|
||||
self.config.Filename_Parsing__remove_c2c,
|
||||
self.config.Filename_Parsing__remove_fcbd,
|
||||
self.config.Filename_Parsing__remove_publisher,
|
||||
@ -261,22 +249,22 @@ class CLI:
|
||||
|
||||
md.overlay(f_md)
|
||||
|
||||
for style in self.config.Runtime_Options__type:
|
||||
for style in self.config.Runtime_Options__type_read:
|
||||
if ca.has_metadata(style):
|
||||
try:
|
||||
t_md = ca.read_metadata(style)
|
||||
md.overlay(t_md)
|
||||
md.overlay(t_md, self.config.internal__load_data_overlay, self.config.internal__overlay_merge_lists)
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error("Failed to load metadata for %s: %s", ca.path, e)
|
||||
|
||||
# finally, use explicit stuff
|
||||
md.overlay(self.config.Runtime_Options__metadata)
|
||||
# finally, use explicit stuff (always 'overlay' mode)
|
||||
md.overlay(self.config.Runtime_Options__metadata, mode=merge.Mode.OVERLAY, merge_lists=True)
|
||||
|
||||
return md
|
||||
|
||||
def print(self, ca: ComicArchive) -> Result:
|
||||
if not self.config.Runtime_Options__type:
|
||||
if not self.config.Runtime_Options__type_read:
|
||||
page_count = ca.get_number_of_pages()
|
||||
|
||||
brief = ""
|
||||
@ -302,7 +290,7 @@ class CLI:
|
||||
|
||||
md = None
|
||||
for style, style_obj in md_styles.items():
|
||||
if not self.config.Runtime_Options__type or style in self.config.Runtime_Options__type:
|
||||
if not self.config.Runtime_Options__type_read or style in self.config.Runtime_Options__type_read:
|
||||
if ca.has_metadata(style):
|
||||
self.output(f"--------- {style_obj.name()} tags ---------")
|
||||
try:
|
||||
@ -334,7 +322,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:
|
||||
for style in self.config.Runtime_Options__type_modify:
|
||||
status = self.delete_style(ca, style)
|
||||
if status == Status.success:
|
||||
res.tags_deleted.append(style)
|
||||
@ -353,7 +341,7 @@ class CLI:
|
||||
|
||||
src_style_name = md_styles[self.config.Commands__copy].name()
|
||||
if not self.config.Runtime_Options__dryrun:
|
||||
if self.config.Comic_Book_Lover__apply_transform_on_bulk_operation == "cbi":
|
||||
if self.config.Metadata_Options__cbl_apply_transform_on_bulk_operation == "cbi":
|
||||
md = CBLTransformer(md, self.config).apply()
|
||||
|
||||
if ca.write_metadata(md, style):
|
||||
@ -379,7 +367,9 @@ 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:
|
||||
for style in self.config.Runtime_Options__type_modify:
|
||||
if style == src_style_name:
|
||||
continue
|
||||
status = self.copy_style(ca, res.md, style)
|
||||
if status == Status.success:
|
||||
res.tags_written.append(style)
|
||||
@ -387,16 +377,19 @@ class CLI:
|
||||
res.status = status
|
||||
return res
|
||||
|
||||
def save(self, ca: ComicArchive, match_results: OnlineMatchResults) -> Result:
|
||||
def save(self, ca: ComicArchive, match_results: OnlineMatchResults) -> tuple[Result, OnlineMatchResults]:
|
||||
if not self.config.Runtime_Options__overwrite:
|
||||
for style in self.config.Runtime_Options__type:
|
||||
for style in self.config.Runtime_Options__type_modify:
|
||||
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,
|
||||
return (
|
||||
Result(
|
||||
Action.save,
|
||||
original_path=ca.path,
|
||||
status=Status.existing_tags,
|
||||
tags_written=self.config.Runtime_Options__type_modify,
|
||||
),
|
||||
match_results,
|
||||
)
|
||||
|
||||
if self.batch_mode:
|
||||
@ -409,6 +402,8 @@ 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
|
||||
@ -420,25 +415,23 @@ class CLI:
|
||||
Action.save,
|
||||
original_path=ca.path,
|
||||
status=Status.fetch_data_failure,
|
||||
tags_written=self.config.Runtime_Options__type,
|
||||
tags_written=self.config.Runtime_Options__type_modify,
|
||||
)
|
||||
match_results.fetch_data_failures.append(res)
|
||||
return res
|
||||
return res, match_results
|
||||
|
||||
if ct_md is None:
|
||||
if ct_md is None or ct_md.is_empty:
|
||||
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,
|
||||
tags_written=self.config.Runtime_Options__type_modify,
|
||||
)
|
||||
match_results.no_matches.append(res)
|
||||
return res
|
||||
return res, match_results
|
||||
|
||||
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!")
|
||||
@ -447,10 +440,10 @@ class CLI:
|
||||
status=Status.match_failure,
|
||||
original_path=ca.path,
|
||||
match_status=MatchStatus.no_match,
|
||||
tags_written=self.config.Runtime_Options__type,
|
||||
tags_written=self.config.Runtime_Options__type_modify,
|
||||
)
|
||||
match_results.no_matches.append(res)
|
||||
return res
|
||||
return res, match_results
|
||||
|
||||
ii = IssueIdentifier(ca, self.config, self.current_talker())
|
||||
|
||||
@ -458,11 +451,8 @@ 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))
|
||||
# ii.cover_page_index = md.get_cover_page_index_list()[0]
|
||||
# use our overlaid MD to search
|
||||
result, matches = ii.identify(ca, md)
|
||||
|
||||
found_match = False
|
||||
@ -493,10 +483,10 @@ class CLI:
|
||||
original_path=ca.path,
|
||||
online_results=matches,
|
||||
match_status=MatchStatus.low_confidence_match,
|
||||
tags_written=self.config.Runtime_Options__type,
|
||||
tags_written=self.config.Runtime_Options__type_modify,
|
||||
)
|
||||
match_results.low_confidence_matches.append(res)
|
||||
return res
|
||||
return res, match_results
|
||||
|
||||
logger.error("Online search: Multiple good matches. Save aborted")
|
||||
res = Result(
|
||||
@ -505,10 +495,10 @@ class CLI:
|
||||
original_path=ca.path,
|
||||
online_results=matches,
|
||||
match_status=MatchStatus.multiple_match,
|
||||
tags_written=self.config.Runtime_Options__type,
|
||||
tags_written=self.config.Runtime_Options__type_modify,
|
||||
)
|
||||
match_results.multiple_matches.append(res)
|
||||
return res
|
||||
return res, match_results
|
||||
if low_confidence and self.config.Runtime_Options__abort_on_low_confidence:
|
||||
logger.error("Online search: Low confidence match. Save aborted")
|
||||
res = Result(
|
||||
@ -517,10 +507,10 @@ class CLI:
|
||||
original_path=ca.path,
|
||||
online_results=matches,
|
||||
match_status=MatchStatus.low_confidence_match,
|
||||
tags_written=self.config.Runtime_Options__type,
|
||||
tags_written=self.config.Runtime_Options__type_modify,
|
||||
)
|
||||
match_results.low_confidence_matches.append(res)
|
||||
return res
|
||||
return res, match_results
|
||||
if not found_match:
|
||||
logger.error("Online search: No match found. Save aborted")
|
||||
res = Result(
|
||||
@ -529,10 +519,10 @@ class CLI:
|
||||
original_path=ca.path,
|
||||
online_results=matches,
|
||||
match_status=MatchStatus.no_match,
|
||||
tags_written=self.config.Runtime_Options__type,
|
||||
tags_written=self.config.Runtime_Options__type_modify,
|
||||
)
|
||||
match_results.no_matches.append(res)
|
||||
return res
|
||||
return res, match_results
|
||||
|
||||
# we got here, so we have a single match
|
||||
|
||||
@ -545,27 +535,10 @@ class CLI:
|
||||
original_path=ca.path,
|
||||
online_results=matches,
|
||||
match_status=MatchStatus.good_match,
|
||||
tags_written=self.config.Runtime_Options__type,
|
||||
tags_written=self.config.Runtime_Options__type_modify,
|
||||
)
|
||||
match_results.fetch_data_failures.append(res)
|
||||
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()
|
||||
return res, match_results
|
||||
|
||||
res = Result(
|
||||
Action.save,
|
||||
@ -573,16 +546,17 @@ class CLI:
|
||||
original_path=ca.path,
|
||||
online_results=matches,
|
||||
match_status=MatchStatus.good_match,
|
||||
md=md,
|
||||
tags_written=self.config.Runtime_Options__type,
|
||||
md=prepare_metadata(md, ct_md, self.config),
|
||||
tags_written=self.config.Runtime_Options__type_modify,
|
||||
)
|
||||
assert res.md
|
||||
# ok, done building our metadata. time to save
|
||||
if self.actual_metadata_save(ca, md):
|
||||
if self.actual_metadata_save(ca, res.md):
|
||||
match_results.good_matches.append(res)
|
||||
else:
|
||||
res.status = Status.write_failure
|
||||
match_results.write_failures.append(res)
|
||||
return res
|
||||
return res, match_results
|
||||
|
||||
def rename(self, ca: ComicArchive) -> Result:
|
||||
original_path = ca.path
|
||||
@ -601,14 +575,16 @@ class CLI:
|
||||
new_ext = ca.extension()
|
||||
|
||||
renamer = FileRenamer(
|
||||
md,
|
||||
None,
|
||||
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_to_dir
|
||||
renamer.move = self.config.File_Rename__move
|
||||
renamer.move_only = self.config.File_Rename__only_move
|
||||
|
||||
try:
|
||||
new_name = renamer.determine_name(ext=new_ext)
|
||||
@ -628,7 +604,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_to_dir else None)
|
||||
folder = get_rename_dir(ca, self.config.File_Rename__dir if self.config.File_Rename__move else None)
|
||||
|
||||
full_path = folder / new_name
|
||||
|
||||
@ -700,36 +676,38 @@ class CLI:
|
||||
|
||||
return Result(Action.export, Status.success, ca.path, new_file)
|
||||
|
||||
def process_file_cli(self, command: Action, filename: str, match_results: OnlineMatchResults) -> Result:
|
||||
def process_file_cli(
|
||||
self, command: Action, filename: str, match_results: OnlineMatchResults
|
||||
) -> tuple[Result, OnlineMatchResults]:
|
||||
if not os.path.lexists(filename):
|
||||
logger.error("Cannot find %s", filename)
|
||||
return Result(command, Status.read_failure, pathlib.Path(filename))
|
||||
return Result(command, Status.read_failure, pathlib.Path(filename)), match_results
|
||||
|
||||
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)
|
||||
return Result(Action.rename, Status.read_failure, ca.path), match_results
|
||||
|
||||
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)
|
||||
return Result(command, Status.write_permission_failure, ca.path), match_results
|
||||
|
||||
if command == Action.print:
|
||||
return self.print(ca)
|
||||
return self.print(ca), match_results
|
||||
|
||||
elif command == Action.delete:
|
||||
return self.delete(ca)
|
||||
return self.delete(ca), match_results
|
||||
|
||||
elif command == Action.copy is not None:
|
||||
return self.copy(ca)
|
||||
return self.copy(ca), match_results
|
||||
|
||||
elif command == Action.save:
|
||||
return self.save(ca, match_results)
|
||||
|
||||
elif command == Action.rename:
|
||||
return self.rename(ca)
|
||||
return self.rename(ca), match_results
|
||||
|
||||
elif command == Action.export:
|
||||
return self.export(ca)
|
||||
return Result(None, Status.read_failure, ca.path) # type: ignore[arg-type]
|
||||
return self.export(ca), match_results
|
||||
return Result(None, Status.read_failure, ca.path), match_results # type: ignore[arg-type]
|
||||
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import pathlib
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
import settngs
|
||||
@ -55,7 +56,11 @@ 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):
|
||||
if (
|
||||
isinstance(value, str)
|
||||
or isinstance(default, Enum)
|
||||
or (isinstance(setting.type, type) and issubclass(setting.type, Enum))
|
||||
):
|
||||
config.values[setting.group][setting.dest] = setting.type(value)
|
||||
return config
|
||||
|
||||
|
@ -25,7 +25,7 @@ import subprocess
|
||||
|
||||
import settngs
|
||||
|
||||
from comicapi import utils
|
||||
from comicapi import merge, 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, 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""",
|
||||
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""",
|
||||
file=False,
|
||||
)
|
||||
parser.add_setting(
|
||||
@ -160,14 +160,42 @@ 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(
|
||||
"-t",
|
||||
"--type",
|
||||
"--type-modify",
|
||||
metavar=f"{{{','.join(metadata_styles).upper()}}}",
|
||||
default=[],
|
||||
type=metadata_type,
|
||||
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""",
|
||||
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",
|
||||
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.)",
|
||||
file=False,
|
||||
)
|
||||
parser.add_setting(
|
||||
@ -178,7 +206,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="*", file=False)
|
||||
parser.add_setting("files", nargs="*", default=[], file=False)
|
||||
|
||||
|
||||
def register_commands(parser: settngs.Manager) -> None:
|
||||
@ -190,7 +218,8 @@ def register_commands(parser: settngs.Manager) -> None:
|
||||
dest="command",
|
||||
action="store_const",
|
||||
const=Action.print,
|
||||
help="""Print out tag info from file. Specify type\n(via -t) to get only info of that tag type.\n\n""",
|
||||
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""",
|
||||
file=False,
|
||||
)
|
||||
parser.add_setting(
|
||||
@ -199,15 +228,16 @@ def register_commands(parser: settngs.Manager) -> None:
|
||||
dest="command",
|
||||
action="store_const",
|
||||
const=Action.delete,
|
||||
help="Deletes the tag block of specified type (via -t).\n",
|
||||
help="Deletes the tag block of specified type (via --type-modify).\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 -t\n(potentially lossy operation).\n\n",
|
||||
help="Copy the specified source tag block to\ndestination style specified via --type-modify\n(potentially lossy operation).\n\n",
|
||||
file=False,
|
||||
)
|
||||
parser.add_setting(
|
||||
@ -216,7 +246,7 @@ def register_commands(parser: settngs.Manager) -> None:
|
||||
dest="command",
|
||||
action="store_const",
|
||||
const=Action.save,
|
||||
help="Save out tags as specified type (via -t).\nMust specify also at least -o, -f, or -m.\n\n",
|
||||
help="Save out tags as specified type (via --type-modify).\nMust specify also at least -o, -f, or -m.\n\n",
|
||||
file=False,
|
||||
)
|
||||
parser.add_setting(
|
||||
@ -250,6 +280,7 @@ 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,
|
||||
)
|
||||
@ -284,6 +315,9 @@ 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
|
||||
@ -291,16 +325,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:
|
||||
parser.exit(message="Please specify the type to delete with -t\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.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__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__copy:
|
||||
config[0].Commands__command = Action.copy
|
||||
if not config[0].Runtime_Options__type:
|
||||
parser.exit(message="Please specify the type to copy to with -t\n", status=1)
|
||||
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 config[0].Runtime_Options__recursive:
|
||||
config[0].Runtime_Options__files = utils.get_recursive_filelist(config[0].Runtime_Options__files)
|
||||
|
@ -5,6 +5,7 @@ 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
|
||||
|
||||
@ -12,13 +13,6 @@ 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,
|
||||
@ -31,7 +25,10 @@ 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_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("last_opened_folder", default="", cmdline=False)
|
||||
parser.add_setting("window_width", default=0, cmdline=False)
|
||||
parser.add_setting("window_height", default=0, cmdline=False)
|
||||
@ -102,10 +99,12 @@ def dialog(parser: settngs.Manager) -> None:
|
||||
def filename(parser: settngs.Manager) -> None:
|
||||
# filename parsing settings
|
||||
parser.add_setting(
|
||||
"--complicated-parser",
|
||||
default=False,
|
||||
action=argparse.BooleanOptionalAction,
|
||||
help="Enables the new parser which tries to extract more information from filenames",
|
||||
"--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",
|
||||
)
|
||||
parser.add_setting(
|
||||
"--remove-c2c",
|
||||
@ -161,17 +160,25 @@ def talker(parser: settngs.Manager) -> None:
|
||||
)
|
||||
|
||||
|
||||
def cbl(parser: settngs.Manager) -> None:
|
||||
def md_options(parser: settngs.Manager) -> None:
|
||||
# CBL Transform settings
|
||||
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)
|
||||
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",
|
||||
)
|
||||
|
||||
|
||||
def rename(parser: settngs.Manager) -> None:
|
||||
@ -198,11 +205,16 @@ 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,
|
||||
@ -271,6 +283,15 @@ 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
|
||||
|
||||
|
||||
@ -294,7 +315,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("Comic Book Lover", cbl, False)
|
||||
parser.add_group("Metadata Options", md_options, False)
|
||||
parser.add_group("File Rename", rename, False)
|
||||
parser.add_group("Auto-Tag", autotag, False)
|
||||
parser.add_group("General", general, False)
|
||||
|
@ -5,6 +5,8 @@ import typing
|
||||
import settngs
|
||||
|
||||
import comicapi.genericmetadata
|
||||
import comicapi.merge
|
||||
import comicapi.utils
|
||||
import comictaggerlib.ctsettings.types
|
||||
import comictaggerlib.defaults
|
||||
import comictaggerlib.resulttypes
|
||||
@ -20,7 +22,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
|
||||
Runtime_Options__issue_id: str | None
|
||||
Runtime_Options__online: bool
|
||||
Runtime_Options__metadata: comicapi.genericmetadata.GenericMetadata
|
||||
Runtime_Options__interactive: bool
|
||||
@ -33,14 +35,21 @@ class SettngsNS(settngs.TypedNS):
|
||||
Runtime_Options__glob: bool
|
||||
Runtime_Options__quiet: bool
|
||||
Runtime_Options__json: bool
|
||||
Runtime_Options__type: list[str]
|
||||
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__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: 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__last_opened_folder: str
|
||||
internal__window_width: int
|
||||
internal__window_height: int
|
||||
@ -61,7 +70,7 @@ class SettngsNS(settngs.TypedNS):
|
||||
Issue_Identifier__exact_series_matches_first: bool
|
||||
Issue_Identifier__always_use_publisher_filter: bool
|
||||
|
||||
Filename_Parsing__complicated_parser: bool
|
||||
Filename_Parsing__filename_parser: comicapi.utils.Parser
|
||||
Filename_Parsing__remove_c2c: bool
|
||||
Filename_Parsing__remove_fcbd: bool
|
||||
Filename_Parsing__remove_publisher: bool
|
||||
@ -72,22 +81,25 @@ class SettngsNS(settngs.TypedNS):
|
||||
Sources__source: str
|
||||
Sources__remove_html_tables: bool
|
||||
|
||||
Comic_Book_Lover__assume_lone_credit_is_primary: bool
|
||||
Comic_Book_Lover__copy_characters_to_tags: bool
|
||||
Comic_Book_Lover__copy_teams_to_tags: bool
|
||||
Comic_Book_Lover__copy_locations_to_tags: bool
|
||||
Comic_Book_Lover__copy_storyarcs_to_tags: bool
|
||||
Comic_Book_Lover__copy_notes_to_comments: bool
|
||||
Comic_Book_Lover__copy_weblink_to_comments: bool
|
||||
Comic_Book_Lover__apply_transform_on_import: bool
|
||||
Comic_Book_Lover__apply_transform_on_bulk_operation: bool
|
||||
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
|
||||
|
||||
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_to_dir: bool
|
||||
File_Rename__move: bool
|
||||
File_Rename__only_move: bool
|
||||
File_Rename__strict: bool
|
||||
File_Rename__replacements: comictaggerlib.defaults.Replacements
|
||||
|
||||
@ -98,8 +110,6 @@ 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
|
||||
@ -108,8 +118,8 @@ class SettngsNS(settngs.TypedNS):
|
||||
|
||||
Archive__rar: str
|
||||
|
||||
Source_comicvine__comicvine_key: str
|
||||
Source_comicvine__comicvine_url: str
|
||||
Source_comicvine__comicvine_key: str | None
|
||||
Source_comicvine__comicvine_url: str | None
|
||||
Source_comicvine__cv_use_series_start_as_volume: bool
|
||||
|
||||
|
||||
@ -125,7 +135,7 @@ class Runtime_Options(typing.TypedDict):
|
||||
abort_on_conflict: bool
|
||||
delete_original: bool
|
||||
parse_filename: bool
|
||||
issue_id: str
|
||||
issue_id: str | None
|
||||
online: bool
|
||||
metadata: comicapi.genericmetadata.GenericMetadata
|
||||
interactive: bool
|
||||
@ -138,7 +148,11 @@ class Runtime_Options(typing.TypedDict):
|
||||
glob: bool
|
||||
quiet: bool
|
||||
json: bool
|
||||
type: list[str]
|
||||
type_modify: list[str]
|
||||
type_read: list[str]
|
||||
read_style_overlay: comicapi.merge.Mode
|
||||
source_overlay: comicapi.merge.Mode
|
||||
overlay_merge_lists: bool
|
||||
overwrite: bool
|
||||
no_gui: bool
|
||||
files: list[str]
|
||||
@ -147,7 +161,10 @@ class Runtime_Options(typing.TypedDict):
|
||||
class internal(typing.TypedDict):
|
||||
install_id: str
|
||||
save_data_style: list[str]
|
||||
load_data_style: str
|
||||
load_data_style: list[str]
|
||||
load_data_overlay: comicapi.merge.Mode
|
||||
source_data_overlay: comicapi.merge.Mode
|
||||
overlay_merge_lists: bool
|
||||
last_opened_folder: str
|
||||
window_width: int
|
||||
window_height: int
|
||||
@ -172,7 +189,7 @@ class Issue_Identifier(typing.TypedDict):
|
||||
|
||||
|
||||
class Filename_Parsing(typing.TypedDict):
|
||||
complicated_parser: bool
|
||||
filename_parser: comicapi.utils.Parser
|
||||
remove_c2c: bool
|
||||
remove_fcbd: bool
|
||||
remove_publisher: bool
|
||||
@ -186,16 +203,18 @@ class Sources(typing.TypedDict):
|
||||
remove_html_tables: 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 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 File_Rename(typing.TypedDict):
|
||||
@ -204,7 +223,8 @@ class File_Rename(typing.TypedDict):
|
||||
use_smart_string_cleanup: bool
|
||||
auto_extension: bool
|
||||
dir: str
|
||||
move_to_dir: bool
|
||||
move: bool
|
||||
only_move: bool
|
||||
strict: bool
|
||||
replacements: comictaggerlib.defaults.Replacements
|
||||
|
||||
@ -219,8 +239,6 @@ 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
|
||||
|
||||
|
||||
@ -235,8 +253,8 @@ class Archive(typing.TypedDict):
|
||||
|
||||
|
||||
class Source_comicvine(typing.TypedDict):
|
||||
comicvine_key: str
|
||||
comicvine_url: str
|
||||
comicvine_key: str | None
|
||||
comicvine_url: str | None
|
||||
cv_use_series_start_as_volume: bool
|
||||
|
||||
|
||||
@ -249,7 +267,7 @@ SettngsDict = typing.TypedDict(
|
||||
"Issue Identifier": Issue_Identifier,
|
||||
"Filename Parsing": Filename_Parsing,
|
||||
"Sources": Sources,
|
||||
"Comic Book Lover": Comic_Book_Lover,
|
||||
"Metadata Options": Metadata_Options,
|
||||
"File Rename": File_Rename,
|
||||
"Auto-Tag": Auto_Tag,
|
||||
"General": General,
|
||||
|
@ -2,12 +2,95 @@ 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 GenericMetadata
|
||||
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
|
||||
|
||||
|
||||
class ComicTaggerPaths(AppDirs):
|
||||
@ -84,50 +167,78 @@ 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
|
||||
|
||||
example = "series=Kickers^, Inc. ,issue=1, year=1986"
|
||||
"""
|
||||
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]
|
||||
|
||||
escaped_comma = "^,"
|
||||
escaped_equals = "^="
|
||||
replacement_token = "<_~_>"
|
||||
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
|
||||
|
||||
md = GenericMetadata()
|
||||
|
||||
# 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)
|
||||
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 + "}"
|
||||
|
||||
# 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
|
||||
md_dict = yaml.safe_load(mdstr)
|
||||
|
||||
empty = True
|
||||
# Map the dict to the metadata object
|
||||
for key, value in md_dict.items():
|
||||
if not hasattr(md, key):
|
||||
raise argparse.ArgumentTypeError(f"'{key}' is not a valid tag name")
|
||||
else:
|
||||
md.is_empty = False
|
||||
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:
|
||||
raise argparse.ArgumentTypeError(f"'{key}' is not a valid tag name")
|
||||
md.is_empty = empty
|
||||
return md
|
||||
|
@ -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 = rename_dir.strip()
|
||||
folder = pathlib.Path(rename_dir).absolute()
|
||||
rename_dir = pathlib.Path(rename_dir.strip())
|
||||
folder = 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(value))
|
||||
return ", ".join(list(str(v) for v in value))
|
||||
return cast(str, super().convert_field(value, conversion))
|
||||
|
||||
def handle_replacements(self, string: str, replacements: list[Replacement]) -> str:
|
||||
@ -192,9 +192,12 @@ class FileRenamer:
|
||||
self.move = False
|
||||
self.platform = platform
|
||||
self.replacements = replacements
|
||||
self.original_name = ""
|
||||
self.move_only = False
|
||||
|
||||
def set_metadata(self, metadata: GenericMetadata) -> None:
|
||||
def set_metadata(self, metadata: GenericMetadata, original_name: str) -> None:
|
||||
self.metadata = metadata
|
||||
self.original_name = original_name
|
||||
|
||||
def set_issue_zero_padding(self, count: int) -> None:
|
||||
self.issue_zero_padding = count
|
||||
@ -218,6 +221,10 @@ 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)
|
||||
@ -236,9 +243,9 @@ class FileRenamer:
|
||||
).strip()
|
||||
new_name = os.path.join(new_name, new_basename)
|
||||
|
||||
new_name += ext
|
||||
new_basename += ext
|
||||
|
||||
if self.move_only:
|
||||
new_folder = os.path.join(new_name, os.path.splitext(self.original_name)[0])
|
||||
return new_folder + ext
|
||||
if self.move:
|
||||
return new_name.strip()
|
||||
return new_basename.strip()
|
||||
return new_name.strip() + ext
|
||||
return new_basename.strip() + ext
|
||||
|
@ -98,10 +98,14 @@ class FileSelectionList(QtWidgets.QWidget):
|
||||
self.dirty_flag = modified
|
||||
|
||||
def select_all(self) -> None:
|
||||
self.twList.setRangeSelected(QtWidgets.QTableWidgetSelectionRange(0, 0, self.twList.rowCount() - 1, 5), True)
|
||||
self.twList.setRangeSelected(
|
||||
QtWidgets.QTableWidgetSelectionRange(0, 0, self.twList.rowCount() - 1, self.twList.columnCount() - 1), True
|
||||
)
|
||||
|
||||
def deselect_all(self) -> None:
|
||||
self.twList.setRangeSelected(QtWidgets.QTableWidgetSelectionRange(0, 0, self.twList.rowCount() - 1, 5), False)
|
||||
self.twList.setRangeSelected(
|
||||
QtWidgets.QTableWidgetSelectionRange(0, 0, self.twList.rowCount() - 1, self.twList.columnCount() - 1), False
|
||||
)
|
||||
|
||||
def remove_archive_list(self, ca_list: list[ComicArchive]) -> None:
|
||||
self.twList.setSortingEnabled(False)
|
||||
|
BIN
comictaggerlib/graphics/down.png
Normal file
BIN
comictaggerlib/graphics/down.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.1 KiB |
BIN
comictaggerlib/graphics/up.png
Normal file
BIN
comictaggerlib/graphics/up.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.1 KiB |
@ -24,9 +24,14 @@ 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)
|
||||
errorbox.exec()
|
||||
QtWidgets.QApplication.exit(1)
|
||||
if errorbox.exec() == QtWidgets.QMessageBox.StandardButton.Abort:
|
||||
QtWidgets.QApplication.exit(1)
|
||||
else:
|
||||
logger.warning("Exception ignored")
|
||||
else:
|
||||
logger.debug("No QApplication instance available.")
|
||||
|
||||
|
@ -643,7 +643,10 @@ class IssueIdentifier:
|
||||
)
|
||||
final_cover_matching.remove(match)
|
||||
|
||||
best_score = final_cover_matching[0].distance
|
||||
if final_cover_matching:
|
||||
best_score = final_cover_matching[0].distance
|
||||
else:
|
||||
best_score = 0
|
||||
if best_score >= self.min_score_thresh:
|
||||
if len(final_cover_matching) == 1:
|
||||
self.log_msg("No matching pages in the issue.")
|
||||
|
@ -252,7 +252,7 @@ class App:
|
||||
# config already loaded
|
||||
error = None
|
||||
|
||||
if self.config[0].General__disable_cr:
|
||||
if self.config[0].Metadata_Options__disable_cr:
|
||||
if "cr" in comicapi.comicarchive.metadata_styles:
|
||||
del comicapi.comicarchive.metadata_styles["cr"]
|
||||
|
||||
|
41
comictaggerlib/md.py
Normal file
41
comictaggerlib/md.py
Normal file
@ -0,0 +1,41 @@
|
||||
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,
|
||||
)
|
@ -110,6 +110,7 @@ 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
|
||||
|
||||
@ -135,6 +136,21 @@ 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)
|
||||
@ -232,14 +248,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.UserRole)[0])
|
||||
self.chkDoublePage.setChecked("double_page" in self.listWidget.item(row).data(QtCore.Qt.ItemDataRole.UserRole))
|
||||
|
||||
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"])
|
||||
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"])
|
||||
else:
|
||||
self.leBookmark.setText("")
|
||||
|
||||
idx = int(self.listWidget.item(row).data(QtCore.Qt.ItemDataRole.UserRole)[0]["image_index"])
|
||||
idx = int(self.listWidget.item(row).data(QtCore.Qt.ItemDataRole.UserRole)["image_index"])
|
||||
|
||||
if self.comic_archive is not None:
|
||||
self.pageWidget.set_archive(self.comic_archive, idx)
|
||||
@ -248,7 +264,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)[0]
|
||||
page_dict: ImageMetadata = item.data(QtCore.Qt.ItemDataRole.UserRole)
|
||||
if "type" in page_dict and page_dict["type"] == PageType.FrontCover:
|
||||
front_cover = int(page_dict["image_index"])
|
||||
break
|
||||
@ -256,51 +272,53 @@ 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)[0]
|
||||
page_dict: ImageMetadata = self.listWidget.item(row).data(QtCore.Qt.ItemDataRole.UserRole)
|
||||
if "type" in page_dict:
|
||||
return page_dict["type"]
|
||||
|
||||
return ""
|
||||
|
||||
def set_current_page_type(self, t: str) -> None:
|
||||
row = self.listWidget.currentRow()
|
||||
page_dict: ImageMetadata = self.listWidget.item(row).data(QtCore.Qt.ItemDataRole.UserRole)[0]
|
||||
rows = self.listWidget.selectionModel().selectedRows()
|
||||
for index in rows:
|
||||
row = index.row()
|
||||
page_dict: ImageMetadata = self.listWidget.item(row).data(QtCore.Qt.ItemDataRole.UserRole)
|
||||
|
||||
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)
|
||||
# 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))
|
||||
item = self.listWidget.item(row)
|
||||
item.setData(QtCore.Qt.ItemDataRole.UserRole, page_dict)
|
||||
item.setText(self.list_entry_text(page_dict))
|
||||
|
||||
def toggle_double_page(self) -> None:
|
||||
row = self.listWidget.currentRow()
|
||||
page_dict: ImageMetadata = self.listWidget.item(row).data(QtCore.Qt.UserRole)[0]
|
||||
rows = self.listWidget.selectionModel().selectedRows()
|
||||
for index in rows:
|
||||
row = index.row()
|
||||
page_dict: ImageMetadata = self.listWidget.item(row).data(QtCore.Qt.ItemDataRole.UserRole)
|
||||
|
||||
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
|
||||
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()
|
||||
elif "double_page" in page_dict:
|
||||
del page_dict["double_page"]
|
||||
self.modified.emit()
|
||||
|
||||
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))
|
||||
item = self.listWidget.item(row)
|
||||
item.setData(QtCore.Qt.ItemDataRole.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.UserRole)[0]
|
||||
page_dict: ImageMetadata = self.listWidget.item(row).data(QtCore.Qt.ItemDataRole.UserRole)
|
||||
|
||||
current_bookmark = ""
|
||||
if "bookmark" in page_dict:
|
||||
@ -316,8 +334,7 @@ class PageListEditor(QtWidgets.QWidget):
|
||||
self.modified.emit()
|
||||
|
||||
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.setData(QtCore.Qt.ItemDataRole.UserRole, page_dict)
|
||||
item.setText(self.list_entry_text(page_dict))
|
||||
|
||||
self.listWidget.setFocus()
|
||||
@ -337,8 +354,7 @@ class PageListEditor(QtWidgets.QWidget):
|
||||
self.listWidget.clear()
|
||||
for p in pages_list:
|
||||
item = QtWidgets.QListWidgetItem(self.list_entry_text(p))
|
||||
# wrap the dict in a tuple to keep from being converted to QtWidgets.QStrings
|
||||
item.setData(QtCore.Qt.ItemDataRole.UserRole, (p,))
|
||||
item.setData(QtCore.Qt.ItemDataRole.UserRole, p)
|
||||
|
||||
self.listWidget.addItem(item)
|
||||
self.first_front_page = self.get_first_front_cover()
|
||||
@ -362,7 +378,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)[0])
|
||||
page_list.append(item.data(QtCore.Qt.ItemDataRole.UserRole))
|
||||
return page_list
|
||||
|
||||
def emit_front_cover_change(self) -> None:
|
||||
|
@ -39,7 +39,7 @@ class RenameWindow(QtWidgets.QDialog):
|
||||
self,
|
||||
parent: QtWidgets.QWidget,
|
||||
comic_archive_list: list[ComicArchive],
|
||||
data_style: str,
|
||||
load_data_styles: list[str],
|
||||
config: settngs.Config[ct_ns],
|
||||
talkers: dict[str, ComicTalker],
|
||||
) -> None:
|
||||
@ -48,7 +48,9 @@ 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 {metadata_styles[data_style].name()} tags):")
|
||||
self.label.setText(
|
||||
f"Preview (based on {', '.join(metadata_styles[style].name() for style in load_data_styles)} tags):"
|
||||
)
|
||||
|
||||
self.setWindowFlags(
|
||||
QtCore.Qt.WindowType(
|
||||
@ -61,7 +63,7 @@ class RenameWindow(QtWidgets.QDialog):
|
||||
self.config = config
|
||||
self.talkers = talkers
|
||||
self.comic_archive_list = comic_archive_list
|
||||
self.data_style = data_style
|
||||
self.load_data_styles = load_data_styles
|
||||
self.rename_list: list[str] = []
|
||||
|
||||
self.btnSettings.clicked.connect(self.modify_settings)
|
||||
@ -70,27 +72,36 @@ class RenameWindow(QtWidgets.QDialog):
|
||||
|
||||
self.do_preview()
|
||||
|
||||
def config_renamer(self, ca: ComicArchive, md: GenericMetadata | None = None) -> str:
|
||||
def config_renamer(self, ca: ComicArchive, md: GenericMetadata = GenericMetadata()) -> 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:
|
||||
md = ca.read_metadata(self.data_style)
|
||||
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_empty:
|
||||
md = ca.metadata_from_filename(
|
||||
self.config[0].Filename_Parsing__complicated_parser,
|
||||
self.config[0].Filename_Parsing__filename_parser,
|
||||
self.config[0].Filename_Parsing__remove_c2c,
|
||||
self.config[0].Filename_Parsing__remove_fcbd,
|
||||
self.config[0].Filename_Parsing__remove_publisher,
|
||||
)
|
||||
self.renamer.set_metadata(md)
|
||||
self.renamer.move = self.config[0].File_Rename__move_to_dir
|
||||
self.renamer.set_metadata(md, ca.path.name)
|
||||
self.renamer.move = self.config[0].File_Rename__move
|
||||
return new_ext
|
||||
|
||||
def do_preview(self) -> None:
|
||||
@ -192,7 +203,7 @@ class RenameWindow(QtWidgets.QDialog):
|
||||
|
||||
folder = get_rename_dir(
|
||||
comic[0],
|
||||
self.config[0].File_Rename__dir if self.config[0].File_Rename__move_to_dir else None,
|
||||
self.config[0].File_Rename__dir if self.config[0].File_Rename__move else None,
|
||||
)
|
||||
|
||||
full_path = folder / comic[1]
|
||||
|
@ -2,51 +2,11 @@ from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import pathlib
|
||||
import sys
|
||||
from enum import Enum, auto
|
||||
from typing import Any
|
||||
from enum import auto
|
||||
|
||||
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:
|
||||
@ -69,7 +29,8 @@ 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(StrEnum):
|
||||
class Action(utils.StrEnum):
|
||||
gui = auto()
|
||||
print = auto()
|
||||
delete = auto()
|
||||
copy = auto()
|
||||
@ -80,14 +41,14 @@ class Action(StrEnum):
|
||||
list_plugins = auto()
|
||||
|
||||
|
||||
class MatchStatus(StrEnum):
|
||||
class MatchStatus(utils.StrEnum):
|
||||
good_match = auto()
|
||||
no_match = auto()
|
||||
multiple_match = auto()
|
||||
low_confidence_match = auto()
|
||||
|
||||
|
||||
class Status(StrEnum):
|
||||
class Status(utils.StrEnum):
|
||||
success = auto()
|
||||
match_failure = auto()
|
||||
write_failure = auto()
|
||||
|
@ -247,11 +247,6 @@ 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)
|
||||
|
@ -28,7 +28,7 @@ import settngs
|
||||
from PyQt5 import QtCore, QtGui, QtWidgets, uic
|
||||
|
||||
import comictaggerlib.ui.talkeruigenerator
|
||||
from comicapi import utils
|
||||
from comicapi import merge, utils
|
||||
from comicapi.archivers.archiver import Archiver
|
||||
from comicapi.genericmetadata import md_test
|
||||
from comictaggerlib import ctsettings
|
||||
@ -192,6 +192,11 @@ 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()
|
||||
@ -208,8 +213,9 @@ 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.cbxComplicatedParser.clicked.connect(self.switch_parser)
|
||||
self.cbFilenameParser.currentIndexChanged.connect(self.switch_parser)
|
||||
|
||||
self.btnAddLiteralReplacement.clicked.connect(self.addLiteralReplacement)
|
||||
self.btnAddValueReplacement.clicked.connect(self.addValueReplacement)
|
||||
@ -218,6 +224,7 @@ 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)
|
||||
@ -244,8 +251,9 @@ class SettingsWindow(QtWidgets.QDialog):
|
||||
self.btnResetSettings.clicked.disconnect()
|
||||
self.btnTemplateHelp.clicked.disconnect()
|
||||
self.cbxChangeExtension.clicked.disconnect()
|
||||
self.cbxComplicatedParser.clicked.disconnect()
|
||||
self.cbFilenameParser.currentIndexChanged.disconnect()
|
||||
self.cbxMoveFiles.clicked.disconnect()
|
||||
self.cbxMoveOnly.clicked.disconnect()
|
||||
self.cbxRenameStrict.clicked.disconnect()
|
||||
self.cbxSmartCleanup.clicked.disconnect()
|
||||
self.leDirectory.textEdited.disconnect()
|
||||
@ -273,9 +281,10 @@ 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,
|
||||
complicated_parser=self.cbxComplicatedParser.isChecked(),
|
||||
parser=utils.Parser(self.cbFilenameParser.currentText()),
|
||||
remove_c2c=self.cbxRemoveC2C.isChecked(),
|
||||
remove_fcbd=self.cbxRemoveFCBD.isChecked(),
|
||||
remove_publisher=self.cbxRemovePublisher.isChecked(),
|
||||
@ -333,19 +342,31 @@ 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() else ""
|
||||
str(pathlib.Path(self.leDirectory.text().strip()).resolve())
|
||||
if self.cbxMoveFiles.isChecked() or self.cbxMoveOnly.isChecked()
|
||||
else ""
|
||||
)
|
||||
|
||||
def _rename_test(self, template: str) -> None:
|
||||
if not str(self.leIssueNumPadding.text()).isdigit():
|
||||
self.leIssueNumPadding.setText("0")
|
||||
fr = FileRenamer(
|
||||
md_test,
|
||||
None,
|
||||
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()))
|
||||
@ -358,11 +379,14 @@ class SettingsWindow(QtWidgets.QDialog):
|
||||
self.lblRenameTest.setText(str(e))
|
||||
|
||||
def switch_parser(self) -> None:
|
||||
complicated = self.cbxComplicatedParser.isChecked()
|
||||
currentParser = utils.Parser(self.cbFilenameParser.currentText())
|
||||
|
||||
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:
|
||||
@ -378,9 +402,8 @@ 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.cbxComplicatedParser.setChecked(self.config[0].Filename_Parsing__complicated_parser)
|
||||
self.cbFilenameParser.setCurrentText(self.config[0].Filename_Parsing__filename_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)
|
||||
@ -396,23 +419,33 @@ 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].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.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.cbxApplyCBLTransformOnBatchOperation.setChecked(
|
||||
self.config[0].Comic_Book_Lover__apply_transform_on_bulk_operation
|
||||
self.config[0].Metadata_Options__cbl_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_to_dir)
|
||||
self.cbxMoveFiles.setChecked(self.config[0].File_Rename__move)
|
||||
self.cbxMoveOnly.setChecked(self.config[0].File_Rename__only_move)
|
||||
self.leDirectory.setText(self.config[0].File_Rename__dir)
|
||||
self.cbxRenameStrict.setChecked(self.config[0].File_Rename__strict)
|
||||
|
||||
@ -497,17 +530,11 @@ 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__complicated_parser = self.cbxComplicatedParser.isChecked()
|
||||
self.config[0].Filename_Parsing__filename_parser = utils.Parser(self.cbFilenameParser.currentText())
|
||||
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()
|
||||
@ -521,23 +548,36 @@ 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].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.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.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_to_dir = self.cbxMoveFiles.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__dir = self.leDirectory.text()
|
||||
|
||||
self.config[0].File_Rename__strict = self.cbxRenameStrict.isChecked()
|
||||
|
@ -25,14 +25,13 @@ import platform
|
||||
import re
|
||||
import sys
|
||||
import webbrowser
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable
|
||||
from urllib.parse import urlparse
|
||||
from typing import Any, Callable, cast
|
||||
|
||||
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
|
||||
@ -53,18 +52,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, IssueResult, MatchStatus, OnlineMatchResults, Result, Status
|
||||
from comictaggerlib.resulttypes import Action, 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__)
|
||||
|
||||
@ -112,7 +111,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
|
||||
"alternate_count": self.leAltIssueCount,
|
||||
"imprint": self.leImprint,
|
||||
"notes": self.teNotes,
|
||||
"web_link": self.leWebLink,
|
||||
"web_links": (self.leWebLink, self.btnOpenWebLink, self.btnAddWebLink, self.btnRemoveWebLink),
|
||||
"format": self.cbFormat,
|
||||
"manga": self.cbManga,
|
||||
"black_and_white": self.cbBW,
|
||||
@ -124,7 +123,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,
|
||||
@ -213,18 +212,28 @@ class TaggerWindow(QtWidgets.QMainWindow):
|
||||
|
||||
self.setWindowIcon(QtGui.QIcon(str(graphics_path / "app.png")))
|
||||
|
||||
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]
|
||||
# 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
|
||||
|
||||
for style in config[0].internal__save_data_style:
|
||||
if style not in metadata_styles:
|
||||
config[0].internal__save_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]
|
||||
for style in config[0].internal__load_data_style:
|
||||
if style not in metadata_styles:
|
||||
config[0].internal__load_data_style.remove(style)
|
||||
self.save_data_styles: list[str] = config[0].internal__save_data_style
|
||||
self.load_data_style: str = config[0].internal__load_data_style
|
||||
self.load_data_styles: list[str] = config[0].internal__load_data_style
|
||||
|
||||
self.setAcceptDrops(True)
|
||||
self.view_tag_actions, self.remove_tag_actions = self.tag_actions()
|
||||
@ -273,7 +282,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
|
||||
self.cbMaturityRating.lineEdit().setAcceptDrops(False)
|
||||
|
||||
# hook up the callbacks
|
||||
self.cbLoadDataStyle.currentIndexChanged.connect(self.set_load_data_style)
|
||||
self.cbLoadDataStyle.dropdownClosed.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)
|
||||
@ -435,7 +444,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
|
||||
self.actionAutoTag.triggered.connect(self.auto_tag)
|
||||
|
||||
self.actionCopyTags.setShortcut("Ctrl+C")
|
||||
self.actionCopyTags.setStatusTip("Copy one tag style to another")
|
||||
self.actionCopyTags.setStatusTip("Copy one tag style tags to enabled modify style(s)")
|
||||
self.actionCopyTags.triggered.connect(self.copy_tags)
|
||||
|
||||
self.actionRemoveAuto.setShortcut("Ctrl+D")
|
||||
@ -532,6 +541,31 @@ 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
|
||||
@ -784,6 +818,8 @@ 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():
|
||||
@ -810,6 +846,8 @@ 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():
|
||||
@ -820,7 +858,10 @@ 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:
|
||||
field.setText(str(value))
|
||||
if isinstance(field, QtWidgets.QTextEdit) and False:
|
||||
field.setPlainText(str(value))
|
||||
else:
|
||||
field.setText(str(value))
|
||||
|
||||
md = self.metadata
|
||||
|
||||
@ -844,7 +885,9 @@ 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)
|
||||
assign_text(self.leWebLink, md.web_link)
|
||||
self.leWebLink.clear()
|
||||
for u in md.web_links:
|
||||
self.add_weblink_item(u.url)
|
||||
assign_text(self.teCharacters, "\n".join(md.characters))
|
||||
assign_text(self.teTeams, "\n".join(md.teams))
|
||||
assign_text(self.teLocations, "\n".join(md.locations))
|
||||
@ -899,12 +942,10 @@ 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"] if "primary" in credit else False)
|
||||
)
|
||||
self.add_new_credit_entry(row, credit.role.title(), credit.person, credit.primary)
|
||||
|
||||
self.twCredits.setSortingEnabled(True)
|
||||
self.update_metadata_credit_colors()
|
||||
@ -967,7 +1008,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_link = utils.xlate(self.leWebLink.text())
|
||||
md.web_links = [utils.parse_url(self.leWebLink.item(i).text()) for i in range(self.leWebLink.count())]
|
||||
md.characters = set(utils.split(self.teCharacters.toPlainText(), "\n"))
|
||||
md.teams = set(utils.split(self.teTeams.toPlainText(), "\n"))
|
||||
md.locations = set(utils.split(self.teLocations.toPlainText(), "\n"))
|
||||
@ -1004,7 +1045,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__complicated_parser,
|
||||
self.config[0].Filename_Parsing__filename_parser,
|
||||
self.config[0].Filename_Parsing__remove_c2c,
|
||||
self.config[0].Filename_Parsing__remove_fcbd,
|
||||
self.config[0].Filename_Parsing__remove_publisher,
|
||||
@ -1012,7 +1053,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)
|
||||
self.metadata.overlay(new_metadata, mode=comicapi.merge.Mode.OVERLAY, merge_lists=False)
|
||||
self.metadata_to_form()
|
||||
|
||||
def use_filename_split(self) -> None:
|
||||
@ -1117,33 +1158,18 @@ class TaggerWindow(QtWidgets.QMainWindow):
|
||||
except TalkerError as e:
|
||||
QtWidgets.QApplication.restoreOverrideCursor()
|
||||
QtWidgets.QMessageBox.critical(self, f"{e.source} {e.code_name} Error", f"{e}")
|
||||
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()
|
||||
return
|
||||
QtWidgets.QApplication.restoreOverrideCursor()
|
||||
|
||||
if self.config[0].Issue_Identifier__clear_metadata:
|
||||
self.clear_form()
|
||||
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
|
||||
|
||||
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"
|
||||
)
|
||||
self.metadata = prepare_metadata(self.metadata, new_metadata, self.config[0])
|
||||
# Now push the new combined data into the edit controls
|
||||
self.metadata_to_form()
|
||||
|
||||
def commit_metadata(self) -> None:
|
||||
if self.metadata is not None and self.comic_archive is not None:
|
||||
@ -1171,7 +1197,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
|
||||
failed_style = metadata_styles[style].name()
|
||||
break
|
||||
|
||||
self.comic_archive.load_cache(list(metadata_styles))
|
||||
self.comic_archive.load_cache(set(metadata_styles))
|
||||
QtWidgets.QApplication.restoreOverrideCursor()
|
||||
|
||||
if failed_style:
|
||||
@ -1184,26 +1210,37 @@ class TaggerWindow(QtWidgets.QMainWindow):
|
||||
self.clear_dirty_flag()
|
||||
self.update_info_box()
|
||||
self.update_menus()
|
||||
self.fileSelectionList.update_current_row()
|
||||
|
||||
self.metadata = self.comic_archive.read_metadata(self.load_data_style)
|
||||
# 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.update_ui_for_archive()
|
||||
else:
|
||||
QtWidgets.QMessageBox.information(self, "Whoops!", "No data to commit!")
|
||||
|
||||
def set_load_data_style(self, s: str) -> None:
|
||||
def set_load_data_style(self, load_data_styles: list[str]) -> None:
|
||||
"""Should only be called from the combobox signal"""
|
||||
if self.dirty_flag_verification(
|
||||
"Change Tag Read Style", "If you change read tag style now, data in the form will be lost. Are you sure?"
|
||||
"Change Tag Read Style",
|
||||
"If you change read tag style(s) now, data in the form will be lost. Are you sure?",
|
||||
):
|
||||
self.load_data_style = self.cbLoadDataStyle.itemData(s)
|
||||
self.config[0].internal__load_data_style = self.load_data_style
|
||||
self.load_data_styles = list(reversed(load_data_styles))
|
||||
self.config[0].internal__load_data_style = self.load_data_styles
|
||||
self.update_menus()
|
||||
if self.comic_archive is not None:
|
||||
self.load_archive(self.comic_archive)
|
||||
else:
|
||||
self.cbLoadDataStyle.currentIndexChanged.disconnect(self.set_load_data_style)
|
||||
self.cbLoadDataStyle.itemChanged.disconnect()
|
||||
self.adjust_load_style_combo()
|
||||
self.cbLoadDataStyle.currentIndexChanged.connect(self.set_load_data_style)
|
||||
self.cbLoadDataStyle.itemChanged.connect(self.set_load_data_style)
|
||||
|
||||
def set_save_data_style(self) -> None:
|
||||
self.save_data_styles = self.cbSaveDataStyle.currentData()
|
||||
@ -1343,14 +1380,17 @@ class TaggerWindow(QtWidgets.QMainWindow):
|
||||
self.set_dirty_flag()
|
||||
|
||||
def open_web_link(self) -> None:
|
||||
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."))
|
||||
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.")
|
||||
|
||||
def show_settings(self) -> None:
|
||||
settingswin = SettingsWindow(self, self.config, self.talkers)
|
||||
@ -1372,28 +1412,38 @@ 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 current style
|
||||
self.cbLoadDataStyle.setCurrentIndex(self.cbLoadDataStyle.findData(self.load_data_style))
|
||||
"""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)
|
||||
|
||||
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.cbLoadDataStyle.findData(style), True)
|
||||
self.cbSaveDataStyle.setItemChecked(self.cbSaveDataStyle.findData(style), True)
|
||||
for style in unchecked:
|
||||
self.cbSaveDataStyle.setItemChecked(self.cbLoadDataStyle.findData(style), False)
|
||||
self.cbSaveDataStyle.setItemChecked(self.cbSaveDataStyle.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():
|
||||
self.cbLoadDataStyle.addItem(style.name(), style.short_name)
|
||||
if self.config[0].General__use_short_metadata_names:
|
||||
if self.config[0].Metadata_Options__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()
|
||||
@ -1560,7 +1610,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
|
||||
# Abandon any further tag removals to prevent any greater damage to archive
|
||||
break
|
||||
ca.reset_cache()
|
||||
ca.load_cache(list(metadata_styles))
|
||||
ca.load_cache(set(metadata_styles))
|
||||
|
||||
progdialog.hide()
|
||||
QtCore.QCoreApplication.processEvents()
|
||||
@ -1584,12 +1634,12 @@ class TaggerWindow(QtWidgets.QMainWindow):
|
||||
ca_list = self.fileSelectionList.get_selected_archive_list()
|
||||
has_src_count = 0
|
||||
|
||||
src_style = self.load_data_style
|
||||
dest_styles = self.save_data_styles
|
||||
src_styles: list[str] = self.load_data_styles
|
||||
dest_styles: list[str] = self.save_data_styles
|
||||
|
||||
# Remove the read style from the write style
|
||||
if src_style in dest_styles:
|
||||
dest_styles.remove(src_style)
|
||||
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])
|
||||
|
||||
if not dest_styles:
|
||||
QtWidgets.QMessageBox.information(
|
||||
@ -1598,12 +1648,16 @@ class TaggerWindow(QtWidgets.QMainWindow):
|
||||
return
|
||||
|
||||
for ca in ca_list:
|
||||
if ca.has_metadata(src_style):
|
||||
has_src_count += 1
|
||||
for style in src_styles:
|
||||
if ca.has_metadata(style):
|
||||
has_src_count += 1
|
||||
continue
|
||||
|
||||
if has_src_count == 0:
|
||||
QtWidgets.QMessageBox.information(
|
||||
self, "Copy Tags", f"No archives with {metadata_styles[src_style].name()} tags selected!"
|
||||
self,
|
||||
"Copy Tags",
|
||||
f"No archives with {', '.join([metadata_styles[style].name() for style in src_styles])} tags selected!",
|
||||
)
|
||||
return
|
||||
|
||||
@ -1616,8 +1670,9 @@ class TaggerWindow(QtWidgets.QMainWindow):
|
||||
reply = QtWidgets.QMessageBox.question(
|
||||
self,
|
||||
"Copy Tags",
|
||||
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"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"{has_src_count} archive(s)?",
|
||||
QtWidgets.QMessageBox.StandardButton.Yes,
|
||||
QtWidgets.QMessageBox.StandardButton.No,
|
||||
@ -1635,10 +1690,11 @@ class TaggerWindow(QtWidgets.QMainWindow):
|
||||
success_count = 0
|
||||
for prog_idx, ca in enumerate(ca_list, 1):
|
||||
ca_saved = False
|
||||
|
||||
if ca.has_metadata(src_style) and ca.is_writable():
|
||||
md = ca.read_metadata(src_style)
|
||||
else:
|
||||
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:
|
||||
continue
|
||||
|
||||
for style in dest_styles:
|
||||
@ -1652,7 +1708,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
|
||||
center_window_on_parent(prog_dialog)
|
||||
QtCore.QCoreApplication.processEvents()
|
||||
|
||||
if style == "cbi" and self.config[0].Comic_Book_Lover__apply_transform_on_bulk_operation:
|
||||
if style == "cbi" and self.config[0].Metadata_Options__cbl_apply_transform_on_bulk_operation:
|
||||
md = CBLTransformer(md, self.config[0]).apply()
|
||||
|
||||
if ca.write_metadata(md, style):
|
||||
@ -1663,7 +1719,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
|
||||
failed_list.append(ca.path)
|
||||
|
||||
ca.reset_cache()
|
||||
ca.load_cache([self.load_data_style, *self.save_data_styles])
|
||||
ca.load_cache({*self.load_data_styles, *self.save_data_styles})
|
||||
|
||||
prog_dialog.hide()
|
||||
QtCore.QCoreApplication.processEvents()
|
||||
@ -1682,24 +1738,6 @@ 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())
|
||||
@ -1725,14 +1763,19 @@ class TaggerWindow(QtWidgets.QMainWindow):
|
||||
ii = IssueIdentifier(ca, self.config[0], self.current_talker())
|
||||
|
||||
# read in metadata, and parse file name if not there
|
||||
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)
|
||||
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
|
||||
|
||||
if md.is_empty:
|
||||
md = ca.metadata_from_filename(
|
||||
self.config[0].Filename_Parsing__complicated_parser,
|
||||
self.config[0].Filename_Parsing__filename_parser,
|
||||
self.config[0].Filename_Parsing__remove_c2c,
|
||||
self.config[0].Filename_Parsing__remove_fcbd,
|
||||
self.config[0].Filename_Parsing__remove_publisher,
|
||||
@ -1759,10 +1802,8 @@ 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)
|
||||
@ -1839,8 +1880,18 @@ 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
|
||||
ct_md = self.actual_issue_data_fetch(matches[0])
|
||||
if ct_md is None:
|
||||
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:
|
||||
match_results.fetch_data_failures.append(
|
||||
Result(
|
||||
Action.save,
|
||||
@ -1852,17 +1903,11 @@ 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():
|
||||
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")))
|
||||
temp_opts.Issue_Identifier__clear_metadata
|
||||
|
||||
if self.config[0].Issue_Identifier__auto_imprint:
|
||||
md.fix_publisher()
|
||||
md = prepare_metadata(md, ct_md, temp_opts)
|
||||
|
||||
res = Result(
|
||||
Action.save,
|
||||
@ -1884,7 +1929,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
|
||||
match_results.write_failures.append(res)
|
||||
|
||||
ca.reset_cache()
|
||||
ca.load_cache([self.load_data_style] + self.save_data_styles)
|
||||
ca.load_cache({*self.load_data_styles, *self.save_data_styles})
|
||||
|
||||
return success, match_results
|
||||
|
||||
@ -1933,7 +1978,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_style).get_cover_page_index_list()[0]
|
||||
cover_idx = ca.read_metadata(self.load_data_styles[0]).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)
|
||||
@ -2004,7 +2049,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
|
||||
self,
|
||||
match_results.multiple_matches,
|
||||
styles,
|
||||
self.actual_issue_data_fetch,
|
||||
lambda match: self.current_talker().fetch_comic_data(match.issue_id),
|
||||
self.config[0],
|
||||
self.current_talker(),
|
||||
)
|
||||
@ -2128,7 +2173,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_style, self.config, self.talkers)
|
||||
dlg = RenameWindow(self, ca_list, self.load_data_styles, self.config, self.talkers)
|
||||
dlg.setModal(True)
|
||||
if dlg.exec() and self.comic_archive is not None:
|
||||
self.fileSelectionList.update_selected_rows()
|
||||
@ -2137,6 +2182,7 @@ 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
|
||||
@ -2146,15 +2192,32 @@ 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
|
||||
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.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")
|
||||
|
||||
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()
|
||||
|
||||
|
@ -2,10 +2,21 @@
|
||||
|
||||
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, QRect, Qt, pyqtSignal
|
||||
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()
|
||||
|
||||
|
||||
# Multiselect combobox from: https://gis.stackexchange.com/a/351152 (with custom changes)
|
||||
@ -112,3 +123,309 @@ 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)
|
||||
|
@ -28,6 +28,9 @@
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="horizontalScrollBarPolicy">
|
||||
<enum>Qt::ScrollBarAlwaysOff</enum>
|
||||
</property>
|
||||
<property name="dragEnabled">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
@ -88,7 +91,10 @@
|
||||
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||
<item>
|
||||
<layout class="QGridLayout" name="gridLayout_1">
|
||||
<item row="0" column="0">
|
||||
<item row="1" column="1">
|
||||
<widget class="QComboBox" name="cbPageType"/>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="lblPageType">
|
||||
<property name="text">
|
||||
<string>Page Type:</string>
|
||||
@ -98,16 +104,20 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QComboBox" name="cbPageType"/>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<item row="1" column="2">
|
||||
<widget class="QCheckBox" name="chkDoublePage">
|
||||
<property name="text">
|
||||
<string>&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>
|
||||
|
@ -6,6 +6,7 @@ import io
|
||||
import logging
|
||||
import traceback
|
||||
import webbrowser
|
||||
from collections.abc import Sequence
|
||||
|
||||
from PyQt5.QtCore import QUrl
|
||||
from PyQt5.QtWidgets import QWidget
|
||||
@ -155,7 +156,7 @@ if qt_available:
|
||||
active_palette = None
|
||||
|
||||
def enable_widget(widget: QtWidgets.QWidget | list[QtWidgets.QWidget], enable: bool) -> None:
|
||||
if isinstance(widget, list):
|
||||
if isinstance(widget, Sequence):
|
||||
for w in widget:
|
||||
_enable_widget(w, enable)
|
||||
else:
|
||||
@ -214,6 +215,8 @@ 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(
|
||||
|
@ -7,7 +7,7 @@
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>703</width>
|
||||
<height>574</height>
|
||||
<height>597</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
@ -28,7 +28,7 @@
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="currentIndex">
|
||||
<number>0</number>
|
||||
<number>4</number>
|
||||
</property>
|
||||
<widget class="QWidget" name="tGeneral">
|
||||
<attribute name="title">
|
||||
@ -48,43 +48,7 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<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 "short" 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">
|
||||
<item row="2" column="1">
|
||||
<widget class="QLabel" name="lblDefaultSettings">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||
@ -98,9 +62,12 @@
|
||||
<property name="wordWrap">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="buddy">
|
||||
<cstring>btnResetSettings</cstring>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="1">
|
||||
<item row="3" column="1">
|
||||
<widget class="QLabel" name="label_2">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||
@ -114,15 +81,44 @@
|
||||
<property name="wordWrap">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="buddy">
|
||||
<cstring>btnClearCache</cstring>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<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>
|
||||
<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>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
@ -173,6 +169,9 @@
|
||||
<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">
|
||||
@ -183,6 +182,9 @@
|
||||
<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">
|
||||
@ -190,6 +192,9 @@
|
||||
<property name="text">
|
||||
<string>Always use Publisher Filter on "manual" searches:</string>
|
||||
</property>
|
||||
<property name="buddy">
|
||||
<cstring>cbxUseFilter</cstring>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
@ -207,6 +212,9 @@
|
||||
<property name="text">
|
||||
<string>Publisher Filter:</string>
|
||||
</property>
|
||||
<property name="buddy">
|
||||
<cstring>tePublisherFilter</cstring>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="1">
|
||||
@ -308,9 +316,19 @@
|
||||
<widget class="QGroupBox" name="groupBox_2">
|
||||
<layout class="QVBoxLayout" name="verticalLayout_7">
|
||||
<item>
|
||||
<widget class="QCheckBox" name="cbxComplicatedParser">
|
||||
<widget class="QLabel" name="lblFilenamearser">
|
||||
<property name="text">
|
||||
<string>Use "Complicated" Parser</string>
|
||||
<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>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
@ -399,19 +417,124 @@
|
||||
</attribute>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_4"/>
|
||||
</widget>
|
||||
<widget class="QWidget" name="tMDType">
|
||||
<widget class="QWidget" name="tMDOptions">
|
||||
<attribute name="title">
|
||||
<string>Metadata Types</string>
|
||||
<string>Metadata Options</string>
|
||||
</attribute>
|
||||
<layout class="QGridLayout" name="gridLayout_6">
|
||||
<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>
|
||||
<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>
|
||||
</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 "short" 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="4" column="0">
|
||||
<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">
|
||||
<widget class="QGroupBox" name="groupBox">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="MinimumExpanding">
|
||||
@ -422,99 +545,359 @@
|
||||
<property name="title">
|
||||
<string>CBL Transforms</string>
|
||||
</property>
|
||||
<widget class="QWidget" name="layoutWidget">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>11</x>
|
||||
<y>21</y>
|
||||
<width>251</width>
|
||||
<height>206</height>
|
||||
</rect>
|
||||
<layout class="QGridLayout" name="gridLayout_9">
|
||||
<property name="leftMargin">
|
||||
<number>6</number>
|
||||
</property>
|
||||
<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>
|
||||
</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="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>
|
||||
<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>
|
||||
<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="3" column="0">
|
||||
<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>
|
||||
</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>
|
||||
</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 "Merge Lists" check box. Merge will replace duplicates within the list with the "new" 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>
|
||||
</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 "new" list replaces the old</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Merge Lists</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="8" 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>10</height>
|
||||
<height>40</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">
|
||||
@ -546,6 +929,9 @@
|
||||
<property name="text">
|
||||
<string>Template:</string>
|
||||
</property>
|
||||
<property name="buddy">
|
||||
<cstring>leRenameTemplate</cstring>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
@ -576,6 +962,9 @@
|
||||
<property name="text">
|
||||
<string>Issue # Zero Padding</string>
|
||||
</property>
|
||||
<property name="buddy">
|
||||
<cstring>leIssueNumPadding</cstring>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="1">
|
||||
@ -624,17 +1013,20 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="9" column="0">
|
||||
<item row="11" 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="9" column="1">
|
||||
<item row="11" column="1">
|
||||
<widget class="QLineEdit" name="leDirectory"/>
|
||||
</item>
|
||||
<item row="8" column="0">
|
||||
<item row="9" 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.<br/>By default only removes restricted characters and filenames for the current Operating System.</string>
|
||||
@ -644,9 +1036,16 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="10" column="1">
|
||||
<item row="12" 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">
|
||||
@ -734,6 +1133,9 @@
|
||||
<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">
|
||||
@ -741,6 +1143,9 @@
|
||||
<property name="text">
|
||||
<string>Literal Text Replacements</string>
|
||||
</property>
|
||||
<property name="buddy">
|
||||
<cstring>twLiteralReplacements</cstring>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
@ -776,6 +1181,9 @@
|
||||
<property name="text">
|
||||
<string>RAR program</string>
|
||||
</property>
|
||||
<property name="buddy">
|
||||
<cstring>leRarExePath</cstring>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
|
@ -7,7 +7,7 @@
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>1096</width>
|
||||
<height>658</height>
|
||||
<height>660</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="sizePolicy">
|
||||
@ -76,7 +76,11 @@
|
||||
<widget class="QComboBox" name="cbx_sources"/>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QComboBox" name="cbLoadDataStyle"/>
|
||||
<widget class="CheckableOrderComboBox" name="cbLoadDataStyle">
|
||||
<property name="toolTip">
|
||||
<string>At least one read style must be selected</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="CheckableComboBox" name="cbSaveDataStyle"/>
|
||||
@ -958,10 +962,32 @@
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<layout class="QGridLayout" name="gridLayout_7">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLineEdit" name="leWebLink">
|
||||
<property name="acceptDrops">
|
||||
<bool>false</bool>
|
||||
<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>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
@ -981,6 +1007,13 @@
|
||||
</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">
|
||||
@ -1187,7 +1220,7 @@
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>1096</width>
|
||||
<height>28</height>
|
||||
<height>30</height>
|
||||
</rect>
|
||||
</property>
|
||||
<widget class="QMenu" name="menuComicTagger">
|
||||
@ -1477,6 +1510,16 @@
|
||||
<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>
|
||||
@ -1485,7 +1528,45 @@
|
||||
<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/>
|
||||
<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>
|
||||
</ui>
|
||||
|
@ -37,6 +37,8 @@ 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
|
||||
|
@ -33,6 +33,7 @@ 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
|
||||
@ -643,10 +644,15 @@ 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:
|
||||
|
11
setup.cfg
11
setup.cfg
@ -37,6 +37,7 @@ 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
|
||||
@ -44,9 +45,10 @@ install_requires =
|
||||
pathvalidate
|
||||
pillow>=9.1.0,<10
|
||||
pyrate-limiter>=2.6,<3
|
||||
pyyaml
|
||||
rapidfuzz>=2.12.0
|
||||
requests==2.*
|
||||
settngs==0.10.0
|
||||
settngs==0.10.2
|
||||
text2digits
|
||||
typing-extensions>=4.3.0
|
||||
wordninja
|
||||
@ -86,7 +88,7 @@ QTW =
|
||||
all =
|
||||
PyQt5
|
||||
PyQtWebEngine
|
||||
comicinfoxml
|
||||
comicinfoxml>=0.2.0
|
||||
gcd-talker>=0.1.0
|
||||
metron-talker>=0.1.5
|
||||
pillow-avif-plugin>=1.4.1
|
||||
@ -96,7 +98,7 @@ all =
|
||||
avif =
|
||||
pillow-avif-plugin>=1.4.1
|
||||
cix =
|
||||
comicinfoxml
|
||||
comicinfoxml>=0.2.0
|
||||
gcd =
|
||||
gcd-talker>=0.1.0
|
||||
metron =
|
||||
@ -302,7 +304,7 @@ deps =
|
||||
|
||||
[flake8]
|
||||
max-line-length = 120
|
||||
extend-ignore = E203, E501, A003, T202, E701
|
||||
extend-ignore = E203, E501, A003, A005, T202, E701
|
||||
extend-exclude = venv, scripts, build, dist, comictaggerlib/ctversion.py
|
||||
per-file-ignores =
|
||||
comictaggerlib/cli.py: T20
|
||||
@ -316,6 +318,7 @@ 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
|
||||
|
@ -55,25 +55,142 @@ 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.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.md_test.copy(),
|
||||
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 = [
|
||||
@ -98,7 +215,10 @@ credits = [
|
||||
(comicapi.genericmetadata.md_test, "writeR", "Dara Naraghi"),
|
||||
(
|
||||
comicapi.genericmetadata.md_test.replace(
|
||||
credits=[{"person": "Dara Naraghi", "role": "writer"}, {"person": "Dara Naraghi", "role": "writer"}]
|
||||
credits=[
|
||||
comicapi.genericmetadata.Credit(person="Dara Naraghi", role="writer"),
|
||||
comicapi.genericmetadata.Credit(person="Dara Naraghi", role="writer"),
|
||||
]
|
||||
),
|
||||
"writeR",
|
||||
"Dara Naraghi",
|
||||
@ -135,3 +255,30 @@ 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]",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
@ -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_link=cv_issue_result["results"]["site_detail_url"],
|
||||
web_links=[comicapi.genericmetadata.parse_url(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_link=cv_issue_result["results"]["site_detail_url"],
|
||||
web_links=[comicapi.genericmetadata.parse_url(cv_issue_result["results"]["site_detail_url"])],
|
||||
format=None,
|
||||
manga=None,
|
||||
black_and_white=None,
|
||||
|
@ -438,6 +438,21 @@ 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",
|
||||
@ -1032,10 +1047,11 @@ for p in names:
|
||||
)
|
||||
)
|
||||
|
||||
rnames = [
|
||||
file_renames = [
|
||||
(
|
||||
"{series!c} {price} {year}", # Capitalize
|
||||
False,
|
||||
False,
|
||||
"universal",
|
||||
"Cory doctorow's futuristic tales of the here and now 2007.cbz",
|
||||
does_not_raise(),
|
||||
@ -1043,6 +1059,7 @@ rnames = [
|
||||
(
|
||||
"{series!t} {price} {year}", # Title Case
|
||||
False,
|
||||
False,
|
||||
"universal",
|
||||
"Cory Doctorow'S Futuristic Tales Of The Here And Now 2007.cbz",
|
||||
does_not_raise(),
|
||||
@ -1050,6 +1067,7 @@ rnames = [
|
||||
(
|
||||
"{series!S} {price} {year}", # Swap Case
|
||||
False,
|
||||
False,
|
||||
"universal",
|
||||
"cORY dOCTOROW'S fUTURISTIC tALES OF THE hERE AND nOW 2007.cbz",
|
||||
does_not_raise(),
|
||||
@ -1057,6 +1075,7 @@ rnames = [
|
||||
(
|
||||
"{title!l} {price} {year}", # Lowercase
|
||||
False,
|
||||
False,
|
||||
"universal",
|
||||
"anda's game 2007.cbz",
|
||||
does_not_raise(),
|
||||
@ -1064,6 +1083,7 @@ rnames = [
|
||||
(
|
||||
"{title!u} {price} {year}", # Upper Case
|
||||
False,
|
||||
False,
|
||||
"universal",
|
||||
"ANDA'S GAME 2007.cbz",
|
||||
does_not_raise(),
|
||||
@ -1071,6 +1091,7 @@ rnames = [
|
||||
(
|
||||
"{title} {price} {year+}", # Empty alternate value
|
||||
False,
|
||||
False,
|
||||
"universal",
|
||||
"Anda's Game.cbz",
|
||||
does_not_raise(),
|
||||
@ -1078,6 +1099,7 @@ rnames = [
|
||||
(
|
||||
"{title} {price} {year+year!u}", # Alternate value Upper Case
|
||||
False,
|
||||
False,
|
||||
"universal",
|
||||
"Anda's Game YEAR.cbz",
|
||||
does_not_raise(),
|
||||
@ -1085,6 +1107,7 @@ rnames = [
|
||||
(
|
||||
"{title} {price} {year+year}", # Alternate Value
|
||||
False,
|
||||
False,
|
||||
"universal",
|
||||
"Anda's Game year.cbz",
|
||||
does_not_raise(),
|
||||
@ -1092,6 +1115,7 @@ rnames = [
|
||||
(
|
||||
"{title} {price-0} {year}", # Default value
|
||||
False,
|
||||
False,
|
||||
"universal",
|
||||
"Anda's Game 0 2007.cbz",
|
||||
does_not_raise(),
|
||||
@ -1099,6 +1123,7 @@ rnames = [
|
||||
(
|
||||
"{title} {price+0} {year}", # Alternate Value
|
||||
False,
|
||||
False,
|
||||
"universal",
|
||||
"Anda's Game 2007.cbz",
|
||||
does_not_raise(),
|
||||
@ -1106,6 +1131,7 @@ rnames = [
|
||||
(
|
||||
"{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(),
|
||||
@ -1113,6 +1139,7 @@ rnames = [
|
||||
(
|
||||
"{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(),
|
||||
@ -1120,6 +1147,7 @@ rnames = [
|
||||
(
|
||||
"{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(),
|
||||
@ -1127,6 +1155,7 @@ rnames = [
|
||||
(
|
||||
"{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(),
|
||||
@ -1134,6 +1163,7 @@ rnames = [
|
||||
(
|
||||
"{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(),
|
||||
@ -1141,6 +1171,7 @@ rnames = [
|
||||
(
|
||||
"{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(),
|
||||
@ -1148,6 +1179,15 @@ rnames = [
|
||||
(
|
||||
"{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(),
|
||||
@ -1155,6 +1195,7 @@ rnames = [
|
||||
(
|
||||
"{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(),
|
||||
@ -1162,6 +1203,7 @@ rnames = [
|
||||
(
|
||||
"{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(),
|
||||
@ -1169,6 +1211,7 @@ rnames = [
|
||||
(
|
||||
"{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(),
|
||||
@ -1176,6 +1219,7 @@ rnames = [
|
||||
(
|
||||
"{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(),
|
||||
@ -1183,6 +1227,7 @@ rnames = [
|
||||
(
|
||||
"{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(),
|
||||
@ -1190,6 +1235,7 @@ rnames = [
|
||||
(
|
||||
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(),
|
||||
@ -1197,6 +1243,7 @@ rnames = [
|
||||
(
|
||||
"{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(),
|
||||
@ -1204,6 +1251,7 @@ rnames = [
|
||||
(
|
||||
"{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(),
|
||||
@ -1211,6 +1259,7 @@ rnames = [
|
||||
(
|
||||
"{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(),
|
||||
@ -1218,6 +1267,7 @@ rnames = [
|
||||
(
|
||||
"{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(),
|
||||
@ -1225,6 +1275,7 @@ rnames = [
|
||||
(
|
||||
"{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(),
|
||||
@ -1232,6 +1283,7 @@ rnames = [
|
||||
(
|
||||
"{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(),
|
||||
@ -1239,6 +1291,7 @@ rnames = [
|
||||
(
|
||||
"{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(),
|
||||
@ -1246,6 +1299,7 @@ rnames = [
|
||||
(
|
||||
"{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(),
|
||||
@ -1253,6 +1307,7 @@ rnames = [
|
||||
(
|
||||
"{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(),
|
||||
@ -1260,15 +1315,24 @@ rnames = [
|
||||
(
|
||||
"{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(),
|
||||
),
|
||||
]
|
||||
|
||||
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"),
|
||||
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"),
|
||||
]
|
||||
|
@ -139,7 +139,7 @@ def mock_now(monkeypatch):
|
||||
def now(cls):
|
||||
return cls.time
|
||||
|
||||
monkeypatch.setattr(comictaggerlib.cli, "datetime", mydatetime)
|
||||
monkeypatch.setattr(comictaggerlib.md, "datetime", mydatetime)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
@ -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=[])),
|
||||
)
|
||||
|
||||
|
||||
|
@ -1,9 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
|
||||
import comicapi.genericmetadata
|
||||
from testing.comicdata import credits, metadata
|
||||
import comicapi.merge
|
||||
import testing.comicdata
|
||||
|
||||
|
||||
def test_apply_default_page_list(tmp_path):
|
||||
@ -15,13 +18,25 @@ def test_apply_default_page_list(tmp_path):
|
||||
assert isinstance(md.pages[0]["image_index"], int)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("replaced, expected", metadata)
|
||||
def test_metadata_overlay(md: comicapi.genericmetadata.GenericMetadata, replaced, expected):
|
||||
md.overlay(replaced)
|
||||
@pytest.mark.parametrize("md, new, expected", testing.comicdata.metadata)
|
||||
def test_metadata_overlay(md, new, expected):
|
||||
md.overlay(new, comicapi.merge.Mode.OVERLAY)
|
||||
|
||||
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()
|
||||
|
||||
@ -37,6 +52,49 @@ def test_add_credit_primary():
|
||||
assert md.credits == [comicapi.genericmetadata.Credit(person="test", role="writer", primary=True)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("md, role, expected", credits)
|
||||
@pytest.mark.parametrize("md, role, expected", testing.comicdata.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
|
||||
|
@ -39,8 +39,9 @@ def test_save(
|
||||
config[0].Runtime_Options__online = True
|
||||
# Use the temporary comic we created
|
||||
config[0].Runtime_Options__files = [tmp_comic.path]
|
||||
# Save ComicRack tags
|
||||
config[0].Runtime_Options__type = ["cr"]
|
||||
# Read and save ComicRack tags
|
||||
config[0].Runtime_Options__type_read = ["cr"]
|
||||
config[0].Runtime_Options__type_modify = ["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
|
||||
@ -53,11 +54,11 @@ def test_save(
|
||||
# unrelated to comicvine need to be re-worked
|
||||
md_saved.credits.insert(
|
||||
1,
|
||||
{
|
||||
"person": "Esteve Polls",
|
||||
"primary": False,
|
||||
"role": "Writer",
|
||||
},
|
||||
comicapi.genericmetadata.Credit(
|
||||
person="Esteve Polls",
|
||||
primary=False,
|
||||
role="Writer",
|
||||
),
|
||||
)
|
||||
|
||||
# Validate that we got the correct metadata back
|
||||
@ -89,7 +90,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 = ["cr"]
|
||||
config[0].Runtime_Options__type_modify = ["cr"]
|
||||
# Run ComicTagger
|
||||
CLI(config[0], talkers).run()
|
||||
|
||||
|
@ -4,6 +4,8 @@ import pytest
|
||||
from importlib_metadata import entry_points
|
||||
|
||||
import comicapi.genericmetadata
|
||||
import testing.comicdata
|
||||
from comictaggerlib.md import prepare_metadata
|
||||
|
||||
metadata_styles = []
|
||||
|
||||
@ -38,3 +40,9 @@ 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
|
||||
|
@ -6,18 +6,20 @@ import pytest
|
||||
|
||||
from comicapi.genericmetadata import md_test
|
||||
from comictaggerlib import filerenamer
|
||||
from testing.filenames import rfnames, rnames
|
||||
from testing.filenames import file_renames, folder_names
|
||||
|
||||
|
||||
@pytest.mark.parametrize("template, move, platform, expected, exception", rnames)
|
||||
def test_rename(template, platform, move, expected, exception):
|
||||
fr = filerenamer.FileRenamer(md_test, platform=platform)
|
||||
@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")
|
||||
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", rfnames)
|
||||
@pytest.mark.parametrize("inp, result", folder_names)
|
||||
def test_get_rename_dir(inp, result, cbz):
|
||||
assert result(cbz) == filerenamer.get_rename_dir(cbz, inp)
|
||||
assert result() == filerenamer.get_rename_dir(cbz, inp)
|
||||
|
Loading…
x
Reference in New Issue
Block a user