Compare commits
18 Commits
1.4.4-beta
...
1.5.0
Author | SHA1 | Date | |
---|---|---|---|
904561fb8e | |||
be6b71dec7 | |||
63b654a173 | |||
bc25acde9f | |||
03677ce4b8 | |||
535afcb4c6 | |||
06255f7848 | |||
00e649bb4c | |||
078f569ec6 | |||
315cf7d920 | |||
e9cc6a16a8 | |||
26eb6985fe | |||
be983c61bc | |||
77a53a6834 | |||
860a3147d2 | |||
8ecb87fa26 | |||
f17f560705 | |||
aadeb07c49 |
16
.github/workflows/build.yaml
vendored
16
.github/workflows/build.yaml
vendored
@ -80,11 +80,25 @@ jobs:
|
||||
run: |
|
||||
choco install -y zip
|
||||
if: runner.os == 'Windows'
|
||||
- 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"
|
||||
python -m pip install --no-binary=:pyicu: pyicu
|
||||
if: runner.os == 'macOS'
|
||||
- name: Install linux dependencies
|
||||
run: |
|
||||
sudo apt-get install pkg-config libicu-dev
|
||||
export PKG_CONFIG_PATH="/usr/local/opt/icu4c/lib/pkgconfig";
|
||||
export PATH="/usr/local/opt/icu4c/bin:/usr/local/opt/icu4c/sbin:$PATH"
|
||||
python -m pip install --no-binary=:pyicu: pyicu
|
||||
if: runner.os == 'Linux'
|
||||
|
||||
- name: Build and install PyPi packages
|
||||
run: |
|
||||
make clean pydist
|
||||
python -m pip install "dist/$(python setup.py --fullname)-py3-none-any.whl[GUI,CBR]"
|
||||
python -m pip install "dist/$(python setup.py --fullname)-py3-none-any.whl[all]"
|
||||
|
||||
- name: build
|
||||
run: |
|
||||
|
19
.github/workflows/package.yaml
vendored
19
.github/workflows/package.yaml
vendored
@ -41,12 +41,25 @@ jobs:
|
||||
run: |
|
||||
choco install -y zip
|
||||
if: runner.os == 'Windows'
|
||||
- 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"
|
||||
python -m pip install --no-binary=:pyicu: pyicu
|
||||
if: runner.os == 'macOS'
|
||||
- name: Install linux dependencies
|
||||
run: |
|
||||
sudo apt-get install pkg-config libicu-dev
|
||||
export PKG_CONFIG_PATH="/usr/local/opt/icu4c/lib/pkgconfig";
|
||||
export PATH="/usr/local/opt/icu4c/bin:/usr/local/opt/icu4c/sbin:$PATH"
|
||||
python -m pip install --no-binary=:pyicu: pyicu
|
||||
if: runner.os == 'Linux'
|
||||
|
||||
- name: Build, Install and Test PyPi packages
|
||||
run: |
|
||||
make clean pydist
|
||||
python -m pip install "dist/$(python setup.py --fullname)-py3-none-any.whl[GUI,CBR]"
|
||||
echo "CT_FULL_NAME=$(python setup.py --fullname)" >> $GITHUB_ENV
|
||||
python -m pip install "dist/$(python setup.py --fullname)-py3-none-any.whl[all]"
|
||||
python -m flake8
|
||||
python -m pytest
|
||||
|
||||
@ -69,4 +82,4 @@ jobs:
|
||||
draft: false
|
||||
files: |
|
||||
dist/!(*Linux).zip
|
||||
dist/*.whl
|
||||
dist/*${{ fromJSON('["never", ""]')[runner.os == 'Linux'] }}.whl
|
||||
|
@ -1,7 +1,7 @@
|
||||
exclude: ^scripts
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.2.0
|
||||
rev: v4.3.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
@ -10,7 +10,7 @@ repos:
|
||||
- id: name-tests-test
|
||||
- id: requirements-txt-fixer
|
||||
- repo: https://github.com/asottile/setup-cfg-fmt
|
||||
rev: v1.20.1
|
||||
rev: v2.0.0
|
||||
hooks:
|
||||
- id: setup-cfg-fmt
|
||||
- repo: https://github.com/PyCQA/isort
|
||||
@ -19,12 +19,12 @@ repos:
|
||||
- id: isort
|
||||
args: [--af,--add-import, 'from __future__ import annotations']
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v2.32.1
|
||||
rev: v2.37.3
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [--py39-plus]
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 22.3.0
|
||||
rev: 22.6.0
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: https://github.com/PyCQA/autoflake
|
||||
@ -33,12 +33,12 @@ repos:
|
||||
- id: autoflake
|
||||
args: [-i]
|
||||
- repo: https://github.com/PyCQA/flake8
|
||||
rev: 4.0.1
|
||||
rev: 5.0.4
|
||||
hooks:
|
||||
- id: flake8
|
||||
additional_dependencies: [flake8-encodings, flake8-warnings, flake8-builtins, flake8-eradicate, flake8-length, flake8-print]
|
||||
additional_dependencies: [flake8-encodings, flake8-warnings, flake8-builtins, flake8-length, flake8-print]
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v0.960
|
||||
rev: v0.971
|
||||
hooks:
|
||||
- id: mypy
|
||||
additional_dependencies: [types-setuptools, types-requests]
|
||||
|
@ -2,6 +2,7 @@
|
||||
[](https://github.com/comictagger/comictagger/releases/latest)
|
||||
[](https://pypi.org/project/comictagger/)
|
||||
[](https://pypistats.org/packages/comictagger)
|
||||
[](https://community.chocolatey.org/packages/comictagger)
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
|
||||
[](https://github.com/comictagger/comictagger/discussions)
|
||||
@ -48,6 +49,12 @@ A pip package is provided, you can install it with:
|
||||
|
||||
There are two optional dependencies GUI and CBR. You can install the optional dependencies by specifying one or more of `GUI`,`CBR` or `all` in braces e.g. `comictagger[CBR,GUI]`
|
||||
|
||||
### Chocolatey installation (Windows only)
|
||||
|
||||
A [Chocolatey package](https://community.chocolatey.org/packages/comictagger), maintained by @Xav83, is provided, you can install it with:
|
||||
```powershell
|
||||
choco install comictagger
|
||||
```
|
||||
### From source
|
||||
|
||||
1. Ensure you have python 3.9 installed
|
||||
|
@ -114,7 +114,7 @@ class SevenZipArchiver(UnknownArchiver):
|
||||
return False
|
||||
|
||||
def read_file(self, archive_file: str) -> bytes:
|
||||
data = bytes()
|
||||
data = b""
|
||||
try:
|
||||
with py7zr.SevenZipFile(self.path, "r") as zf:
|
||||
data = zf.read(archive_file)[archive_file].read()
|
||||
@ -148,7 +148,7 @@ class SevenZipArchiver(UnknownArchiver):
|
||||
def get_filename_list(self) -> list[str]:
|
||||
try:
|
||||
with py7zr.SevenZipFile(self.path, "r") as zf:
|
||||
namelist: list[str] = zf.getnames()
|
||||
namelist: list[str] = [file.filename for file in zf.list() if not file.is_directory]
|
||||
|
||||
return namelist
|
||||
except (py7zr.Bad7zFile, OSError) as e:
|
||||
@ -248,7 +248,7 @@ class ZipArchiver(UnknownArchiver):
|
||||
def get_filename_list(self) -> list[str]:
|
||||
try:
|
||||
with zipfile.ZipFile(self.path, mode="r") as zf:
|
||||
namelist = zf.namelist()
|
||||
namelist = [file.filename for file in zf.infolist() if not file.is_dir()]
|
||||
return namelist
|
||||
except (zipfile.BadZipfile, OSError) as e:
|
||||
logger.error("Error listing files in zip archive [%s]: %s", e, self.path)
|
||||
@ -422,7 +422,7 @@ class RarArchiver(UnknownArchiver):
|
||||
|
||||
rarc = self.get_rar_obj()
|
||||
if rarc is None:
|
||||
return bytes()
|
||||
return b""
|
||||
|
||||
tries = 0
|
||||
while tries < 7:
|
||||
@ -665,7 +665,7 @@ class FolderArchiver(UnknownArchiver):
|
||||
|
||||
|
||||
class ComicArchive:
|
||||
logo_data = bytes()
|
||||
logo_data = b""
|
||||
|
||||
class ArchiveType:
|
||||
SevenZip, Zip, Rar, Folder, Pdf, Unknown = list(range(6))
|
||||
@ -683,7 +683,7 @@ class ComicArchive:
|
||||
self._has_cbi: bool | None = None
|
||||
self._has_cix: bool | None = None
|
||||
self._has_comet: bool | None = None
|
||||
self.path = pathlib.Path(path)
|
||||
self.path = pathlib.Path(path).absolute()
|
||||
self.page_count: int | None = None
|
||||
self.page_list: list[str] = []
|
||||
|
||||
@ -853,13 +853,13 @@ class ComicArchive:
|
||||
return retcode
|
||||
|
||||
def get_page(self, index: int) -> bytes:
|
||||
image_data = bytes()
|
||||
image_data = b""
|
||||
|
||||
filename = self.get_page_name(index)
|
||||
|
||||
if filename:
|
||||
try:
|
||||
image_data = self.archiver.read_file(filename) or bytes()
|
||||
image_data = self.archiver.read_file(filename) or b""
|
||||
except Exception:
|
||||
logger.error("Error reading in page %d. Substituting logo page.", index)
|
||||
image_data = ComicArchive.logo_data
|
||||
@ -934,7 +934,7 @@ class ComicArchive:
|
||||
# seems like some archive creators are on Windows, and don't know about case-sensitivity!
|
||||
if sort_list:
|
||||
|
||||
files = cast(list[str], natsort.natsorted(files, alg=natsort.ns.IC | natsort.ns.I | natsort.ns.U))
|
||||
files = cast(list[str], natsort.os_sorted(files))
|
||||
|
||||
# make a sub-list of image files
|
||||
self.page_list = []
|
||||
@ -1033,7 +1033,7 @@ class ComicArchive:
|
||||
raw_cix = self.archiver.read_file(self.ci_xml_filename) or b""
|
||||
except Exception as e:
|
||||
logger.error("Error reading in raw CIX! for %s: %s", self.path, e)
|
||||
raw_cix = bytes()
|
||||
raw_cix = b""
|
||||
return raw_cix
|
||||
|
||||
def write_cix(self, metadata: GenericMetadata) -> bool:
|
||||
|
@ -24,7 +24,8 @@ import logging
|
||||
import os
|
||||
import re
|
||||
from operator import itemgetter
|
||||
from typing import Callable, Match, TypedDict
|
||||
from re import Match
|
||||
from typing import Callable, TypedDict
|
||||
from urllib.parse import unquote
|
||||
|
||||
from text2digits import text2digits
|
||||
@ -67,17 +68,10 @@ class FileNameParser:
|
||||
|
||||
# replace any name separators with spaces
|
||||
tmpstr = self.fix_spaces(filename)
|
||||
found = False
|
||||
|
||||
match = re.search(r"(?<=\sof\s)\d+(?=\s)", tmpstr, re.IGNORECASE)
|
||||
match = re.search(r"(?:\s\(?of\s)(\d+)(?: |\))", tmpstr, re.IGNORECASE)
|
||||
if match:
|
||||
count = match.group()
|
||||
found = True
|
||||
|
||||
if not found:
|
||||
match = re.search(r"(?<=\(of\s)\d+(?=\))", tmpstr, re.IGNORECASE)
|
||||
if match:
|
||||
count = match.group()
|
||||
count = match.group(1)
|
||||
|
||||
return count.lstrip("0")
|
||||
|
||||
@ -180,10 +174,12 @@ class FileNameParser:
|
||||
if "--" in filename:
|
||||
# the pattern seems to be that anything to left of the first "--" is the series name followed by issue
|
||||
filename = re.sub(r"--.*", self.repl, filename)
|
||||
# never happens
|
||||
|
||||
elif "__" in filename:
|
||||
# the pattern seems to be that anything to left of the first "__" is the series name followed by issue
|
||||
filename = re.sub(r"__.*", self.repl, filename)
|
||||
# never happens
|
||||
|
||||
filename = filename.replace("+", " ")
|
||||
tmpstr = self.fix_spaces(filename, remove_dashes=False)
|
||||
@ -425,6 +421,7 @@ def parse(p: Parser) -> Callable[[Parser], Callable | None] | None:
|
||||
likely_year = False
|
||||
if p.firstItem and p.first_is_alt:
|
||||
p.alt = True
|
||||
p.firstItem = False
|
||||
return parse_issue_number
|
||||
|
||||
# The issue number should hopefully not be in parentheses
|
||||
@ -440,9 +437,6 @@ def parse(p: Parser) -> Callable[[Parser], Callable | None] | None:
|
||||
# Series has already been started/parsed,
|
||||
# filters out leading alternate numbers leading alternate number
|
||||
if len(p.series_parts) > 0:
|
||||
# Unset first item
|
||||
if p.firstItem:
|
||||
p.firstItem = False
|
||||
return parse_issue_number
|
||||
else:
|
||||
p.operator_rejected.append(item)
|
||||
@ -506,9 +500,6 @@ def parse(p: Parser) -> Callable[[Parser], Callable | None] | None:
|
||||
p.series_parts.append(item)
|
||||
p.used_items.append(item)
|
||||
|
||||
# Unset first item
|
||||
if p.firstItem:
|
||||
p.firstItem = False
|
||||
p.get()
|
||||
return parse_series
|
||||
|
||||
@ -590,6 +581,8 @@ def parse(p: Parser) -> Callable[[Parser], Callable | None] | None:
|
||||
if series_append:
|
||||
p.series_parts.append(item)
|
||||
p.used_items.append(item)
|
||||
if p.firstItem:
|
||||
p.firstItem = False
|
||||
return parse_series
|
||||
|
||||
# We found text, it's probably the title or series
|
||||
@ -602,6 +595,8 @@ def parse(p: Parser) -> Callable[[Parser], Callable | None] | None:
|
||||
|
||||
# Usually the word 'of' eg 1 (of 6)
|
||||
elif item.typ == filenamelexer.ItemType.InfoSpecifier:
|
||||
if p.firstItem:
|
||||
p.firstItem = False
|
||||
return parse_info_specifier
|
||||
|
||||
# Operator is a symbol that acts as some sort of separator eg - : ;
|
||||
@ -622,9 +617,13 @@ def parse(p: Parser) -> Callable[[Parser], Callable | None] | None:
|
||||
p.irrelevant.extend([item, p.input[p.pos], p.get()])
|
||||
else:
|
||||
p.backup()
|
||||
if p.firstItem:
|
||||
p.firstItem = False
|
||||
return parse_series
|
||||
# This is text that just happens to also be a month/day
|
||||
else:
|
||||
if p.firstItem:
|
||||
p.firstItem = False
|
||||
return parse_series
|
||||
|
||||
# Specifically '__' or '--', no further title/series parsing is done to keep compatibility with wiki
|
||||
@ -853,10 +852,11 @@ def resolve_year(p: Parser) -> None:
|
||||
p.used_items.append(vol)
|
||||
|
||||
# Remove volume from series and title
|
||||
if selected_year in p.series_parts:
|
||||
p.series_parts.remove(selected_year)
|
||||
if selected_year in p.title_parts:
|
||||
p.title_parts.remove(selected_year)
|
||||
# note: this never happens
|
||||
if vol in p.series_parts:
|
||||
p.series_parts.remove(vol)
|
||||
if vol in p.title_parts:
|
||||
p.title_parts.remove(vol)
|
||||
|
||||
# Remove year from series and title
|
||||
if selected_year in p.series_parts:
|
||||
|
@ -93,7 +93,7 @@ class GenericMetadata:
|
||||
comments: str | None = None # use same way as Summary in CIX
|
||||
|
||||
volume_count: int | None = None
|
||||
critical_rating: float | None = None # rating in cbl; CommunityRating in CIX
|
||||
critical_rating: float | None = None # rating in CBL; CommunityRating in CIX
|
||||
country: str | None = None
|
||||
|
||||
alternate_series: str | None = None
|
||||
|
@ -19,11 +19,11 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import unicodedata
|
||||
from collections import defaultdict
|
||||
from collections.abc import Mapping
|
||||
from shutil import which # noqa: F401
|
||||
from typing import Any, Mapping
|
||||
from typing import Any
|
||||
|
||||
import pycountry
|
||||
import thefuzz.fuzz
|
||||
@ -57,22 +57,20 @@ def get_recursive_filelist(pathlist: list[str]) -> list[str]:
|
||||
|
||||
if os.path.isdir(p):
|
||||
filelist.extend(x for x in glob.glob(f"{p}{os.sep}/**", recursive=True) if not os.path.isdir(x))
|
||||
else:
|
||||
filelist.append(p)
|
||||
elif str(p) not in filelist:
|
||||
filelist.append(str(p))
|
||||
|
||||
return filelist
|
||||
|
||||
|
||||
def add_to_path(dirname: str) -> None:
|
||||
if dirname is not None and dirname != "":
|
||||
if dirname:
|
||||
dirname = os.path.abspath(dirname)
|
||||
paths = [os.path.normpath(x) for x in os.environ["PATH"].split(os.pathsep)]
|
||||
|
||||
# verify that path doesn't already contain the given dirname
|
||||
tmpdirname = re.escape(dirname)
|
||||
pattern = r"(^|{sep}){dir}({sep}|$)".format(dir=tmpdirname, sep=os.pathsep)
|
||||
|
||||
match = re.search(pattern, os.environ["PATH"])
|
||||
if not match:
|
||||
os.environ["PATH"] = dirname + os.pathsep + os.environ["PATH"]
|
||||
if dirname not in paths:
|
||||
paths.insert(0, dirname)
|
||||
os.environ["PATH"] = os.pathsep.join(paths)
|
||||
|
||||
|
||||
def xlate(data: Any, is_int: bool = False, is_float: bool = False) -> Any:
|
||||
@ -123,13 +121,9 @@ def remove_articles(text: str) -> str:
|
||||
"the",
|
||||
"the",
|
||||
"with",
|
||||
"ms",
|
||||
"mrs",
|
||||
"mr",
|
||||
"dr",
|
||||
]
|
||||
new_text = ""
|
||||
for word in text.split(" "):
|
||||
for word in text.split():
|
||||
if word not in articles:
|
||||
new_text += word + " "
|
||||
|
||||
@ -141,19 +135,16 @@ def remove_articles(text: str) -> str:
|
||||
def sanitize_title(text: str, basic: bool = False) -> str:
|
||||
# normalize unicode and convert to ascii. Does not work for everything eg ½ to 1⁄2 not 1/2
|
||||
text = unicodedata.normalize("NFKD", text).casefold()
|
||||
if basic:
|
||||
# comicvine keeps apostrophes a part of the word
|
||||
text = text.replace("'", "")
|
||||
text = text.replace('"', "")
|
||||
else:
|
||||
# comicvine keeps apostrophes a part of the word
|
||||
text = text.replace("'", "")
|
||||
text = text.replace('"', "")
|
||||
if not basic:
|
||||
# comicvine ignores punctuation and accents
|
||||
# remove all characters that are not a letter, separator (space) or number
|
||||
# replace any "dash punctuation" with a space
|
||||
# makes sure that batman-superman and self-proclaimed stay separate words
|
||||
text = "".join(
|
||||
c if not unicodedata.category(c) in ("Pd",) else " "
|
||||
for c in text
|
||||
if unicodedata.category(c)[0] in "LZN" or unicodedata.category(c) in ("Pd",)
|
||||
c if unicodedata.category(c)[0] not in "P" else " " for c in text if unicodedata.category(c)[0] in "LZNP"
|
||||
)
|
||||
# remove extra space and articles and all lower case
|
||||
text = remove_articles(text).strip()
|
||||
@ -161,10 +152,10 @@ def sanitize_title(text: str, basic: bool = False) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def titles_match(search_title: str, record_title: str, threshold: int = 90) -> int:
|
||||
def titles_match(search_title: str, record_title: str, threshold: int = 90) -> bool:
|
||||
sanitized_search = sanitize_title(search_title)
|
||||
sanitized_record = sanitize_title(record_title)
|
||||
ratio = thefuzz.fuzz.ratio(sanitized_search, sanitized_record)
|
||||
ratio: int = thefuzz.fuzz.ratio(sanitized_search, sanitized_record)
|
||||
logger.debug(
|
||||
"search title: %s ; record title: %s ; ratio: %d ; match threshold: %d",
|
||||
search_title,
|
||||
@ -176,12 +167,12 @@ def titles_match(search_title: str, record_title: str, threshold: int = 90) -> i
|
||||
|
||||
|
||||
def unique_file(file_name: pathlib.Path) -> pathlib.Path:
|
||||
name = file_name.name
|
||||
name = file_name.stem
|
||||
counter = 1
|
||||
while True:
|
||||
if not file_name.exists():
|
||||
return file_name
|
||||
file_name = file_name.with_name(name + " (" + str(counter) + ")")
|
||||
file_name = file_name.with_stem(name + " (" + str(counter) + ")")
|
||||
counter += 1
|
||||
|
||||
|
||||
@ -205,6 +196,7 @@ def get_language_from_iso(iso: str | None) -> str | None:
|
||||
def get_language(string: str | None) -> str | None:
|
||||
if string is None:
|
||||
return None
|
||||
string = string.casefold()
|
||||
|
||||
lang = get_language_from_iso(string)
|
||||
|
||||
@ -217,8 +209,6 @@ def get_language(string: str | None) -> str | None:
|
||||
|
||||
|
||||
def get_publisher(publisher: str) -> tuple[str, str]:
|
||||
if publisher is None:
|
||||
return ("", "")
|
||||
imprint = ""
|
||||
|
||||
for pub in publishers.values():
|
||||
|
@ -165,13 +165,13 @@ def post_process_matches(
|
||||
|
||||
|
||||
def cli_mode(opts: argparse.Namespace, settings: ComicTaggerSettings) -> None:
|
||||
if len(opts.files) < 1:
|
||||
if len(opts.file_list) < 1:
|
||||
logger.error("You must specify at least one filename. Use the -h option for more info")
|
||||
return
|
||||
|
||||
match_results = OnlineMatchResults()
|
||||
|
||||
for f in opts.files:
|
||||
for f in opts.file_list:
|
||||
process_file_cli(f, opts, settings, match_results)
|
||||
sys.stdout.flush()
|
||||
|
||||
@ -212,7 +212,7 @@ def create_local_metadata(opts: argparse.Namespace, ca: ComicArchive, settings:
|
||||
def process_file_cli(
|
||||
filename: str, opts: argparse.Namespace, settings: ComicTaggerSettings, match_results: OnlineMatchResults
|
||||
) -> None:
|
||||
batch_mode = len(opts.files) > 1
|
||||
batch_mode = len(opts.file_list) > 1
|
||||
|
||||
ca = ComicArchive(filename, settings.rar_exe_path, ComicTaggerSettings.get_graphic("nocover.png"))
|
||||
|
||||
@ -472,7 +472,7 @@ def process_file_cli(
|
||||
match_results.good_matches.append(str(ca.path.absolute()))
|
||||
|
||||
elif opts.rename:
|
||||
|
||||
original_path = ca.path
|
||||
msg_hdr = ""
|
||||
if batch_mode:
|
||||
msg_hdr = f"{ca.path}: "
|
||||
@ -529,7 +529,7 @@ def process_file_cli(
|
||||
else:
|
||||
suffix = " (dry-run, no change)"
|
||||
|
||||
print(f"renamed '{os.path.basename(ca.path)}' -> '{new_name}' {suffix}")
|
||||
print(f"renamed '{original_path.name}' -> '{new_name}' {suffix}")
|
||||
|
||||
elif opts.export_to_zip:
|
||||
msg_hdr = ""
|
||||
|
@ -184,8 +184,8 @@ class ComicVineTalker:
|
||||
|
||||
def get_url_content(self, url: str, params: dict[str, Any]) -> Any:
|
||||
# connect to server:
|
||||
# if there is a 500 error, try a few more times before giving up
|
||||
# any other error, just bail
|
||||
# if there is a 500 error, try a few more times before giving up
|
||||
# any other error, just bail
|
||||
for tries in range(3):
|
||||
try:
|
||||
resp = requests.get(url, params=params, headers={"user-agent": "comictagger/" + ctversion.version})
|
||||
@ -199,10 +199,15 @@ class ComicVineTalker:
|
||||
break
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
self.write_log(str(e) + "\n")
|
||||
self.write_log(f"{e}\n")
|
||||
raise ComicVineTalkerException(ComicVineTalkerException.Network, "Network Error!") from e
|
||||
except json.JSONDecodeError as e:
|
||||
self.write_log(f"{e}\n")
|
||||
raise ComicVineTalkerException(ComicVineTalkerException.Unknown, "ComicVine did not provide json")
|
||||
|
||||
raise ComicVineTalkerException(ComicVineTalkerException.Unknown, "Error on Comic Vine server")
|
||||
raise ComicVineTalkerException(
|
||||
ComicVineTalkerException.Unknown, f"Error on Comic Vine server: {resp.status_code}"
|
||||
)
|
||||
|
||||
def search_for_series(
|
||||
self,
|
||||
@ -735,7 +740,7 @@ class ComicVineTalker:
|
||||
)
|
||||
|
||||
self.nam.finished.connect(self.async_fetch_issue_cover_url_complete)
|
||||
self.nam.get(QtNetwork.QNetworkRequest(QtCore.QUrl(issue_url)))
|
||||
self.nam.get(QtNetwork.QNetworkRequest(QtCore.QUrl(issue_url.geturl())))
|
||||
|
||||
def async_fetch_issue_cover_url_complete(self, reply: QtNetwork.QNetworkReply) -> None:
|
||||
# read in the response
|
||||
|
@ -113,7 +113,7 @@ class CoverImageWidget(QtWidgets.QWidget):
|
||||
self.page_loader = None
|
||||
self.imageIndex = -1
|
||||
self.imageCount = 1
|
||||
self.imageData = bytes()
|
||||
self.imageData = b""
|
||||
|
||||
self.btnLeft.setIcon(QtGui.QIcon(ComicTaggerSettings.get_graphic("left.png")))
|
||||
self.btnRight.setIcon(QtGui.QIcon(ComicTaggerSettings.get_graphic("right.png")))
|
||||
@ -136,7 +136,7 @@ class CoverImageWidget(QtWidgets.QWidget):
|
||||
self.page_loader = None
|
||||
self.imageIndex = -1
|
||||
self.imageCount = 1
|
||||
self.imageData = bytes()
|
||||
self.imageData = b""
|
||||
|
||||
def clear(self) -> None:
|
||||
self.reset_widget()
|
||||
|
@ -20,10 +20,9 @@ import logging
|
||||
import os
|
||||
import pathlib
|
||||
import string
|
||||
import sys
|
||||
from typing import Any, cast
|
||||
from typing import Any, NamedTuple, cast
|
||||
|
||||
from pathvalidate import sanitize_filename
|
||||
from pathvalidate import Platform, normalize_platform, sanitize_filename
|
||||
|
||||
from comicapi.comicarchive import ComicArchive
|
||||
from comicapi.genericmetadata import GenericMetadata
|
||||
@ -32,6 +31,16 @@ from comicapi.issuestring import IssueString
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Replacements(NamedTuple):
|
||||
literal_text: list[tuple[str, str]]
|
||||
format_value: list[tuple[str, str]]
|
||||
|
||||
|
||||
REPLACEMENTS = Replacements(
|
||||
literal_text=[(": ", " - "), (":", "-")], format_value=[(": ", " - "), (":", "-"), ("/", "-"), ("\\", "-")]
|
||||
)
|
||||
|
||||
|
||||
def get_rename_dir(ca: ComicArchive, rename_dir: str | pathlib.Path | None) -> pathlib.Path:
|
||||
folder = ca.path.parent.absolute()
|
||||
if rename_dir is not None:
|
||||
@ -42,16 +51,24 @@ def get_rename_dir(ca: ComicArchive, rename_dir: str | pathlib.Path | None) -> p
|
||||
|
||||
|
||||
class MetadataFormatter(string.Formatter):
|
||||
def __init__(self, smart_cleanup: bool = False, platform: str = "auto") -> None:
|
||||
def __init__(
|
||||
self, smart_cleanup: bool = False, platform: str = "auto", replacements: Replacements = REPLACEMENTS
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.smart_cleanup = smart_cleanup
|
||||
self.platform = platform
|
||||
self.platform = normalize_platform(platform)
|
||||
self.replacements = replacements
|
||||
|
||||
def format_field(self, value: Any, format_spec: str) -> str:
|
||||
if value is None or value == "":
|
||||
return ""
|
||||
return cast(str, super().format_field(value, format_spec))
|
||||
|
||||
def handle_replacements(self, string: str, replacements: list[tuple[str, str]]) -> str:
|
||||
for f, r in replacements:
|
||||
string = string.replace(f, r)
|
||||
return string
|
||||
|
||||
def _vformat(
|
||||
self,
|
||||
format_string: str,
|
||||
@ -72,6 +89,8 @@ class MetadataFormatter(string.Formatter):
|
||||
if lstrip:
|
||||
literal_text = literal_text.lstrip("-_)}]#")
|
||||
if self.smart_cleanup:
|
||||
if self.platform in [Platform.UNIVERSAL, Platform.WINDOWS]:
|
||||
literal_text = self.handle_replacements(literal_text, self.replacements.literal_text)
|
||||
lspace = literal_text[0].isspace() if literal_text else False
|
||||
rspace = literal_text[-1].isspace() if literal_text else False
|
||||
literal_text = " ".join(literal_text.split())
|
||||
@ -109,19 +128,29 @@ class MetadataFormatter(string.Formatter):
|
||||
|
||||
# format the object and append to the result
|
||||
fmt_obj = self.format_field(obj, format_spec)
|
||||
if fmt_obj == "" and len(result) > 0 and self.smart_cleanup and literal_text:
|
||||
lstrip = True
|
||||
if fmt_obj == "" and result and self.smart_cleanup and literal_text:
|
||||
if self.str_contains(result[-1], "({["):
|
||||
lstrip = True
|
||||
if result:
|
||||
if " " in result[-1]:
|
||||
result[-1], _, _ = result[-1].rpartition(" ")
|
||||
result[-1], _, _ = result[-1].rstrip().rpartition(" ")
|
||||
result[-1] = result[-1].rstrip("-_({[#")
|
||||
if self.smart_cleanup:
|
||||
if self.platform in [Platform.UNIVERSAL, Platform.WINDOWS]:
|
||||
# colons and slashes get special treatment
|
||||
fmt_obj = self.handle_replacements(fmt_obj, self.replacements.format_value)
|
||||
fmt_obj = " ".join(fmt_obj.split())
|
||||
fmt_obj = str(sanitize_filename(fmt_obj, platform=self.platform))
|
||||
result.append(fmt_obj)
|
||||
|
||||
return "".join(result), False
|
||||
|
||||
def str_contains(self, chars: str, string: str) -> bool:
|
||||
for char in chars:
|
||||
if char in string:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class FileRenamer:
|
||||
def __init__(self, metadata: GenericMetadata | None, platform: str = "auto") -> None:
|
||||
@ -172,13 +201,6 @@ class FileRenamer:
|
||||
|
||||
new_basename = ""
|
||||
for component in pathlib.PureWindowsPath(template).parts:
|
||||
if (
|
||||
self.platform.casefold() in ["universal", "windows"] or sys.platform.casefold() in ["windows"]
|
||||
) and self.smart_cleanup:
|
||||
# colons get special treatment
|
||||
component = component.replace(": ", " - ")
|
||||
component = component.replace(":", "-")
|
||||
|
||||
new_basename = str(
|
||||
sanitize_filename(fmt.vformat(component, args=[], kwargs=Default(md_dict)), platform=self.platform)
|
||||
).strip()
|
||||
|
@ -97,14 +97,14 @@ class ImageFetcher:
|
||||
# if we found it, just emit the signal asap
|
||||
if image_data:
|
||||
ImageFetcher.image_fetch_complete(QtCore.QByteArray(image_data))
|
||||
return bytes()
|
||||
return b""
|
||||
|
||||
# didn't find it. look online
|
||||
self.nam.finished.connect(self.finish_request)
|
||||
self.nam.get(QtNetwork.QNetworkRequest(QtCore.QUrl(url)))
|
||||
|
||||
# we'll get called back when done...
|
||||
return bytes()
|
||||
return b""
|
||||
|
||||
def finish_request(self, reply: QtNetwork.QNetworkReply) -> None:
|
||||
# read in the image data
|
||||
@ -159,10 +159,10 @@ class ImageFetcher:
|
||||
row = cur.fetchone()
|
||||
|
||||
if row is None:
|
||||
return bytes()
|
||||
return b""
|
||||
|
||||
filename = row[0]
|
||||
image_data = bytes()
|
||||
image_data = b""
|
||||
|
||||
try:
|
||||
with open(filename, "rb") as f:
|
||||
|
@ -157,7 +157,7 @@ class IssueIdentifier:
|
||||
cropped_im = im.crop((int(w / 2), 0, w, h))
|
||||
except Exception:
|
||||
logger.exception("cropCover() error")
|
||||
return bytes()
|
||||
return b""
|
||||
|
||||
output = io.BytesIO()
|
||||
cropped_im.save(output, format="PNG")
|
||||
|
@ -405,6 +405,8 @@ def parse_cmd_line() -> argparse.Namespace:
|
||||
opts.copy = opts.copy[0]
|
||||
|
||||
if opts.recursive:
|
||||
opts.file_list = utils.get_recursive_filelist(opts.file_list)
|
||||
opts.file_list = utils.get_recursive_filelist(opts.files)
|
||||
else:
|
||||
opts.file_list = opts.files
|
||||
|
||||
return opts
|
||||
|
@ -22,7 +22,8 @@ import pathlib
|
||||
import platform
|
||||
import sys
|
||||
import uuid
|
||||
from typing import Iterator, TextIO, no_type_check
|
||||
from collections.abc import Iterator
|
||||
from typing import TextIO, no_type_check
|
||||
|
||||
from comicapi import utils
|
||||
|
||||
|
@ -26,7 +26,8 @@ import pprint
|
||||
import re
|
||||
import sys
|
||||
import webbrowser
|
||||
from typing import Any, Callable, Iterable, cast
|
||||
from collections.abc import Iterable
|
||||
from typing import Any, Callable, cast
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import natsort
|
||||
@ -1854,7 +1855,7 @@ Have fun!
|
||||
logger.error("Failed to load metadata for %s: %s", ca.path, e)
|
||||
image_data = ca.get_page(cover_idx)
|
||||
self.atprogdialog.set_archive_image(image_data)
|
||||
self.atprogdialog.set_test_image(bytes())
|
||||
self.atprogdialog.set_test_image(b"")
|
||||
|
||||
QtCore.QCoreApplication.processEvents()
|
||||
if self.atprogdialog.isdone:
|
||||
|
1
requirements-speedup.txt
Normal file
1
requirements-speedup.txt
Normal file
@ -0,0 +1 @@
|
||||
thefuzz[speedup]>=0.19.0
|
@ -2,11 +2,12 @@ beautifulsoup4 >= 4.1
|
||||
importlib_metadata
|
||||
natsort>=8.1.0
|
||||
pathvalidate
|
||||
pillow>=4.3.0
|
||||
pillow>=9.1.0
|
||||
py7zr
|
||||
pycountry
|
||||
pyicu; sys_platform == 'linux' or sys_platform == 'darwin'
|
||||
requests==2.*
|
||||
text2digits
|
||||
thefuzz[speedup]>=0.19.0
|
||||
thefuzz>=0.19.0
|
||||
typing_extensions
|
||||
wordninja
|
||||
|
@ -114,6 +114,7 @@ imprints = [
|
||||
("marvel", ("", "Marvel")),
|
||||
("marvel comics", ("", "Marvel")),
|
||||
("aircel", ("Aircel Comics", "Marvel")),
|
||||
("nothing", ("", "nothing")),
|
||||
]
|
||||
|
||||
additional_imprints = [
|
||||
|
Binary file not shown.
Binary file not shown.
@ -733,6 +733,20 @@ fnames = [
|
||||
},
|
||||
True,
|
||||
),
|
||||
(
|
||||
"Cory Doctorow's Futuristic Tales of the Here and Now: Anda's Game #001 (2007).cbz",
|
||||
"full-date, issue in parenthesis",
|
||||
{
|
||||
"issue": "1",
|
||||
"series": "Cory Doctorow's Futuristic Tales of the Here and Now",
|
||||
"title": "Anda's Game",
|
||||
"volume": "",
|
||||
"year": "2007",
|
||||
"remainder": "",
|
||||
"issue_count": "",
|
||||
},
|
||||
True,
|
||||
),
|
||||
]
|
||||
|
||||
rnames = [
|
||||
@ -743,6 +757,13 @@ rnames = [
|
||||
"Cory Doctorow's Futuristic Tales of the Here and Now #001 - Anda's Game (2007).cbz",
|
||||
does_not_raise(),
|
||||
),
|
||||
(
|
||||
"{series} #{issue} - {title} {volume:02} ({year})", # Ensure format specifier works
|
||||
False,
|
||||
"universal",
|
||||
"Cory Doctorow's Futuristic Tales of the Here and Now #001 - Anda's Game 01 (2007).cbz",
|
||||
does_not_raise(),
|
||||
),
|
||||
(
|
||||
"{series} #{issue} - {title} ({year})({price})", # price should be none, test no space between ')('
|
||||
False,
|
||||
@ -764,6 +785,20 @@ rnames = [
|
||||
"Cory Doctorow's Futuristic Tales of the Here and Now #001 - Anda's Game (2007).cbz",
|
||||
does_not_raise(),
|
||||
),
|
||||
(
|
||||
"{title} {web_link}", # Ensure colon is replaced in metadata
|
||||
False,
|
||||
"universal",
|
||||
"Anda's Game https---comicvine.gamespot.com-cory-doctorows-futuristic-tales-of-the-here-and-no-4000-140529-.cbz",
|
||||
does_not_raise(),
|
||||
),
|
||||
(
|
||||
"{series}:{title} #{issue} ({year})", # on windows the ':' is replaced
|
||||
False,
|
||||
"universal",
|
||||
"Cory Doctorow's Futuristic Tales of the Here and Now-Anda's Game #001 (2007).cbz",
|
||||
does_not_raise(),
|
||||
),
|
||||
(
|
||||
"{series}: {title} #{issue} ({year})", # on windows the ':' is replaced
|
||||
False,
|
||||
@ -795,7 +830,7 @@ rnames = [
|
||||
(
|
||||
r"{publisher}\ {series} #{issue} - {title} ({year})", # backslashes separate directories
|
||||
False,
|
||||
"universal",
|
||||
"Linux",
|
||||
"Cory Doctorow's Futuristic Tales of the Here and Now #001 - Anda's Game (2007).cbz",
|
||||
does_not_raise(),
|
||||
),
|
||||
@ -807,10 +842,10 @@ rnames = [
|
||||
does_not_raise(),
|
||||
),
|
||||
(
|
||||
"{series} # {issue} - {locations} ({year})",
|
||||
"{series} #{issue} - {locations} ({year})",
|
||||
False,
|
||||
"universal",
|
||||
"Cory Doctorow's Futuristic Tales of the Here and Now # 001 - lonely cottage (2007).cbz",
|
||||
"Cory Doctorow's Futuristic Tales of the Here and Now #001 - lonely cottage (2007).cbz",
|
||||
does_not_raise(),
|
||||
),
|
||||
(
|
||||
@ -848,6 +883,20 @@ rnames = [
|
||||
"Cory Doctorow's Futuristic Tales of the Here and Now - Anda's Game {test} #001 (2007).cbz",
|
||||
does_not_raise(),
|
||||
),
|
||||
(
|
||||
"{series} - {title} #{issue} ({year} {price})", # Test null value in parenthesis with a non-null value
|
||||
False,
|
||||
"universal",
|
||||
"Cory Doctorow's Futuristic Tales of the Here and Now - Anda's Game #001 (2007).cbz",
|
||||
does_not_raise(),
|
||||
),
|
||||
(
|
||||
"{series} - {title} #{issue} (of {price})", # null value with literal text in parenthesis
|
||||
False,
|
||||
"universal",
|
||||
"Cory Doctorow's Futuristic Tales of the Here and Now - Anda's Game #001.cbz",
|
||||
does_not_raise(),
|
||||
),
|
||||
(
|
||||
"{series} - {title} {1} #{issue} ({year})", # Test numeric key
|
||||
False,
|
||||
|
@ -15,6 +15,8 @@ def test_getPageNameList():
|
||||
pageNameList = c.get_page_name_list()
|
||||
|
||||
assert pageNameList == [
|
||||
"!cover.jpg",
|
||||
"00.jpg",
|
||||
"page0.jpg",
|
||||
"Page1.jpeg",
|
||||
"Page2.png",
|
||||
|
@ -5,7 +5,8 @@ import datetime
|
||||
import io
|
||||
import shutil
|
||||
import unittest.mock
|
||||
from typing import Any, Generator
|
||||
from collections.abc import Generator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
import comicapi.utils
|
||||
@ -22,13 +24,16 @@ def test_recursive_list_with_file(tmp_path) -> None:
|
||||
temp_txt = tmp_path / "info.txt"
|
||||
temp_txt.write_text("this is here")
|
||||
|
||||
expected_result = {str(foo_png), str(temp_cbr), str(temp_file), str(temp_txt)}
|
||||
result = set(comicapi.utils.get_recursive_filelist([tmp_path]))
|
||||
temp_txt2 = tmp_path / "info2.txt"
|
||||
temp_txt2.write_text("this is here")
|
||||
|
||||
expected_result = {str(foo_png), str(temp_cbr), str(temp_file), str(temp_txt), str(temp_txt2)}
|
||||
result = set(comicapi.utils.get_recursive_filelist([str(temp_txt2), tmp_path]))
|
||||
|
||||
assert result == expected_result
|
||||
|
||||
|
||||
values = [
|
||||
xlate_values = [
|
||||
({"data": "", "is_int": False, "is_float": False}, None),
|
||||
({"data": None, "is_int": False, "is_float": False}, None),
|
||||
({"data": None, "is_int": True, "is_float": False}, None),
|
||||
@ -52,6 +57,70 @@ values = [
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value, result", values)
|
||||
@pytest.mark.parametrize("value, result", xlate_values)
|
||||
def test_xlate(value, result):
|
||||
assert comicapi.utils.xlate(**value) == result
|
||||
|
||||
|
||||
language_values = [
|
||||
("en", "English"),
|
||||
("EN", "English"),
|
||||
("En", "English"),
|
||||
("", None),
|
||||
(None, None),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value, result", language_values)
|
||||
def test_get_language(value, result):
|
||||
assert result == comicapi.utils.get_language(value)
|
||||
|
||||
|
||||
def test_unique_file(tmp_path):
|
||||
file = tmp_path / "test.cbz"
|
||||
assert file == comicapi.utils.unique_file(file)
|
||||
|
||||
file.mkdir()
|
||||
assert (tmp_path / "test (1).cbz") == comicapi.utils.unique_file(file)
|
||||
|
||||
|
||||
def test_add_to_path(monkeypatch):
|
||||
monkeypatch.setenv("PATH", os.path.abspath("/usr/bin"))
|
||||
comicapi.utils.add_to_path("/bin")
|
||||
assert os.environ["PATH"] == (os.path.abspath("/bin") + os.pathsep + os.path.abspath("/usr/bin"))
|
||||
|
||||
comicapi.utils.add_to_path("/usr/bin")
|
||||
comicapi.utils.add_to_path("/usr/bin/")
|
||||
assert os.environ["PATH"] == (os.path.abspath("/bin") + os.pathsep + os.path.abspath("/usr/bin"))
|
||||
|
||||
|
||||
titles = [
|
||||
(("", ""), True),
|
||||
(("Conan el Barbaro", "Conan el Bárbaro"), True),
|
||||
(("鋼の錬金術師", "鋼の錬金術師"), True),
|
||||
(("钢之炼金术师", "鋼の錬金術師"), False),
|
||||
(("batmans grave", "The Batman's Grave"), True),
|
||||
(("batman grave", "The Batman's Grave"), True),
|
||||
(("bats grave", "The Batman's Grave"), False),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value, result", titles)
|
||||
def test_titles_match(value, result):
|
||||
assert comicapi.utils.titles_match(value[0], value[1]) == result
|
||||
|
||||
|
||||
titles_2 = [
|
||||
("", ""),
|
||||
("鋼の錬金術師", "鋼の錬金術師"),
|
||||
("Conan el Bárbaro", "Conan el Barbaro"),
|
||||
("The Batman's Grave", "batmans grave"),
|
||||
("A+X", "ax"),
|
||||
("ms. marvel", "ms marvel"),
|
||||
("spider-man/deadpool", "spider man deadpool"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value, result", titles_2)
|
||||
def test_sanitize_title(value, result):
|
||||
assert comicapi.utils.sanitize_title(value) == result.casefold()
|
||||
|
Reference in New Issue
Block a user