Compare commits

...

9 Commits

Author SHA1 Message Date
2aff026ebc Update QWebEngineView usage and fix pyqtSignal enforcing types
Some checks failed
CI / lint (ubuntu-latest, 3.9) (push) Has been cancelled
CI / build-and-test (macos-13, 3.13) (push) Has been cancelled
CI / build-and-test (macos-13, 3.9) (push) Has been cancelled
CI / build-and-test (macos-14, 3.13) (push) Has been cancelled
CI / build-and-test (macos-14, 3.9) (push) Has been cancelled
CI / build-and-test (ubuntu-22.04, 3.13) (push) Has been cancelled
CI / build-and-test (ubuntu-22.04, 3.9) (push) Has been cancelled
CI / build-and-test (ubuntu-22.04-arm, 3.13) (push) Has been cancelled
CI / build-and-test (ubuntu-22.04-arm, 3.9) (push) Has been cancelled
CI / build-and-test (windows-latest, 3.13) (push) Has been cancelled
CI / build-and-test (windows-latest, 3.9) (push) Has been cancelled
2025-09-01 18:33:10 -07:00
b8dff44e54 Update comicapi docstrings 2025-09-01 18:33:10 -07:00
71fa3a57e0 Fix Tag test 2025-09-01 18:33:10 -07:00
88908691d1 Update most things to use a Tag directly 2025-09-01 18:33:10 -07:00
19495b6e36 Tests 2025-09-01 18:33:10 -07:00
91b5d3ce5d Redesign comic files 2025-09-01 18:33:10 -07:00
371c457d5b Fix tests 2025-09-01 18:33:10 -07:00
acb1d2951f Update comictaggerlib and comicapi for exception handling 2025-09-01 18:33:10 -07:00
8261e98ae1 Fix Archive and Tag plugin definitions for exception handling 2025-09-01 18:33:10 -07:00
46 changed files with 1965 additions and 1750 deletions

BIN
comicapi/.DS_Store vendored Normal file

Binary file not shown.

View File

@ -1,13 +0,0 @@
from __future__ import annotations
from comicapi.archivers.archiver import Archiver
from comicapi.archivers.folder import FolderArchiver
from comicapi.archivers.zip import ZipArchiver
class UnknownArchiver(Archiver):
def name(self) -> str:
return "Unknown"
__all__ = ["Archiver", "UnknownArchiver", "FolderArchiver", "ZipArchiver"]

View File

@ -1,146 +0,0 @@
from __future__ import annotations
import pathlib
from collections.abc import Collection
from typing import Protocol, runtime_checkable
@runtime_checkable
class Archiver(Protocol):
"""Archiver Protocol"""
"""The path to the archive"""
path: pathlib.Path
"""
The name of the executable used for this archiver. This should be the base name of the executable.
For example if 'rar.exe' is needed this should be "rar".
If an executable is not used this should be the empty string.
"""
exe: str = ""
"""
Whether or not this archiver is enabled.
If external imports are required and are not available this should be false. See rar.py and sevenzip.py.
"""
enabled: bool = True
"""
If self.path is a single file that can be hashed.
For example directories cannot be hashed.
"""
hashable: bool = True
supported_extensions: Collection[str] = set()
def __init__(self) -> None:
self.path = pathlib.Path()
def get_comment(self) -> str:
"""
Returns the comment from the current archive as a string.
Should always return a string. If comments are not supported in the archive the empty string should be returned.
"""
return ""
def set_comment(self, comment: str) -> bool:
"""
Returns True if the comment was successfully set on the current archive.
Should always return a boolean. If comments are not supported in the archive False should be returned.
"""
return False
def supports_comment(self) -> bool:
"""
Returns True if the current archive supports comments.
Should always return a boolean. If comments are not supported in the archive False should be returned.
"""
return False
def read_file(self, archive_file: str) -> bytes:
"""
Reads the named file from the current archive.
archive_file should always come from the output of get_filename_list.
Should always return a bytes object. Exceptions should be of the type OSError.
"""
raise NotImplementedError
def remove_file(self, archive_file: str) -> bool:
"""
Removes the named file from the current archive.
archive_file should always come from the output of get_filename_list.
Should always return a boolean. Failures should return False.
Rebuilding the archive without the named file is a standard way to remove a file.
"""
return False
def write_file(self, archive_file: str, data: bytes) -> bool:
"""
Writes the named file to the current archive.
Should always return a boolean. Failures should return False.
"""
return False
def get_filename_list(self) -> list[str]:
"""
Returns a list of filenames in the current archive.
Should always return a list of string. Failures should return an empty list.
"""
return []
def supports_files(self) -> bool:
"""
Returns True if the current archive supports arbitrary non-picture files.
Should always return a boolean.
If arbitrary non-picture files are not supported in the archive False should be returned.
"""
return False
def copy_from_archive(self, other_archive: Archiver) -> bool:
"""
Copies the contents of another achive to the current archive.
Should always return a boolean. Failures should return False.
"""
return False
def is_writable(self) -> bool:
"""
Retuns True if the current archive is writeable
Should always return a boolean. Failures should return False.
"""
return False
def extension(self) -> str:
"""
Returns the extension that this archiver should use eg ".cbz".
Should always return a string. Failures should return the empty string.
"""
return ""
def name(self) -> str:
"""
Returns the name of this archiver for display purposes eg "CBZ".
Should always return a string. Failures should return the empty string.
"""
return ""
@classmethod
def is_valid(cls, path: pathlib.Path) -> bool:
"""
Returns True if the given path can be opened by this archiver.
Should always return a boolean. Failures should return False.
"""
return False
@classmethod
def open(cls, path: pathlib.Path) -> Archiver:
"""
Opens the given archive.
Should always return a an Archver.
Should never cause an exception no file operations should take place in this method,
is_valid will always be called before open.
"""
archiver = cls()
archiver.path = path
return archiver

View File

@ -1,115 +0,0 @@
from __future__ import annotations
import logging
import os
import pathlib
from comicapi.archivers import Archiver
logger = logging.getLogger(__name__)
class FolderArchiver(Archiver):
"""Folder implementation"""
hashable = False
def __init__(self) -> None:
super().__init__()
self.comment_file_name = "ComicTaggerFolderComment.txt"
self._filename_list: list[str] = []
def get_comment(self) -> str:
try:
return (self.path / self.comment_file_name).read_text()
except OSError:
return ""
def set_comment(self, comment: str) -> bool:
self._filename_list = []
if comment:
return self.write_file(self.comment_file_name, comment.encode("utf-8"))
(self.path / self.comment_file_name).unlink(missing_ok=True)
return True
def supports_comment(self) -> bool:
return True
def read_file(self, archive_file: str) -> bytes:
try:
data = (self.path / archive_file).read_bytes()
except OSError as e:
logger.error("Error reading folder archive [%s]: %s :: %s", e, self.path, archive_file)
raise
return data
def remove_file(self, archive_file: str) -> bool:
self._filename_list = []
try:
(self.path / archive_file).unlink(missing_ok=True)
except OSError as e:
logger.error("Error removing file for folder archive [%s]: %s :: %s", e, self.path, archive_file)
return False
else:
return True
def write_file(self, archive_file: str, data: bytes) -> bool:
self._filename_list = []
try:
file_path = self.path / archive_file
file_path.parent.mkdir(exist_ok=True, parents=True)
with open(self.path / archive_file, mode="wb") as f:
f.write(data)
except OSError as e:
logger.error("Error writing folder archive [%s]: %s :: %s", e, self.path, archive_file)
return False
else:
return True
def get_filename_list(self) -> list[str]:
if self._filename_list:
return self._filename_list
filenames = []
try:
for root, _dirs, files in os.walk(self.path):
for f in files:
filenames.append(os.path.relpath(os.path.join(root, f), self.path).replace(os.path.sep, "/"))
self._filename_list = filenames
return filenames
except OSError as e:
logger.error("Error listing files in folder archive [%s]: %s", e, self.path)
return []
def supports_files(self) -> bool:
return True
def copy_from_archive(self, other_archive: Archiver) -> bool:
"""Replace the current zip with one copied from another archive"""
self._filename_list = []
try:
for filename in other_archive.get_filename_list():
data = other_archive.read_file(filename)
if data is not None:
self.write_file(filename, data)
# preserve the old comment
comment = other_archive.get_comment()
if comment is not None:
if not self.set_comment(comment):
return False
except Exception:
logger.exception("Error while copying archive from %s to %s", other_archive.path, self.path)
return False
else:
return True
def is_writable(self) -> bool:
return True
def name(self) -> str:
return "Folder"
@classmethod
def is_valid(cls, path: pathlib.Path) -> bool:
return path.is_dir()

View File

@ -1,347 +0,0 @@
from __future__ import annotations
import functools
import logging
import os
import pathlib
import platform
import shutil
import subprocess
import tempfile
from comicapi.archivers import Archiver
try:
import rarfile
rar_support = True
except ImportError:
rar_support = False
logger = logging.getLogger(__name__)
if not rar_support:
logger.error("rar unavailable")
# windows only, keeps the cmd.exe from popping up
STARTUPINFO = None
if platform.system() == "Windows":
STARTUPINFO = subprocess.STARTUPINFO() # type: ignore
STARTUPINFO.dwFlags |= subprocess.STARTF_USESHOWWINDOW # type: ignore
class RarArchiver(Archiver):
"""RAR implementation"""
enabled = rar_support
exe = "rar"
supported_extensions = frozenset({".cbr", ".rar"})
_rar: rarfile.RarFile | None = None
_rar_setup: rarfile.ToolSetup | None = None
_writeable: bool | None = None
def __init__(self) -> None:
super().__init__()
self._filename_list: list[str] = []
def get_comment(self) -> str:
rarc = self.get_rar_obj()
return (rarc.comment if rarc else "") or ""
def set_comment(self, comment: str) -> bool:
self._reset()
if rar_support and self.exe:
try:
# write comment to temp file
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_file = pathlib.Path(tmp_dir) / "rar_comment.txt"
tmp_file.write_text(comment, encoding="utf-8")
working_dir = os.path.dirname(os.path.abspath(self.path))
# use external program to write comment to Rar archive
proc_args = [
self.exe,
"c",
f"-w{working_dir}",
"-c-",
f"-z{tmp_file}",
str(self.path),
]
result = subprocess.run(
proc_args,
startupinfo=STARTUPINFO,
stdin=subprocess.DEVNULL,
capture_output=True,
encoding="utf-8",
cwd=tmp_dir,
)
if result.returncode != 0:
logger.error(
"Error writing comment to rar archive [exitcode: %d]: %s :: %s",
result.returncode,
self.path,
result.stderr,
)
return False
except OSError as e:
logger.exception("Error writing comment to rar archive [%s]: %s", e, self.path)
return False
return True
return False
def supports_comment(self) -> bool:
return True
def read_file(self, archive_file: str) -> bytes:
rarc = self.get_rar_obj()
if rarc is None:
return b""
tries = 0
while tries < 7:
try:
tries = tries + 1
data: bytes = rarc.open(archive_file).read()
entries = [(rarc.getinfo(archive_file), data)]
if entries[0][0].file_size != len(entries[0][1]):
logger.info(
"Error reading rar archive [file is not expected size: %d vs %d] %s :: %s :: tries #%d",
entries[0][0].file_size,
len(entries[0][1]),
self.path,
archive_file,
tries,
)
continue
except OSError as e:
logger.error("Error reading rar archive [%s]: %s :: %s :: tries #%d", e, self.path, archive_file, tries)
except Exception as e:
logger.error(
"Unexpected exception reading rar archive [%s]: %s :: %s :: tries #%d",
e,
self.path,
archive_file,
tries,
)
break
else:
# Success. Entries is a list of of tuples: ( rarinfo, filedata)
if len(entries) == 1:
return entries[0][1]
raise OSError
raise OSError
def remove_file(self, archive_file: str) -> bool:
self._reset()
if self.exe:
working_dir = os.path.dirname(os.path.abspath(self.path))
# use external program to remove file from Rar archive
result = subprocess.run(
[self.exe, "d", f"-w{working_dir}", "-c-", self.path, archive_file],
startupinfo=STARTUPINFO,
stdin=subprocess.DEVNULL,
capture_output=True,
encoding="utf-8",
cwd=self.path.absolute().parent,
)
if result.returncode != 0:
logger.error(
"Error removing file from rar archive [exitcode: %d]: %s :: %s",
result.returncode,
self.path,
archive_file,
)
return False
return True
return False
def write_file(self, archive_file: str, data: bytes) -> bool:
self._reset()
if self.exe:
archive_path = pathlib.PurePosixPath(archive_file)
archive_name = archive_path.name
archive_parent = str(archive_path.parent).lstrip("./")
working_dir = os.path.dirname(os.path.abspath(self.path))
# use external program to write file to Rar archive
result = subprocess.run(
[
self.exe,
"a",
f"-w{working_dir}",
f"-si{archive_name}",
f"-ap{archive_parent}",
"-c-",
"-ep",
self.path,
],
input=data,
startupinfo=STARTUPINFO,
capture_output=True,
cwd=self.path.absolute().parent,
)
if result.returncode != 0:
logger.error(
"Error writing rar archive [exitcode: %d]: %s :: %s :: %s",
result.returncode,
self.path,
archive_file,
result.stderr,
)
return False
return True
return False
def get_filename_list(self) -> list[str]:
if self._filename_list:
return self._filename_list
rarc = self.get_rar_obj()
tries = 0
if rar_support and rarc:
while tries < 7:
try:
tries = tries + 1
namelist = []
for item in rarc.infolist():
if item.file_size != 0:
namelist.append(item.filename)
except OSError as e:
logger.error("Error listing files in rar archive [%s]: %s :: attempt #%d", e, self.path, tries)
else:
self._filename_list = namelist
return namelist
return []
def supports_files(self) -> bool:
return True
def copy_from_archive(self, other_archive: Archiver) -> bool:
"""Replace the current archive with one copied from another archive"""
self._reset()
try:
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_path = pathlib.Path(tmp_dir)
rar_cwd = tmp_path / "rar"
rar_cwd.mkdir(exist_ok=True)
rar_path = (tmp_path / self.path.name).with_suffix(".rar")
working_dir = os.path.dirname(os.path.abspath(self.path))
for filename in other_archive.get_filename_list():
(rar_cwd / filename).parent.mkdir(exist_ok=True, parents=True)
data = other_archive.read_file(filename)
if data is not None:
with open(rar_cwd / filename, mode="w+b") as tmp_file:
tmp_file.write(data)
result = subprocess.run(
[self.exe, "a", f"-w{working_dir}", "-r", "-c-", str(rar_path.absolute()), "."],
cwd=rar_cwd.absolute(),
startupinfo=STARTUPINFO,
stdin=subprocess.DEVNULL,
capture_output=True,
encoding="utf-8",
)
if result.returncode != 0:
logger.error(
"Error while copying to rar archive [exitcode: %d]: %s: %s",
result.returncode,
self.path,
result.stderr,
)
return False
self.path.unlink(missing_ok=True)
shutil.move(rar_path, self.path)
except Exception as e:
logger.exception("Error while copying to rar archive [%s]: from %s to %s", e, other_archive.path, self.path)
return False
else:
return True
@classmethod
@functools.cache
def _log_not_writeable(cls, exe: str) -> None:
logger.warning("Unable to find a useable copy of %r, will not be able to write rar files", exe)
def is_writable(self) -> bool:
return bool(self._writeable and bool(self.exe and (os.path.exists(self.exe) or shutil.which(self.exe))))
def extension(self) -> str:
return ".cbr"
def name(self) -> str:
return "RAR"
@classmethod
def _setup_rar(cls) -> None:
if cls._rar_setup is None:
assert rarfile
orig = rarfile.UNRAR_TOOL
rarfile.UNRAR_TOOL = cls.exe
try:
cls._rar_setup = rarfile.tool_setup(sevenzip=False, sevenzip2=False, force=True)
except rarfile.RarCannotExec:
rarfile.UNRAR_TOOL = orig
try:
cls._rar_setup = rarfile.tool_setup(force=True)
except rarfile.RarCannotExec as e:
logger.info(e)
if cls._writeable is None:
try:
cls._writeable = (
subprocess.run(
(cls.exe,),
startupinfo=STARTUPINFO,
capture_output=True,
# cwd=cls.path.absolute().parent,
)
.stdout.strip()
.startswith(b"RAR")
)
except OSError:
cls._writeable = False
if not cls._writeable:
cls._log_not_writeable(cls.exe or "rar")
@classmethod
def is_valid(cls, path: pathlib.Path) -> bool:
if rar_support:
assert rarfile
cls._setup_rar()
# Fallback to standard
try:
return rarfile.is_rarfile(str(path))
except rarfile.RarCannotExec as e:
logger.info(e)
return False
def _reset(self) -> None:
self._rar = None
self._filename_list = []
def get_rar_obj(self) -> rarfile.RarFile | None:
if self._rar is not None:
return self._rar
if rar_support:
try:
rarc = rarfile.RarFile(str(self.path))
self._rar = rarc
except (OSError, rarfile.RarFileError) as e:
logger.error("Unable to get rar object [%s]: %s", e, self.path)
else:
return rarc
return None

View File

@ -1,143 +0,0 @@
from __future__ import annotations
import logging
import os
import pathlib
import shutil
import tempfile
from comicapi.archivers import Archiver
try:
import py7zr
z7_support = True
except ImportError:
z7_support = False
logger = logging.getLogger(__name__)
class SevenZipArchiver(Archiver):
"""7Z implementation"""
enabled = z7_support
supported_extensions = frozenset({".7z", ".cb7"})
def __init__(self) -> None:
super().__init__()
self._filename_list: list[str] = []
# @todo: Implement Comment?
def get_comment(self) -> str:
return ""
def set_comment(self, comment: str) -> bool:
return False
def read_file(self, archive_file: str) -> bytes:
data = b""
try:
with py7zr.SevenZipFile(self.path, "r") as zf:
data = zf.read([archive_file])[archive_file].read()
except (py7zr.Bad7zFile, OSError) as e:
logger.error("Error reading 7zip archive [%s]: %s :: %s", e, self.path, archive_file)
raise
return data
def remove_file(self, archive_file: str) -> bool:
self._filename_list = []
return self.rebuild([archive_file])
def write_file(self, archive_file: str, data: bytes) -> bool:
# At the moment, no other option but to rebuild the whole
# archive w/o the indicated file. Very sucky, but maybe
# another solution can be found
files = self.get_filename_list()
self._filename_list = []
if archive_file in files:
if not self.rebuild([archive_file]):
return False
try:
# now just add the archive file as a new one
with py7zr.SevenZipFile(self.path, "a") as zf:
zf.writestr(data, archive_file)
return True
except (py7zr.Bad7zFile, OSError) as e:
logger.error("Error writing 7zip archive [%s]: %s :: %s", e, self.path, archive_file)
return False
def get_filename_list(self) -> list[str]:
if self._filename_list:
return self._filename_list
try:
with py7zr.SevenZipFile(self.path, "r") as zf:
namelist: list[str] = [file.filename for file in zf.list() if not file.is_directory]
self._filename_list = namelist
return namelist
except (py7zr.Bad7zFile, OSError) as e:
logger.error("Error listing files in 7zip archive [%s]: %s", e, self.path)
return []
def supports_files(self) -> bool:
return True
def rebuild(self, exclude_list: list[str]) -> bool:
"""Zip helper func
This recompresses the zip archive, without the files in the exclude_list
"""
self._filename_list = []
try:
# py7zr treats all archives as if they used solid compression
# so we need to get the filename list first to read all the files at once
with py7zr.SevenZipFile(self.path, mode="r") as zin:
targets = [f for f in zin.getnames() if f not in exclude_list]
with tempfile.NamedTemporaryFile(dir=os.path.dirname(self.path), delete=False) as tmp_file:
with py7zr.SevenZipFile(tmp_file.file, mode="w") as zout:
with py7zr.SevenZipFile(self.path, mode="r") as zin:
for filename, buffer in zin.read(targets).items():
zout.writef(buffer, filename)
self.path.unlink(missing_ok=True)
tmp_file.close() # Required on windows
shutil.move(tmp_file.name, self.path)
except (py7zr.Bad7zFile, OSError) as e:
logger.error("Error rebuilding 7zip file [%s]: %s", e, self.path)
return False
return True
def copy_from_archive(self, other_archive: Archiver) -> bool:
"""Replace the current zip with one copied from another archive"""
self._filename_list = []
try:
with py7zr.SevenZipFile(self.path, "w") as zout:
for filename in other_archive.get_filename_list():
data = other_archive.read_file(
filename
) # This will be very inefficient if other_archive is a 7z file
if data is not None:
zout.writestr(data, filename)
except Exception as e:
logger.error("Error while copying to 7zip archive [%s]: from %s to %s", e, other_archive.path, self.path)
return False
else:
return True
def is_writable(self) -> bool:
return True
def extension(self) -> str:
return ".cb7"
def name(self) -> str:
return "Seven Zip"
@classmethod
def is_valid(cls, path: pathlib.Path) -> bool:
return py7zr.is_7zfile(path)

View File

@ -1,160 +0,0 @@
from __future__ import annotations
import logging
import os
import pathlib
import shutil
import tempfile
import zipfile
from typing import cast
import chardet
from zipremove import ZipFile
from comicapi.archivers import Archiver
logger = logging.getLogger(__name__)
class ZipArchiver(Archiver):
"""ZIP implementation"""
supported_extensions = frozenset((".cbz", ".zip"))
def __init__(self) -> None:
super().__init__()
self._filename_list: list[str] = []
def supports_comment(self) -> bool:
return True
def get_comment(self) -> str:
with ZipFile(self.path, "r") as zf:
encoding = chardet.detect(zf.comment, True)
if encoding["confidence"] > 60:
try:
comment = zf.comment.decode(encoding["encoding"])
except UnicodeDecodeError:
comment = zf.comment.decode("utf-8", errors="replace")
else:
comment = zf.comment.decode("utf-8", errors="replace")
return comment
def set_comment(self, comment: str) -> bool:
with ZipFile(self.path, mode="a") as zf:
zf.comment = bytes(comment, "utf-8")
return True
def read_file(self, archive_file: str) -> bytes:
with ZipFile(self.path, mode="r") as zf:
try:
data = zf.read(archive_file)
except (zipfile.BadZipfile, OSError) as e:
logger.exception("Error reading zip archive [%s]: %s :: %s", e, self.path, archive_file)
raise
return data
def remove_file(self, archive_file: str) -> bool:
files = self.get_filename_list()
self._filename_list = []
try:
with ZipFile(self.path, mode="a", allowZip64=True, compression=zipfile.ZIP_DEFLATED) as zf:
if archive_file in files:
zf.repack([zf.remove(archive_file)])
return True
except (zipfile.BadZipfile, OSError) as e:
logger.error("Error writing zip archive [%s]: %s :: %s", e, self.path, archive_file)
return False
def write_file(self, archive_file: str, data: bytes) -> bool:
files = self.get_filename_list()
self._filename_list = []
try:
# now just add the archive file as a new one
with ZipFile(self.path, mode="a", allowZip64=True, compression=zipfile.ZIP_DEFLATED) as zf:
if archive_file in files:
zf.repack([zf.remove(archive_file)])
zf.writestr(archive_file, data)
return True
except (zipfile.BadZipfile, OSError) as e:
logger.error("Error writing zip archive [%s]: %s :: %s", e, self.path, archive_file)
return False
def get_filename_list(self) -> list[str]:
if self._filename_list:
return self._filename_list
try:
with ZipFile(self.path, mode="r") as zf:
self._filename_list = [file.filename for file in zf.infolist() if not file.is_dir()]
return self._filename_list
except (zipfile.BadZipfile, OSError) as e:
logger.error("Error listing files in zip archive [%s]: %s", e, self.path)
return []
def supports_files(self) -> bool:
return True
def rebuild(self, exclude_list: list[str]) -> bool:
"""Zip helper func
This recompresses the zip archive, without the files in the exclude_list
"""
self._filename_list = []
try:
with ZipFile(
tempfile.NamedTemporaryFile(dir=os.path.dirname(self.path), delete=False), "w", allowZip64=True
) as zout:
with ZipFile(self.path, mode="r") as zin:
for item in zin.infolist():
buffer = zin.read(item.filename)
if item.filename not in exclude_list:
zout.writestr(item, buffer)
# preserve the old comment
zout.comment = zin.comment
# replace with the new file
self.path.unlink(missing_ok=True)
zout.close() # Required on windows
shutil.move(cast(str, zout.filename), self.path)
except (zipfile.BadZipfile, OSError) as e:
logger.error("Error rebuilding zip file [%s]: %s", e, self.path)
return False
return True
def copy_from_archive(self, other_archive: Archiver) -> bool:
"""Replace the current zip with one copied from another archive"""
self._filename_list = []
try:
with ZipFile(self.path, mode="w", allowZip64=True) as zout:
for filename in other_archive.get_filename_list():
data = other_archive.read_file(filename)
if data is not None:
zout.writestr(filename, data)
# preserve the old comment
comment = other_archive.get_comment()
if comment is not None:
if not self.set_comment(comment):
return False
except Exception as e:
logger.error("Error while copying to zip archive [%s]: from %s to %s", e, other_archive.path, self.path)
return False
else:
return True
def is_writable(self) -> bool:
return True
def extension(self) -> str:
return ".cbz"
def name(self) -> str:
return "ZIP"
@classmethod
def is_valid(cls, path: pathlib.Path) -> bool:
return zipfile.is_zipfile(path) # only checks central directory ot the end of the archive

View File

@ -0,0 +1,16 @@
from __future__ import annotations
from .comicfile import BadComic, ComicFile, WrongType
from .folder import FolderComic
from .zip import ZipComic
class UnknownArchiver(ComicFile):
name = "Unknown"
enabled = False
tag_locations = frozenset()
extension = ""
supported_extensions = frozenset()
__all__ = ("ComicFile", "UnknownArchiver", "FolderComic", "ZipComic", "BadComic", "WrongType")

220
comicapi/comic/comicfile.py Normal file
View File

@ -0,0 +1,220 @@
from __future__ import annotations
import pathlib
from collections.abc import Collection, Iterable
from typing import ClassVar
from comicapi.genericmetadata import GenericMetadata
from comicapi.tags import TagLocation
class WrongType(Exception): ...
class BadComic(Exception): ...
class ComicFile:
"""A file containing a comic"""
name: ClassVar[str]
"""
Display name for this ComicFile
Should be considered a ReadOnly attribute.
"""
enabled: ClassVar[bool]
"""
When false ComicTagger will refuse to load this ComicFile.
If external imports are required and are not available this should be false. See rar.py and sevenzip.py.
Should be considered a ReadOnly attribute.
"""
tag_locations: ClassVar[frozenset[TagLocation]]
extension: ClassVar[str]
"""
Canonical extension.
eg the Zip ComicFile uses ".cbz"
Should be considered a ReadOnly attribute.
"""
supported_extensions: ClassVar[frozenset[str]] = frozenset()
"""
Only used to optimize ComicFile selection.
For example the Zip ComicFile sets this to {".cbz", ".zip"}.
`check_path` will be called to verify that an archive can be opened.
Should be considered a ReadOnly attribute.
"""
exe: ClassVar[str] = ""
"""
Name of executable needed.
eg The RAR ComicFile needs the 'rar' executable to write rar files
The caller may set this value to explicitly state which excutable to use
Should be considered a ReadOnly attribute.
"""
supported_attributes: ClassVar[set[str]] = set()
"""
Only needed when tag_storage includes TagLocation.CUSTOM
For display to user in GUI. Has no effect on data processed or given
"""
path: pathlib.Path
"""The path to the archive"""
def __init__(self, path: pathlib.Path) -> None:
"""
Opens the given archive.
`check_path` will always be called before this.
If the path does not exist then this is a new comic and the file should be created.
Raise `comicapi.comic.BadComic` if the comic is corrupt but the correct archive type.
Raise `comicapi.comic.WrongType` if the comic is not the correct type.
NOTE: is_7zfile from py7zr does not validate that py7zr can open the file
MUST not keep an open file.
"""
self.path = path
def get_filename_list(self) -> list[str]:
"""
Returns a list of filenames in the archive.
It is recommended to cache this list for performance.
"""
raise NotImplementedError
def is_writable(self) -> bool:
"""
Retuns True if the archive is writeable.
eg RAR archives can only be written if the rar executable is available.
"""
raise NotImplementedError
def read_comment(self) -> str:
"""
Returns the comment from the archive as a string.
Not Required if `comment_allowed` is False
"""
raise NotImplementedError
def write_comment(self, comment: str) -> None:
"""
If an empty string is provided, remove the comment.
Not Required if `comment_allowed` is False
if get_filename_list is cached evict it during this call.
"""
raise NotImplementedError
def read_file(self, filename: str) -> bytes:
"""
Reads the named file from the archive.
filename should always come directly from the output of get_filename_list.
"""
raise NotImplementedError
def read_files(self, filenames: Collection[str]) -> Iterable[tuple[str, bytes]]:
"""
Reads the named files from the archive.
filenames should always come from the output of get_filename_list.
Specifically this is used in an attempt to optimize exporting to a different Archive type.
The iterable must always be completely read. If in doubt make a list.
```
files_iterable = ComicFile.read_files(['file1','file2'])
files = list(files_iterable)
```
"""
for file in filenames:
yield file, self.read_file(file)
def write_file(self, filename: str, data: bytes) -> None:
"""
Writes the named file to the archive.
if get_filename_list is cached evict it during this call.
"""
raise NotImplementedError
def write_files(self, *, files: Iterable[tuple[str, bytes]], filenames: Collection[str]) -> None:
"""
Specifically this is used in an attempt to optimize exporting to a different Archive type.
`files` must always be completely read. If in doubt make a list.
```
files = list(files)
```
if get_filename_list is cached evict it during this call.
"""
try:
for name, data in files:
self.write_file(name, data)
except Exception:
list(files)
raise
def remove_files(self, filenames: Collection[str]) -> None:
"""
Removes the named files from the archive.
filename will always come from the output of get_filename_list.
if get_filename_list is cached evict it during this call.
Rebuilding the archive without the named file is a standard (but incredibly inefficient) way to remove a file.
"""
raise NotImplementedError
@staticmethod
def check_path(path: pathlib.Path) -> None:
"""
Check if the given path is valid for this ComicFile.
This method should do basic identification checks, validity and corruption checks can be done in `open`.
Raise `comicapi.comic.BadComic` if the comic is corrupt but the correct comic type.
Raise `comicapi.comic.WrongType` if the comic is not the correct type.
"""
raise NotImplementedError
def validate_comic(self) -> None:
"""Full validation of comic. Not required to be implemented"""
raise NotImplementedError
# ---
def display_tags(self) -> str:
"""Only needed when when tag_storage includes TagLocation.CUSTOM. See `comicapi.tags.Tag.display_tags`"""
raise NotImplementedError
def load_tags(self) -> GenericMetadata:
"""Only needed when when tag_storage includes TagLocation.CUSTOM. See `comicapi.tags.Tag.read_tags`"""
raise NotImplementedError
def write_tags(self, version: str, metadata: GenericMetadata) -> None:
"""Only needed when when tag_storage includes TagLocation.CUSTOM. See `comicapi.tags.Tag.write_tags`"""
raise NotImplementedError
def has_tags(self) -> bool:
"""Only needed when when tag_storage includes TagLocation.CUSTOM"""
raise NotImplementedError
def remove_tags(self) -> None:
"""Only needed when when tag_storage includes TagLocation.CUSTOM"""
raise NotImplementedError
def supports_credit_role(self, role: str) -> bool:
"""
Only needed when tag_storage includes TagLocation.CUSTOM
For display to user in GUI. Has no effect on data processed or given
"""
raise NotImplementedError

61
comicapi/comic/folder.py Normal file
View File

@ -0,0 +1,61 @@
from __future__ import annotations
import logging
import os
import pathlib
from collections.abc import Collection
from comicapi.comic import ComicFile, WrongType
from comicapi.tags.tag import TagLocation
logger = logging.getLogger(__name__)
class FolderComic(ComicFile):
"""Folder implementation"""
name = "Folder"
enabled = True
tag_locations = frozenset((TagLocation.FILE,))
extension = ""
supported_extensions = frozenset()
def __init__(self, path: pathlib.Path) -> None:
super().__init__(path)
self._filename_list: list[str] = []
def get_filename_list(self) -> list[str]:
if self._filename_list:
return self._filename_list
filenames = []
for root, _dirs, files in os.walk(self.path):
for f in files:
filenames.append(os.path.relpath(os.path.join(root, f), self.path).replace(os.path.sep, "/"))
self._filename_list = filenames
return filenames
def read_file(self, filename: str) -> bytes:
return (self.path / filename).read_bytes()
def remove_files(self, filenames: Collection[str]) -> None:
self._filename_list = []
for filename in filenames:
(self.path / filename).unlink(missing_ok=True)
def write_file(self, filename: str, data: bytes) -> None:
self._filename_list = []
file_path = self.path / filename
file_path.parent.mkdir(exist_ok=True, parents=True)
file_path.write_bytes(data)
def is_writable(self) -> bool:
return True
@staticmethod
def check_path(path: pathlib.Path) -> None:
if path.is_dir():
return
raise WrongType

304
comicapi/comic/rar.py Normal file
View File

@ -0,0 +1,304 @@
from __future__ import annotations
import functools
import logging
import os
import pathlib
import platform
import shutil
import subprocess
import tempfile
from collections.abc import Collection, Iterable
from comicapi.comic import BadComic, WrongType
from comicapi.tags.tag import TagLocation
try:
import rarfile
rar_support = True
except ImportError:
rar_support = False
logger = logging.getLogger(__name__)
if not rar_support:
logger.error("rar unavailable")
# windows only, keeps the cmd.exe from popping up
STARTUPINFO = None
if platform.system() == "Windows":
STARTUPINFO = subprocess.STARTUPINFO() # type: ignore
STARTUPINFO.dwFlags |= subprocess.STARTF_USESHOWWINDOW # type: ignore
class RarComic:
"""RAR implementation"""
name = "RAR"
enabled = rar_support
tag_locations = frozenset((TagLocation.FILE, TagLocation.COMMENT))
extension = ".cbr"
supported_extensions = frozenset({".cbr", ".rar"})
exe = "rar"
_rar: rarfile.RarFile | None = None
_rar_setup: rarfile.ToolSetup | None = None
_writeable: bool | None = None
def __init__(self, path: pathlib.Path) -> None:
self.path = path
self._filename_list: list[str] = []
def read_comment(self) -> str:
rarc = self._get_rar_obj()
return (rarc.comment if rarc else "") or ""
def write_comment(self, comment: str) -> None:
self._reset()
if not self.is_writable():
return
try:
# write comment to temp file
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_file = pathlib.Path(tmp_dir) / "rar_comment.txt"
tmp_file.write_text(comment, encoding="utf-8")
working_dir = os.path.dirname(os.path.abspath(self.path))
# use external program to write comment to Rar archive
proc_args = [
self.exe,
"c",
f"-w{working_dir}",
"-c-",
f"-z{tmp_file}",
str(self.path),
]
result = subprocess.run(
proc_args,
startupinfo=STARTUPINFO,
stdin=subprocess.DEVNULL,
capture_output=True,
encoding="utf-8",
cwd=tmp_dir,
)
except Exception as e:
logger.exception("Error writing comment to rar archive [%s]: %s", e, self.path)
raise OSError(f"Error writing comment to rar archive [{e}]: {self.path}")
if result.returncode != 0:
logger.error(
"Error writing comment to rar archive [exitcode: %d]: %s :: %s",
result.returncode,
self.path,
result.stderr,
)
raise OSError(
f"Error writing comment to rar archive check log for more information [exitcode: {result.returncode}]: {self.path}"
)
def supports_comment(self) -> bool:
return True
def read_file(self, filename: str) -> bytes:
rarc = self._get_rar_obj()
entry: tuple[rarfile.RarInfo, bytes] = (rarfile.RarInfo(), b"")
try:
data: bytes = rarc.open(filename).read()
entry = (rarc.getinfo(filename), data)
except rarfile.Error as e:
logger.error("Error reading file from rar archive [%s]: %s :: %s", e, self.path, filename)
raise BadComic(f"Error reading file from rar archive [{e}]: {self.path} :: {filename}")
if entry[0].file_size != len(entry[1]):
raise BadComic(
'"Error reading rar archive [file is not expected size: {:d} vs {:d}] {} :: {}"'.format(
entry[0].file_size,
len(entry[1]),
self.path,
filename,
)
)
return entry[1]
def read_files(self, filenames: Collection[str]) -> Iterable[bytes]:
for filename in filenames:
yield self.read_file(filename)
def remove_files(self, filenames: Collection[str]) -> None:
self._reset()
if not self.is_writable():
return
working_dir = os.path.dirname(os.path.abspath(self.path))
try:
result = subprocess.run(
[self.exe, "d", f"-w{working_dir}", "-c-", self.path, *filenames],
startupinfo=STARTUPINFO,
stdin=subprocess.DEVNULL,
capture_output=True,
encoding="utf-8",
cwd=self.path.absolute().parent,
)
except Exception as e:
raise OSError(f"Error removing files from rar archive [{e}]: {self.path}:: {filenames}")
if result.returncode != 0:
logger.error(
"Error removing file from rar archive [exitcode: %d]: %s :: %s",
result.returncode,
self.path,
filenames,
)
raise RuntimeError(
"Error removing file from rar archive check log for more information [exitcode: {:d}]: {} :: {}".format(
result.returncode,
self.path,
filenames,
)
)
def write_file(self, filename: str, data: bytes) -> None:
self._reset()
if not self.is_writable():
return
archive_path = pathlib.PurePosixPath(filename)
archive_name = archive_path.name
archive_parent = str(archive_path.parent).lstrip("./")
working_dir = os.path.dirname(os.path.abspath(self.path))
try:
# use external program to write file to Rar archive
result = subprocess.run(
[
self.exe,
"a",
f"-w{working_dir}",
f"-si{archive_name}",
f"-ap{archive_parent}",
"-c-",
"-ep",
self.path,
],
input=data,
startupinfo=STARTUPINFO,
capture_output=True,
cwd=self.path.absolute().parent,
)
except Exception as e:
raise OSError(f"Error writing file to rar archive [{e}]: {self.path}:: {filename}")
if result.returncode != 0:
logger.error(
"Error writing rar archive [exitcode: %d]: %s :: %s :: %s",
result.returncode,
self.path,
filename,
result.stderr,
)
raise OSError(
f"Error writing file to rar archive check log for more information [exitcode: {result.returncode}]: {self.path}:: {filename}"
)
def write_files(self, *, files: Iterable[tuple[str, bytes]], filenames: Collection[str]) -> None:
# TODO: Write everything out to a temp directory first for performance
try:
for filename, data in files:
self.write_file(filename, data)
except Exception:
list(files)
raise
def get_filename_list(self) -> list[str]:
if not self.path.exists():
return []
if self._filename_list:
return self._filename_list
rarc = self._get_rar_obj()
namelist = []
for item in rarc.infolist(): # infolist should never cause an exception it's only an attribute access
if item.file_size != 0:
namelist.append(item.filename)
self._filename_list = namelist
return self._filename_list
def is_writable(self) -> bool:
return bool(self._writeable and bool(self.exe and (os.path.exists(self.exe) or shutil.which(self.exe))))
def validate_archive(self) -> None:
rarc = self._get_rar_obj()
try:
rarc.testrar()
except rarfile.Error as e:
raise BadComic(f"Error testing files in zip archive: {self.path} :: [{e}]")
@staticmethod
def check_path(path: pathlib.Path) -> None:
RarComic._setup_rar()
try:
if not rarfile.is_rarfile(str(path)):
raise WrongType
except rarfile.RarCannotExec as e:
raise BadComic(e)
@classmethod
def _setup_rar(cls) -> None:
if cls._rar_setup is None:
orig = rarfile.UNRAR_TOOL
rarfile.UNRAR_TOOL = cls.exe
try:
cls._rar_setup = rarfile.tool_setup(sevenzip=False, sevenzip2=False, force=True)
except rarfile.RarCannotExec:
rarfile.UNRAR_TOOL = orig
try:
cls._rar_setup = rarfile.tool_setup(force=True)
except rarfile.RarCannotExec as e:
logger.info(e)
if cls._writeable is None:
try:
cls._writeable = (
subprocess.run(
(cls.exe,),
startupinfo=STARTUPINFO,
capture_output=True,
# cwd=cls.path.absolute().parent,
)
.stdout.strip()
.startswith(b"RAR")
)
except OSError:
cls._writeable = False
if not cls._writeable:
cls._log_not_writeable(cls.exe or "rar")
@classmethod
@functools.cache
def _log_not_writeable(cls, exe: str) -> None:
logger.warning("Unable to find a useable copy of %r, will not be able to write rar files", exe)
def _reset(self) -> None:
self._rar = None
self._filename_list = []
def _get_rar_obj(self) -> rarfile.RarFile:
if self._rar is not None:
return self._rar
try:
rarc = rarfile.RarFile(str(self.path))
self._rar = rarc
return rarc
except rarfile.NotRarFile:
raise WrongType
except rarfile.Error as e:
raise BadComic(f"Unable to get rar object [{e}]: {self.path}")

152
comicapi/comic/sevenzip.py Normal file
View File

@ -0,0 +1,152 @@
from __future__ import annotations
import logging
import os
import pathlib
import shutil
import tempfile
from collections.abc import Collection, Iterable
from typing import IO, BinaryIO, cast
from comicapi.comic import BadComic, ComicFile, WrongType
from comicapi.tags.tag import TagLocation
try:
import py7zr
z7_support = True
except ImportError:
z7_support = False
logger = logging.getLogger(__name__)
class SevenZipComic(ComicFile):
"""7Z implementation"""
name = "Seven Zip"
enabled = z7_support
tag_locations = frozenset((TagLocation.FILE,))
extension = "cb7"
supported_extensions = frozenset((".7z", ".cb7"))
def __init__(self, path: pathlib.Path) -> None:
super().__init__(path)
if not path.exists():
with py7zr.SevenZipFile(self.path, mode="w"):
...
self._filename_list: list[str] = []
def read_file(self, filename: str) -> bytes:
try:
with py7zr.SevenZipFile(self.path, "r") as zf:
data = cast(dict[str, IO[bytes]], zf.read([filename]))[filename].read() # typing is wrong in py7zr
return data
except (py7zr.Bad7zFile, OSError) as e:
raise BadComic(f"Error reading file in 7zip archive [{e}]: {self.path} :: {filename}") from e
def read_files(self, filenames: Collection[str]) -> Iterable[tuple[str, bytes]]:
with py7zr.SevenZipFile(self.path, mode="r") as zin:
for filename, buffer in cast(dict[str, IO[bytes]], zin.read(filenames)).items():
yield filename, buffer.read()
def remove_files(self, filenames: Collection[str]) -> None:
self._filename_list = []
return self._rebuild(filenames)
def write_file(self, filename: str, data: bytes) -> None:
# At the moment, no other option but to rebuild the whole
# archive w/o the indicated file. Very sucky, but maybe
# another solution can be found
files = self.get_filename_list()
self._filename_list = []
if filename in files:
self.remove_files([filename])
try:
# now just add the archive file as a new one
with py7zr.SevenZipFile(self.path, "a") as zf:
zf.writestr(data, filename)
except py7zr.Bad7zFile as e:
raise BadComic(f"Error writing file in 7zip archive [{e}]: {self.path} :: {filename}") from e
def write_files(self, *, files: Iterable[tuple[str, bytes]], filenames: Collection[str]) -> None:
# py7zr treats all archives as if they used solid compression
# so we need to get the filename list first to read all the files at once
self._filename_list = []
current_filenames = self.get_filename_list()
existing_files = set(current_filenames).difference(filenames)
try:
with tempfile.NamedTemporaryFile(dir=os.path.dirname(self.path), delete=False) as tmp_file:
# py7zr has wrong typing here it accepts IO[bytes]
with py7zr.SevenZipFile(cast(BinaryIO, tmp_file.file), mode="w") as zout:
with py7zr.SevenZipFile(self.path, mode="r") as zin:
old_files = cast(dict[str, IO[bytes]], zin.read())
for filename in existing_files:
zout.writef(old_files[filename], filename)
for filename, buffer in files:
zout.writestr(buffer, filename)
self.path.unlink(missing_ok=True)
tmp_file.close() # Required on windows
shutil.move(tmp_file.name, self.path)
except py7zr.Bad7zFile as e:
list(files)
raise BadComic(f"Error rebuilding 7zip archive [{e}]: {self.path}") from e
def get_filename_list(self) -> list[str]:
if self._filename_list:
return self._filename_list
try:
with py7zr.SevenZipFile(self.path, "r") as zf:
namelist: list[str] = [file.filename for file in zf.list() if not file.is_directory]
self._filename_list = namelist
return namelist
except py7zr.Bad7zFile as e:
raise BadComic(f"Error listing files in 7zip archive [{e}]: {self.path}") from e
def _rebuild(self, exclude_list: Collection[str]) -> None:
# py7zr treats all archives as if they used solid compression
# so we need to get the filename list first to read all the files at once
self._filename_list = []
filenames = self.get_filename_list()
targets = set(filenames).difference(exclude_list)
if targets == set(filenames):
return
try:
with tempfile.NamedTemporaryFile(dir=os.path.dirname(self.path), delete=False) as tmp_file:
# py7zr has wrong typing here it accepts IO[bytes]
with py7zr.SevenZipFile(cast(BinaryIO, tmp_file.file), mode="w") as zout:
with py7zr.SevenZipFile(self.path, mode="r") as zin:
for filename, buffer in cast(dict[str, IO[bytes]], zin.read(targets)).items():
zout.writef(buffer, filename)
self.path.unlink(missing_ok=True)
tmp_file.close() # Required on windows
shutil.move(tmp_file.name, self.path)
except py7zr.Bad7zFile as e:
raise BadComic(f"Error rebuilding 7zip archive [{e}]: {self.path}") from e
def is_writable(self) -> bool:
return True
def validate_archive(self) -> None:
with py7zr.SevenZipFile(self.path, mode="r") as zf:
filename = zf.testzip()
if filename:
raise BadComic(f"Error testing files in zip archive: {self.path} :: {filename}")
@staticmethod
def check_path(path: pathlib.Path) -> None:
if not py7zr.is_7zfile(path):
raise WrongType

143
comicapi/comic/zip.py Normal file
View File

@ -0,0 +1,143 @@
from __future__ import annotations
import logging
import pathlib
from collections.abc import Collection, Iterable
import chardet
from zipremove import ZIP_DEFLATED, BadZipfile, ZipFile, is_zipfile
from comicapi.comic.comicfile import BadComic, WrongType
from comicapi.tags.tag import TagLocation
logger = logging.getLogger(__name__)
class ZipComic:
"""ZIP implementation"""
name = "ZIP"
enabled = True
tag_locations = frozenset((TagLocation.FILE, TagLocation.COMMENT))
extension = ".cbz"
supported_extensions = frozenset((".cbz", ".zip"))
exe = ""
def __init__(self, path: pathlib.Path) -> None:
self.path = path
if not self.path.exists():
with ZipFile(self.path, mode="w"):
...
self._filename_list: list[str] = []
def get_filename_list(self) -> list[str]:
if self._filename_list:
return self._filename_list
try:
with ZipFile(self.path, mode="r") as zf:
self._filename_list = [file.filename for file in zf.infolist() if not file.is_dir()]
return self._filename_list
except BadZipfile as e:
raise BadComic(f"Error listing files in zip archive [{e}]: {self.path}") from e
def read_comment(self) -> str:
comment = ""
with ZipFile(self.path, "r") as zf:
encoding = chardet.detect(zf.comment, True)
if encoding["confidence"] > 60:
try:
comment = zf.comment.decode(encoding["encoding"])
except UnicodeDecodeError:
comment = zf.comment.decode("utf-8", errors="replace")
else:
comment = zf.comment.decode("utf-8", errors="replace")
return comment
def write_comment(self, comment: str) -> None:
try:
with ZipFile(self.path, mode="a") as zf:
zf.comment = bytes(comment, "utf-8")
except BadZipfile as e:
raise BadComic(f"Error writing zip comment [{e}]: {self.path}") from e
def read_file(self, filename: str) -> bytes:
data = b""
with ZipFile(self.path, mode="r") as zf:
try:
data = zf.read(filename)
except BadZipfile as e:
raise BadComic(f"Error reading file in zip archive [{e}]: {self.path} :: {filename}") from e
return data
def read_files(self, filenames: Collection[str]) -> Iterable[tuple[str, bytes]]:
for filename in filenames:
with ZipFile(self.path, mode="r") as zf:
try:
yield filename, zf.read(filename)
except BadZipfile as e:
raise BadComic(f"Error reading file in zip archive [{e}]: {self.path} :: {filename}") from e
def remove_files(self, filenames: Collection[str]) -> None:
files = self.get_filename_list()
self._filename_list = []
try:
with ZipFile(self.path, mode="a", allowZip64=True, compression=ZIP_DEFLATED) as zf:
removed_files = []
for filename in filenames:
if filename in files:
removed_files.append(zf.remove(filename))
zf.repack(removed_files)
except BadZipfile as e:
raise BadComic(f"Error removing file in zip archive [{e}]: {self.path} :: {filenames}") from e
def write_files(self, *, files: Iterable[tuple[str, bytes]], filenames: Collection[str]) -> None:
filenames = self.get_filename_list()
self._filename_list = []
filename = ""
try:
with ZipFile(self.path, mode="a", allowZip64=True, compression=ZIP_DEFLATED) as zf:
removed = []
for name, data in files:
filename = name
if name in filenames:
removed.append(zf.remove(name))
zf.writestr(name, data)
filename = ""
zf.repack(removed)
except BadZipfile as e:
list(files)
raise BadComic(f"Error writing zip archive [{e}]: {self.path} :: {filename}") from e
except Exception:
list(files)
raise
def write_file(self, filename: str, data: bytes) -> None:
files = self.get_filename_list()
self._filename_list = []
try:
with ZipFile(self.path, mode="a", allowZip64=True, compression=ZIP_DEFLATED) as zf:
if filename in files:
zf.repack([zf.remove(filename)])
zf.writestr(filename, data)
except BadZipfile as e:
raise BadComic(f"Error writing zip archive [{e}]: {self.path} :: {filename}") from e
def is_writable(self) -> bool:
return True
def validate_archive(self) -> None:
with ZipFile(self.path, mode="r") as zf:
filename = zf.testzip()
if filename:
raise BadComic(f"Error testing files in zip archive: {self.path} :: {filename}")
@staticmethod
def check_path(path: pathlib.Path) -> None:
if not is_zipfile(path): # only checks central directory at the end of the archive
raise WrongType

View File

@ -25,29 +25,30 @@ import os
import pathlib
import shutil
import sys
from collections.abc import Iterable
from collections.abc import Collection, Iterable
from comicapi import utils
from comicapi.archivers import Archiver, UnknownArchiver, ZipArchiver
from comicapi.comic import ComicFile, UnknownArchiver, WrongType
from comicapi.genericmetadata import FileHash, GenericMetadata
from comicapi.tags import Tag
from comicapi.tags.tag import TagLocation
from comictaggerlib.ctversion import version
logger = logging.getLogger(__name__)
archivers: list[type[Archiver]] = []
tags: dict[str, Tag] = {}
archivers: list[type[ComicFile]] = []
loaded_tags: dict[str, Tag] = {}
def load_archive_plugins(local_plugins: Iterable[type[Archiver]] = tuple()) -> None:
def load_archive_plugins(local_plugins: Iterable[type[ComicFile]] = tuple()) -> None:
if archivers:
return
if sys.version_info < (3, 10):
from importlib_metadata import entry_points
else:
from importlib.metadata import entry_points
builtin: list[type[Archiver]] = []
archive_plugins: list[type[Archiver]] = []
builtin: list[type[ComicFile]] = []
archive_plugins: list[type[ComicFile]] = []
# A list is used first matching plugin wins
for ep in itertools.chain(entry_points(group="comicapi.archiver")):
@ -56,8 +57,10 @@ def load_archive_plugins(local_plugins: Iterable[type[Archiver]] = tuple()) -> N
except ValueError:
spec = None
try:
archiver: type[Archiver] = ep.load()
archiver: type[ComicFile] = ep.load()
if not archiver.enabled:
logger.info("Archiver %r (%s) is disabled. Refusing to load archiver plugin", archiver.name, ep.name)
continue
if ep.module.startswith("comicapi"):
builtin.append(archiver)
else:
@ -73,8 +76,30 @@ def load_archive_plugins(local_plugins: Iterable[type[Archiver]] = tuple()) -> N
archivers.extend(builtin)
__custom_tags: dict[str, type[Tag]] = {}
def custom_tag(comic_file: type[ComicFile]) -> type[Tag]:
tag_id = f"custom_{comic_file.__name__.lower()}"
if tag_id in __custom_tags:
return __custom_tags[tag_id]
class ClassName(Tag):
id = tag_id
name = comic_file.name
enabled = comic_file.enabled
location = TagLocation.CUSTOM
supported_attributes = comic_file.supported_attributes
_comic_file = comic_file
ClassName.__name__ = comic_file.__name__ + "Tag"
return __custom_tags.setdefault(tag_id, ClassName)
def load_tag_plugins(version: str = f"ComicAPI/{version}", local_plugins: Iterable[type[Tag]] = tuple()) -> None:
if tags:
if loaded_tags:
return
if sys.version_info < (3, 10):
from importlib_metadata import entry_points
@ -82,6 +107,7 @@ def load_tag_plugins(version: str = f"ComicAPI/{version}", local_plugins: Iterab
from importlib.metadata import entry_points
builtin: dict[str, Tag] = {}
tag_plugins: dict[str, tuple[Tag, str]] = {}
custom_tag_plugins: dict[str, Tag] = {}
# A dict is used, last plugin wins
for ep in entry_points(group="comicapi.tags"):
location = "Unknown"
@ -93,10 +119,13 @@ def load_tag_plugins(version: str = f"ComicAPI/{version}", local_plugins: Iterab
location = "Unknown"
try:
tag: type[Tag] = ep.load()
tagClass: type[Tag] = ep.load()
tag = tagClass() # tags are instantiated only because it makes typing simpler
if not tag.enabled:
logger.info("Tag %r (%s) is disabled. Refusing to load tag plugin", tag.name, ep.name)
continue
if ep.module.startswith("comicapi"):
builtin[tag.id] = tag(version)
builtin[tag.id] = tag
else:
if tag.id in tag_plugins:
logger.warning(
@ -105,64 +134,87 @@ def load_tag_plugins(version: str = f"ComicAPI/{version}", local_plugins: Iterab
location,
tag.id,
)
tag_plugins[tag.id] = (tag(version), location)
tag_plugins[tag.id] = (tag, location)
except Exception:
logger.exception("Failed to load tag plugin: %s from %s", ep.name, location)
# A dict is used, last plugin wins
for tag in local_plugins:
tag_plugins[tag.id] = (tag(version), "Local")
for tagClass in local_plugins:
tag = tagClass() # tags are instantiated only because it makes typing simpler
if not tag.enabled:
logger.info("Local Tag %r (%s) is disabled. Refusing to load tag plugin", tag.name, tag.id)
continue
tag_plugins[tag.id] = (tag, "Local")
for archive in archivers:
if TagLocation.CUSTOM in archive.tag_locations:
tag = custom_tag(archive)()
custom_tag_plugins[tag.id] = tag
for tag_id in set(builtin.keys()).intersection(tag_plugins):
location = tag_plugins[tag_id][1]
logger.warning("Builtin plugin for %s tags are being overridden by a plugin from %s", tag_id, location)
tags.clear()
tags.update(builtin)
tags.update({s[0]: s[1][0] for s in tag_plugins.items()})
loaded_tags.clear()
loaded_tags.update(builtin)
loaded_tags.update({s[0]: s[1][0] for s in tag_plugins.items()})
loaded_tags.update(custom_tag_plugins)
class ComicArchive:
logo_data = b""
pil_available: bool | None = None
def __init__(
self,
path: pathlib.Path | str | Archiver,
default_image_path: pathlib.Path | str | None = None,
path: pathlib.Path | ComicFile,
default_image_path: pathlib.Path | None = None,
hash_archive: str = "",
) -> None:
self.md: dict[str, GenericMetadata] = {}
self.page_count: int | None = None
self.page_list: list[str] = []
self.hash_archive = hash_archive
self.Archiver: type[ComicFile] = UnknownArchiver
self.archiver: ComicFile | None = None
self.reset_cache()
self.default_image_path = default_image_path
if isinstance(path, Archiver):
self.path = path.path
self.archiver: Archiver = path
else:
if isinstance(path, pathlib.Path):
self.path = pathlib.Path(path).absolute()
self.archiver = UnknownArchiver.open(self.path)
load_archive_plugins()
load_tag_plugins()
archiver_missing = True
for archiver in archivers:
if self.path.suffix in archiver.supported_extensions and archiver.is_valid(self.path):
self.archiver = archiver.open(self.path)
archiver_missing = False
break
if archiver_missing:
tried_archivers = []
for archiver in archivers:
if self.path.suffix not in archiver.supported_extensions:
continue
tried_archivers.append(archiver)
try:
archiver.check_path(self.path)
self.Archiver = archiver
break
except WrongType:
continue
if self.Archiver == UnknownArchiver:
for archiver in archivers:
if archiver.enabled and archiver.is_valid(self.path):
self.archiver = archiver.open(self.path)
if archiver in tried_archivers:
continue
try:
archiver.check_path(self.path)
self.Archiver = archiver
break
except WrongType:
continue
else:
self.path = path.path
self.archiver = path
self.Archiver = type(path)
if not ComicArchive.logo_data and self.default_image_path:
with open(self.default_image_path, mode="rb") as fd:
with self.default_image_path.open(mode="rb") as fd:
ComicArchive.logo_data = fd.read()
def reset_cache(self) -> None:
@ -172,95 +224,202 @@ class ComicArchive:
self.page_list.clear()
self.md.clear()
def load_cache(self, tag_ids: Iterable[str]) -> None:
for tag_id in tag_ids:
if tag_id not in tags:
continue
tag = tags[tag_id]
if not tag.enabled:
continue
md = tag.read_tags(self.archiver)
if not md.is_empty:
self.md[tag_id] = md
def _open_archive(self) -> ComicFile:
if self.Archiver is UnknownArchiver:
raise Exception("Archive not opened")
if self.archiver is None:
self.archiver = self.Archiver(self.path)
return self.archiver
def get_supported_tags(self) -> list[str]:
return [tag_id for tag_id, tag in tags.items() if tag.enabled and tag.supports_tags(self.archiver)]
def get_supported_tags(self, tags: Collection[Tag] = loaded_tags.values()) -> list[Tag]:
allowed_locations = self.Archiver.tag_locations - {TagLocation.CUSTOM}
allowed_tags = [tag for tag in tags if tag.location in allowed_locations]
if TagLocation.CUSTOM in self.Archiver.tag_locations:
allowed_tags.append(custom_tag(self.Archiver)())
return allowed_tags
def rename(self, path: pathlib.Path | str) -> None:
new_path = pathlib.Path(path).absolute()
def _supported_tag(self, tag: Tag) -> None:
if tag.location not in self.Archiver.tag_locations:
raise Exception(f"{tag.name} tags Not Supported for Comic {self.Archiver.name}")
if tag.location == TagLocation.CUSTOM:
if tag._comic_file != self.Archiver: # type: ignore[attr-defined]
raise Exception(f"{tag.name} tags are only supported on {tag._comic_file.name} not {self.Archiver.name}") # type: ignore[attr-defined]
def rename(self, path: pathlib.Path) -> None:
if self.archiver is not None:
# self.archiver.close()
self.archiver = None
new_path = path.absolute()
if new_path == self.path:
return
os.makedirs(new_path.parent, 0o777, True)
shutil.move(self.path, new_path)
self.path = new_path
self.archiver.path = pathlib.Path(path)
def is_writable(self, check_archive_status: bool = True) -> bool:
if isinstance(self.archiver, UnknownArchiver):
return False
if check_archive_status and not self.archiver.is_writable():
return False
if not (os.access(self.path, os.W_OK) or os.access(self.path.parent, os.W_OK)):
return False
if check_archive_status:
self.archiver = self._open_archive()
if not self.archiver.is_writable():
return False
return True
def is_zip(self) -> bool:
return self.archiver.name() == "ZIP"
return self.Archiver.extension == ".cbz"
def seems_to_be_a_comic_archive(self) -> bool:
if (
not (isinstance(self.archiver, UnknownArchiver))
and self.get_number_of_pages() > 0
and self.archiver.is_valid(self.path)
):
return True
if self.Archiver is UnknownArchiver:
return False
try:
self.Archiver.check_path(self.path)
return self.get_number_of_pages() > 0
except Exception:
...
return False
def extension(self) -> str:
return self.archiver.extension()
return self.Archiver.extension
def read_tags(self, tag_id: str) -> GenericMetadata:
if tag_id in self.md:
return self.md[tag_id]
def read_tags(self, tag: Tag) -> GenericMetadata:
self._supported_tag(tag)
if tag.id in self.md:
return self.md[tag.id]
md = GenericMetadata()
tag = tags[tag_id]
if tag.enabled and tag.has_tags(self.archiver):
md = tag.read_tags(self.archiver)
md.apply_default_page_list(self.get_page_name_list())
if tag.location == TagLocation.COMMENT:
a = self._open_archive()
comment = a.read_comment()
if not tag.validate_tags(comment.encode(encoding="utf-8")):
return md
md = tag.load_tags(comment.encode(encoding="utf-8"))
if tag.location == TagLocation.FILE:
filename = self._find_file(tag)
if filename == "":
return md
a = self._open_archive()
file_content = a.read_file(filename)
if not tag.validate_tags(file_content):
return md
md = tag.load_tags(file_content)
if tag.location == TagLocation.CUSTOM:
a = self._open_archive()
if not a.has_tags():
return md
md = a.load_tags()
md.apply_default_page_list(self.get_page_name_list())
return md
def read_raw_tags(self, tag_id: str) -> str:
if not tags[tag_id].enabled:
def _find_file(self, tag: Tag) -> str:
a = self._open_archive()
filenames = a.get_filename_list()
if not filenames:
return ""
return tags[tag_id].read_raw_tags(self.archiver)
if tag.filename_match[0] == "*":
for name in filenames:
if name.endswith(tag.filename_match[1:]):
return name
return ""
for name in filenames:
if name == tag.filename_match:
return name
return ""
def write_tags(self, metadata: GenericMetadata, tag_id: str) -> bool:
if tag_id in self.md:
del self.md[tag_id]
if not tags[tag_id].enabled:
logger.warning("%s tags not enabled", tags[tag_id].name())
return False
def read_raw_tags(self, tag: Tag) -> str:
self._supported_tag(tag)
if tag.location == TagLocation.COMMENT:
a = self._open_archive()
content = a.read_comment()
return tag.display_tags(content.encode(encoding="utf-8"))
if tag.location == TagLocation.FILE:
filename = self._find_file(tag)
if filename == "":
return ""
a = self._open_archive()
file_content = a.read_file(filename)
return tag.display_tags(file_content)
if tag.location == TagLocation.CUSTOM:
a = self._open_archive()
return a.display_tags()
return ""
def write_tags(self, version: str, metadata: GenericMetadata, tag: Tag) -> None:
self._supported_tag(tag)
if tag.id in self.md:
del self.md[tag.id]
self.apply_archive_info_to_metadata(metadata, True, True, hash_archive=self.hash_archive)
return tags[tag_id].write_tags(metadata, self.archiver)
if tag.location == TagLocation.COMMENT:
a = self._open_archive()
content = a.read_comment()
return a.write_comment(tag.create_tags(version, metadata, content.encode(encoding="utf-8")).decode("utf-8"))
if tag.location == TagLocation.FILE:
filename = self._find_file(tag)
file_content = b""
a = self._open_archive()
if filename:
file_content = a.read_file(filename)
else:
filename = tag.filename
def has_tags(self, tag_id: str) -> bool:
if tag_id in self.md:
return True
if not tags[tag_id].enabled:
return False
return tags[tag_id].has_tags(self.archiver)
return a.write_file(filename, tag.create_tags(version, metadata, file_content))
if tag.location == TagLocation.CUSTOM:
a = self._open_archive()
return a.write_tags(version, metadata)
def remove_tags(self, tag_id: str) -> bool:
if tag_id in self.md:
del self.md[tag_id]
if not tags[tag_id].enabled:
return False
return tags[tag_id].remove_tags(self.archiver)
def has_tags(self, tag: Tag) -> bool:
self._supported_tag(tag)
if tag.location == TagLocation.COMMENT:
a = self._open_archive()
comment = a.read_comment()
return tag.validate_tags(comment.encode(encoding="utf-8"))
if tag.location == TagLocation.FILE:
filename = self._find_file(tag)
if filename == "":
return False
a = self._open_archive()
file_content = a.read_file(filename)
return tag.validate_tags(file_content)
if tag.location == TagLocation.CUSTOM:
a = self._open_archive()
return a.has_tags()
return False
def remove_tags(self, tag: Tag) -> None:
self._supported_tag(tag)
if tag.id in self.md:
del self.md[tag.id]
if tag.location == TagLocation.COMMENT:
a = self._open_archive()
return a.write_comment("")
if tag.location == TagLocation.FILE:
filename = self._find_file(tag)
if filename == "":
return
a = self._open_archive()
return a.remove_files([filename])
if tag.location == TagLocation.CUSTOM:
a = self._open_archive()
return a.remove_tags()
def load_cache(self, loaded_tags: Iterable[Tag]) -> None:
for tag in loaded_tags:
try:
md = self.read_tags(tag)
if not md.is_empty:
self.md[tag.id] = md
except Exception:
...
def get_page(self, index: int) -> bytes:
image_data = b""
@ -269,7 +428,8 @@ class ComicArchive:
if filename:
try:
image_data = self.archiver.read_file(filename) or b""
a = self._open_archive()
image_data = a.read_file(filename)
except Exception:
logger.exception("Error reading in page %d. Substituting logo page.", index)
image_data = ComicArchive.logo_data
@ -277,9 +437,6 @@ class ComicArchive:
return image_data
def get_page_name(self, index: int) -> str:
if index is None:
return ""
page_list = self.get_page_name_list()
num_pages = len(page_list)
@ -338,8 +495,9 @@ class ComicArchive:
def get_page_name_list(self) -> list[str]:
if not self.page_list:
self.__import_pil__() # Import pillow for list of supported extensions
self.page_list = utils.get_page_name_list(self.archiver.get_filename_list())
utils.initialize_pil()
a = self._open_archive()
self.page_list = utils.get_page_name_list(a.get_filename_list())
return self.page_list
@ -348,22 +506,6 @@ class ComicArchive:
self.page_count = len(self.get_page_name_list())
return self.page_count
def __import_pil__(self) -> bool:
if self.pil_available is not None:
return self.pil_available
try:
from PIL import Image
Image.init()
utils.KNOWN_IMAGE_EXTENSIONS.update([ext for ext, typ in Image.EXTENSION.items() if typ in Image.OPEN])
self.pil_available = True
except Exception:
self.pil_available = False
logger.exception("Failed to load Pillow")
return False
return True
def apply_archive_info_to_metadata(
self,
md: GenericMetadata,
@ -379,9 +521,11 @@ class ComicArchive:
return
if hash_archive in hashlib.algorithms_available and not md.original_hash:
if self.path.is_dir():
return
hasher = getattr(hashlib, hash_archive, hash_archive)
try:
with self.archiver.path.open("b+r") as archive:
with self.path.open("b+r") as archive:
digest = utils.file_digest(archive, hasher)
if len(inspect.signature(digest.hexdigest).parameters) > 0:
length = digest.name.rpartition("_")[2]
@ -391,7 +535,7 @@ class ComicArchive:
else:
md.original_hash = FileHash(digest.name, digest.hexdigest())
except Exception:
logger.exception("Failed to calculate original hash for '%s'", self.archiver.path)
logger.exception("Failed to calculate original hash for '%s'", self.path)
if not calc_page_sizes:
return
for p in md.pages:
@ -399,7 +543,7 @@ class ComicArchive:
try:
data = self.get_page(p.archive_index)
p.byte_size = len(data)
if not data or not self.__import_pil__():
if not data or not utils.initialize_pil():
continue
from PIL import Image
@ -464,10 +608,21 @@ class ComicArchive:
metadata.is_empty = False
return metadata
def export_as_zip(self, zip_filename: pathlib.Path) -> bool:
if self.archiver.name() == "ZIP":
# nothing to do, we're already a zip
return True
# def export_as(self, new_filename: pathlib.Path, extension: str = ".cbz") -> None:
# """
# Copies all content from the current archive to
# """
# export_archiver: ComicFile = UnknownArchiver(new_filename)
# for archiver in archivers:
# if extension == archiver.extension:
# export_archiver = archiver(new_filename)
# if isinstance(export_archiver, UnknownArchiver):
# if extension == ".cbz":
# export_archiver = cast(ComicFile, ZipComic(new_filename))
# else:
# raise Exception(f"Cannot export as {extension}")
# a = self._open_archive()
# export_archiver.write_files(a.read_files(a.get_filename_list()), filenames=a.get_filename_list())
zip_archiver = ZipArchiver.open(zip_filename)
return zip_archiver.copy_from_archive(self.archiver)
# if TagLocation.COMMENT in export_archiver.tag_locations and TagLocation.COMMENT in a.tag_locations:
# export_archiver.write_comment(a.read_comment())

View File

@ -61,7 +61,7 @@ class Credit:
return f"{role}{self.person}{lang}"
class PageType(merge.StrEnum):
class PageType(utils.StrEnum):
"""
These page info classes are exactly the same as the CIX scheme, since
it's unique
@ -94,6 +94,7 @@ class PageMetadata:
width: int | None = None
def set_type(self, value: str) -> None:
"""Normalizes given type to a `PageType` if it is an unknown type it is the same as `type = value`"""
values = {x.casefold(): x for x in PageType}
self.type = values.get(value.casefold(), value)
@ -113,6 +114,7 @@ class PageMetadata:
return self.archive_index == other.archive_index
def _get_clean_metadata(self, *attributes: str) -> PageMetadata:
"""helper for tests"""
return PageMetadata(
filename=self.filename if "filename" in attributes else "",
type=self.type if "type" in attributes else "",
@ -269,6 +271,7 @@ class GenericMetadata:
return tmp
def _get_clean_metadata(self, *attributes: str) -> GenericMetadata:
"""helper for tests"""
new_md = GenericMetadata()
list_handled = []
for attr in sorted(attributes):

View File

@ -1,5 +1,5 @@
from __future__ import annotations
from comicapi.tags.tag import Tag
from comicapi.tags.tag import Tag, TagLocation
__all__ = ["Tag"]
__all__ = ["Tag", "TagLocation"]

View File

@ -20,9 +20,8 @@ import xml.etree.ElementTree as ET
from typing import Any
from comicapi import utils
from comicapi.archivers import Archiver
from comicapi.genericmetadata import FileHash, GenericMetadata, PageMetadata
from comicapi.tags import Tag
from comicapi.tags import Tag, TagLocation
logger = logging.getLogger(__name__)
@ -31,132 +30,107 @@ class ComicRack(Tag):
enabled = True
id = "cr"
name = "Comic Rack"
enabled = True
location = TagLocation.FILE
filename_match = "ComicInfo.xml"
def __init__(self, version: str) -> None:
super().__init__(version)
filename = "ComicInfo.xml"
supported_attributes = {
"original_hash",
"series",
"issue",
"issue_count",
"title",
"volume",
"genres",
"description",
"notes",
"alternate_series",
"alternate_number",
"alternate_count",
"story_arcs",
"series_groups",
"publisher",
"imprint",
"day",
"month",
"year",
"language",
"web_links",
"format",
"manga",
"black_and_white",
"maturity_rating",
"critical_rating",
"scan_info",
"pages",
"pages.bookmark",
"pages.double_page",
"pages.height",
"pages.image_index",
"pages.size",
"pages.type",
"pages.width",
"page_count",
"characters",
"teams",
"locations",
"credits",
"credits.person",
"credits.role",
}
self.file = "ComicInfo.xml"
self.supported_attributes = {
"original_hash",
"series",
"issue",
"issue_count",
"title",
"volume",
"genres",
"description",
"notes",
"alternate_series",
"alternate_number",
"alternate_count",
"story_arcs",
"series_groups",
"publisher",
"imprint",
"day",
"month",
"year",
"language",
"web_links",
"format",
"manga",
"black_and_white",
"maturity_rating",
"critical_rating",
"scan_info",
"pages",
"pages.bookmark",
"pages.double_page",
"pages.height",
"pages.image_index",
"pages.size",
"pages.type",
"pages.width",
"page_count",
"characters",
"teams",
"locations",
"credits",
"credits.person",
"credits.role",
}
_parseable_credits = frozenset(
(
*GenericMetadata.writer_synonyms,
*GenericMetadata.penciller_synonyms,
*GenericMetadata.inker_synonyms,
*GenericMetadata.colorist_synonyms,
*GenericMetadata.letterer_synonyms,
*GenericMetadata.cover_synonyms,
*GenericMetadata.editor_synonyms,
)
)
def supports_credit_role(self, role: str) -> bool:
return role.casefold() in self._get_parseable_credits()
@staticmethod
def supports_credit_role(role: str) -> bool:
return role.casefold() in ComicRack._parseable_credits
def supports_tags(self, archive: Archiver) -> bool:
return archive.supports_files()
@staticmethod
def validate_tags(tags: bytes) -> bool:
"""verify that the string actually contains CIX data in XML format"""
def has_tags(self, archive: Archiver) -> bool:
try: # read_file can cause an exception
return (
self.supports_tags(archive)
and self.file in archive.get_filename_list()
and self._validate_bytes(archive.read_file(self.file))
)
except Exception:
try:
root = ET.fromstring(tags)
if root.tag != "ComicInfo":
return False
except ET.ParseError:
return False
def remove_tags(self, archive: Archiver) -> bool:
return self.has_tags(archive) and archive.remove_file(self.file)
return True
def read_tags(self, archive: Archiver) -> GenericMetadata:
if self.has_tags(archive):
try: # read_file can cause an exception
metadata = archive.read_file(self.file) or b""
if self._validate_bytes(metadata):
return self._metadata_from_bytes(metadata)
except Exception:
...
return GenericMetadata()
@staticmethod
def load_tags(tags: bytes) -> GenericMetadata:
root = ET.fromstring(tags)
if root.tag != "ComicInfo":
raise NotImplementedError
return ComicRack._convert_xml_to_metadata(root)
def read_raw_tags(self, archive: Archiver) -> str:
try: # read_file can cause an exception
if self.has_tags(archive):
b = archive.read_file(self.file)
# ET.fromstring is used as xml can declare the encoding
return ET.tostring(ET.fromstring(b), encoding="unicode", xml_declaration=True)
except Exception:
...
return ""
@staticmethod
def display_tags(tags: bytes) -> str:
root = ET.fromstring(tags)
if root.tag != "ComicInfo":
raise NotImplementedError
return ET.tostring(ET.fromstring(tags), encoding="unicode", xml_declaration=True)
def write_tags(self, metadata: GenericMetadata, archive: Archiver) -> bool:
if self.supports_tags(archive):
xml = b""
try: # read_file can cause an exception
if self.has_tags(archive):
xml = archive.read_file(self.file)
return archive.write_file(self.file, self._bytes_from_metadata(metadata, xml))
except Exception:
...
else:
logger.warning("Archive %s(%s) does not support '%s' metadata", archive.path, archive.name(), self.name())
return False
@staticmethod
def create_tags(version: str, metadata: GenericMetadata, existing_tags: bytes) -> bytes:
root = ComicRack._convert_metadata_to_xml(metadata, existing_tags)
return ET.tostring(root, xml_declaration=True)
def name(self) -> str:
return "Comic Rack"
@classmethod
def _get_parseable_credits(cls) -> list[str]:
parsable_credits: list[str] = []
parsable_credits.extend(GenericMetadata.writer_synonyms)
parsable_credits.extend(GenericMetadata.penciller_synonyms)
parsable_credits.extend(GenericMetadata.inker_synonyms)
parsable_credits.extend(GenericMetadata.colorist_synonyms)
parsable_credits.extend(GenericMetadata.letterer_synonyms)
parsable_credits.extend(GenericMetadata.cover_synonyms)
parsable_credits.extend(GenericMetadata.editor_synonyms)
return parsable_credits
def _metadata_from_bytes(self, string: bytes) -> GenericMetadata:
root = ET.fromstring(string)
return self._convert_xml_to_metadata(root)
def _bytes_from_metadata(self, metadata: GenericMetadata, xml: bytes = b"") -> bytes:
root = self._convert_metadata_to_xml(metadata, xml)
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def _convert_metadata_to_xml(self, metadata: GenericMetadata, xml: bytes = b"") -> ET.Element:
@staticmethod
def _convert_metadata_to_xml(metadata: GenericMetadata, xml: bytes = b"") -> ET.Element:
# shorthand for the metadata
md = metadata
@ -167,7 +141,6 @@ class ComicRack(Tag):
root = ET.Element("ComicInfo")
root.attrib["xmlns:xsi"] = "http://www.w3.org/2001/XMLSchema-instance"
root.attrib["xmlns:xsd"] = "http://www.w3.org/2001/XMLSchema"
# helper func
def assign(cr_entry: str, md_entry: Any) -> None:
if md_entry:
@ -293,7 +266,8 @@ class ComicRack(Tag):
return root
def _convert_xml_to_metadata(self, root: ET.Element) -> GenericMetadata:
@staticmethod
def _convert_xml_to_metadata(root: ET.Element) -> GenericMetadata:
if root.tag != "ComicInfo":
raise Exception("Not a ComicInfo file")
@ -403,14 +377,3 @@ class ComicRack(Tag):
md.is_empty = False
return md
def _validate_bytes(self, string: bytes) -> bool:
"""verify that the string actually contains CIX data in XML format"""
try:
root = ET.fromstring(string)
if root.tag != "ComicInfo":
return False
except ET.ParseError:
return False
return True

View File

@ -1,126 +1,139 @@
from __future__ import annotations
from comicapi.archivers import Archiver
from typing import ClassVar
from comicapi.genericmetadata import GenericMetadata
from comicapi.utils import StrEnum
class TagLocation(StrEnum):
"""
TagLocation is where the tags are stored in a file.
FILE: Tags are stored in a distinct file in a comic. Must set filename_match and filename so that tags can be located.
COMMENT: Tags are stored in the comment section of an comic.
CUSTOM: Tags are stored in a file specific format. eg single file acbf format https://acbf.fandom.com/wiki/ACBF_Specifications#Embedding_ACBF_files_and_compatibility_with_popular_comic_book_formats or PDF Metadata https://en.wikipedia.org/wiki/PDF#Metadata
"""
FILE = "file"
COMMENT = "comment"
CUSTOM = "custom"
class Tag:
enabled: bool = False
id: str = ""
"""
Tag class used for loading and saving metadata.
def __init__(self, version: str) -> None:
self.version: str = version
self.supported_attributes = {
"data_origin",
"issue_id",
"series_id",
"original_hash",
"series",
"series_aliases",
"issue",
"issue_count",
"title",
"title_aliases",
"volume",
"volume_count",
"genres",
"description",
"notes",
"alternate_series",
"alternate_number",
"alternate_count",
"gtin",
"story_arcs",
"series_groups",
"publisher",
"imprint",
"day",
"month",
"year",
"language",
"country",
"web_link",
"format",
"manga",
"black_and_white",
"maturity_rating",
"critical_rating",
"scan_info",
"tags",
"pages",
"pages.type",
"pages.bookmark",
"pages.double_page",
"pages.image_index",
"pages.size",
"pages.height",
"pages.width",
"page_count",
"characters",
"teams",
"locations",
"credits",
"credits.person",
"credits.role",
"credits.primary",
"credits.language",
"price",
"is_version_of",
"rights",
"identifier",
"last_mark",
}
It is not required that a tag inherit this class but it must implement the data and methods listed here.
See https://github.com/comictagger/comicinfoxml
"""
def supports_credit_role(self, role: str) -> bool:
return False
id: ClassVar[str]
name: ClassVar[str]
enabled: ClassVar[bool]
"""
When false ComicApi will refuse to load the tag.
If external imports are required and are not available this should be false. See rar.py and sevenzip.py.
"""
def supports_tags(self, archive: Archiver) -> bool:
"""
Checks the given archive for the ability to save these tags.
Should always return a bool. Failures should return False.
Typically consists of a call to either `archive.supports_comment` or `archive.supports_file`
"""
return False
location: ClassVar[TagLocation]
filename_match: ClassVar[str]
"""
filename to obtain tags from.
Either an exact name to match:
`ComicInfo.xml`
or an extension:
`*.xml`
def has_tags(self, archive: Archiver) -> bool:
"""
Checks the given archive for tags.
Should always return a bool. Failures should return False.
"""
return False
the first matching file will be used
def remove_tags(self, archive: Archiver) -> bool:
"""
Removes the tags from the given archive.
Should always return a bool. Failures should return False.
"""
return False
Only needed if storage_location is TagLocation.FILE
"""
filename: ClassVar[str]
"""
The filename to save tags to.
def read_tags(self, archive: Archiver) -> GenericMetadata:
"""
Returns a GenericMetadata representing the tags saved in the given archive.
Should always return a GenericMetadata. Failures should return an empty metadata object.
"""
return GenericMetadata()
Only needed if storage_location is TagLocation.FILE
"""
def read_raw_tags(self, archive: Archiver) -> str:
"""
Returns the raw tags as a string.
If the tags are a binary format a roughly similar text format should be used.
Should always return a string. Failures should return the empty string.
"""
return ""
supported_attributes: ClassVar[set[str]] = {
"data_origin",
"issue_id",
"series_id",
"original_hash",
"series",
"series_aliases",
"issue",
"issue_count",
"title",
"title_aliases",
"volume",
"volume_count",
"genres",
"description",
"notes",
"alternate_series",
"alternate_number",
"alternate_count",
"gtin",
"story_arcs",
"series_groups",
"publisher",
"imprint",
"day",
"month",
"year",
"language",
"country",
"web_link",
"format",
"manga",
"black_and_white",
"maturity_rating",
"critical_rating",
"scan_info",
"tags",
"pages",
"pages.type",
"pages.bookmark",
"pages.double_page",
"pages.image_index",
"pages.size",
"pages.height",
"pages.width",
"page_count",
"characters",
"teams",
"locations",
"credits",
"credits.person",
"credits.role",
"credits.primary",
"credits.language",
"price",
"is_version_of",
"rights",
"identifier",
"last_mark",
}
"""For enabling/disabling in GUI. Has no effect on data processed"""
def write_tags(self, metadata: GenericMetadata, archive: Archiver) -> bool:
"""
Saves the given metadata to the given archive.
Should always return a bool. Failures should return False.
"""
return False
@staticmethod
def supports_credit_role(role: str) -> bool:
raise NotImplementedError
def name(self) -> str:
"""
Returns the name of these tags for display purposes eg "Comic Rack".
Should always return a string. Failures should return the empty string.
"""
return ""
@staticmethod
def validate_tags(tags: bytes) -> bool:
raise NotImplementedError
@staticmethod
def load_tags(tags: bytes) -> GenericMetadata:
raise NotImplementedError
@staticmethod
def display_tags(tags: bytes) -> str:
raise NotImplementedError
@staticmethod
def create_tags(version: str, metadata: GenericMetadata, existing_tags: bytes) -> bytes:
raise NotImplementedError

View File

@ -37,6 +37,8 @@ from comicapi._url import LocationParseError as LocationParseError # noqa: F401
from comicapi._url import Url as Url
from comicapi._url import parse_url as parse_url
pil_available: bool | None = None
try:
import icu
@ -197,6 +199,25 @@ def os_sorted(lst: Iterable[T]) -> list[T]:
KNOWN_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif"}
def initialize_pil() -> bool:
"""Imports and initializes PIL to update KNOWN_IMAGE_EXTENSIONS return True if successful"""
global pil_available
if pil_available is not None:
return pil_available
try:
from PIL import Image
Image.init()
KNOWN_IMAGE_EXTENSIONS.update([ext for ext, typ in Image.EXTENSION.items() if typ in Image.OPEN])
pil_available = True
except Exception:
pil_available = False
logger.exception("Failed to load Pillow")
return False
return True
def parse_filename(
filename: str,
parser: Parser = Parser.ORIGINAL,
@ -367,15 +388,15 @@ def get_page_name_list(files: list[str]) -> list[str]:
return page_list
def get_recursive_filelist(pathlist: list[str]) -> list[str]:
def get_recursive_filelist(pathlist: Iterable[pathlib.Path]) -> list[pathlib.Path]:
"""Get a recursive list of of all files under all path items in the list"""
filelist: list[str] = []
filelist: list[pathlib.Path] = []
for p in pathlist:
if os.path.isdir(p):
if p.is_dir():
for root, _, files in os.walk(p):
for f in files:
filelist.append(os.path.join(root, f))
filelist.append(pathlib.Path(root, f))
elif os.path.exists(p):
filelist.append(p)

View File

@ -22,8 +22,10 @@ from typing import Callable
from PyQt6 import QtCore, QtGui, QtWidgets, uic
from comicapi.comicarchive import ComicArchive, tags
from comicapi.comicarchive import ComicArchive
from comicapi.genericmetadata import GenericMetadata
from comicapi.tags.tag import Tag
from comictaggerlib import ctversion
from comictaggerlib.coverimagewidget import CoverImageWidget
from comictaggerlib.ctsettings import ct_ns
from comictaggerlib.md import prepare_metadata
@ -39,7 +41,7 @@ class AutoTagMatchWindow(QtWidgets.QDialog):
self,
parent: QtWidgets.QWidget,
match_set_list: list[Result],
read_tags: list[str],
read_tags: list[Tag],
fetch_func: Callable[[IssueResult], GenericMetadata],
config: ct_ns,
talker: ComicTalker,
@ -264,15 +266,16 @@ class AutoTagMatchWindow(QtWidgets.QDialog):
QtWidgets.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.CursorShape.WaitCursor))
md = prepare_metadata(md, ct_md, self.config)
for tag_id in self._tags:
success = ca.write_tags(md, tag_id)
QtWidgets.QApplication.restoreOverrideCursor()
if not success:
for tag in self._tags:
try:
ca.write_tags(ctversion.version, md, tag)
except Exception as e:
QtWidgets.QMessageBox.warning(
self,
"Write Error",
f"Saving {tags[tag_id].name()} the tags to the archive seemed to fail!",
f"Saving {tag.name} the tags to the archive seemed to fail! {e}",
)
break
QtWidgets.QApplication.restoreOverrideCursor()
ca.reset_cache()

View File

@ -25,11 +25,14 @@ import pathlib
import re
import sys
from collections.abc import Collection
from typing import Any, TextIO
from typing import Any, TextIO, cast
import comictaggerlib
import comictaggerlib.ctversion
from comicapi import merge, utils
from comicapi.comicarchive import ComicArchive, tags
from comicapi.comicarchive import ComicArchive, loaded_tags
from comicapi.genericmetadata import GenericMetadata
from comicapi.tags.tag import Tag
from comictaggerlib.cbltransformer import CBLTransformer
from comictaggerlib.ctsettings import ct_ns
from comictaggerlib.filerenamer import FileRenamer, get_rename_dir
@ -146,9 +149,12 @@ class CLI:
def write_tags(self, ca: ComicArchive, md: GenericMetadata) -> bool:
if not self.config.Runtime_Options__dryrun:
for tag_id in self.config.Runtime_Options__tags_write:
tag = loaded_tags[tag_id]
# write out the new data
if not ca.write_tags(md, tag_id):
logger.error("The tag save seemed to fail for: %s!", tags[tag_id].name())
try:
ca.write_tags(comictaggerlib.ctversion.version, md, tag)
except Exception:
# Error is already displayed in the log
return False
self.output("Save complete.")
@ -188,7 +194,9 @@ class CLI:
# save the data!
# we know at this point, that the file is all good to go
ca = ComicArchive(match_set.original_path, hash_archive=self.config.Runtime_Options__preferred_hash)
md, match_set.tags_read = self.create_local_metadata(ca, self.config.Runtime_Options__tags_read)
md, match_set.tags_read = self.create_local_metadata(
ca, [loaded_tags[i] for i in self.config.Runtime_Options__tags_read]
)
ct_md = self.fetch_metadata(match_set.online_results[int(i) - 1].issue_id)
match_set.md = prepare_metadata(md, ct_md, self.config)
@ -243,7 +251,7 @@ class CLI:
self.display_match_set_for_choice(label, match_set)
def create_local_metadata(
self, ca: ComicArchive, tags_to_read: list[str], /, tags_only: bool = False
self, ca: ComicArchive, tags_to_read: list[Tag], /, tags_only: bool = False
) -> tuple[GenericMetadata, list[str]]:
md = GenericMetadata()
md.apply_default_page_list(ca.get_page_name_list())
@ -263,17 +271,17 @@ class CLI:
file_md = GenericMetadata()
tags_used = []
for tag_id in tags_to_read:
if ca.has_tags(tag_id):
for tag in tags_to_read:
if ca.has_tags(tag):
try:
t_md = ca.read_tags(tag_id)
t_md = ca.read_tags(tag)
if not t_md.is_empty:
file_md.overlay(
t_md,
self.config.Metadata_Options__tag_merge,
self.config.Metadata_Options__tag_merge_lists,
)
tags_used.append(tag_id)
tags_used.append(tag.id)
except Exception as e:
logger.error("Failed to load metadata for %s: %s", ca.path, e)
@ -305,12 +313,12 @@ class CLI:
if self.batch_mode:
brief = f"{ca.path}: "
brief += ca.archiver.name() + " archive "
brief += ca.Archiver.name + " archive "
brief += f"({page_count: >3} pages)"
brief += " tags:[ "
tag_names = [tags[tag_id].name() for tag_id in tags if ca.has_tags(tag_id)]
tag_names = [tag.name for tag in loaded_tags.values() if ca.has_tags(tag)]
brief += " ".join(tag_names)
brief += " ]"
@ -322,97 +330,104 @@ class CLI:
self.output()
tags_read = []
for tag_id, tag in tags.items():
if not self.config.Runtime_Options__tags_read or tag_id in self.config.Runtime_Options__tags_read:
if ca.has_tags(tag_id):
self.output(f"--------- {tag.name()} tags ---------")
for tag in loaded_tags.values():
if not self.config.Runtime_Options__tags_read or tag.id in self.config.Runtime_Options__tags_read:
if ca.has_tags(tag):
self.output(f"--------- {tag.name} tags ---------")
try:
if self.config.Runtime_Options__raw:
self.output(ca.read_raw_tags(tag_id))
self.output(ca.read_raw_tags(tag))
else:
md = ca.read_tags(tag_id)
md = ca.read_tags(tag)
self.output(md)
tags_read.append(tag_id)
tags_read.append(tag.id)
except Exception as e:
logger.error("Failed to read tags from %s: %s", ca.path, e)
if not self.config.Auto_Tag__metadata.is_empty and not self.config.Runtime_Options__raw:
try:
md, tags_read = self.create_local_metadata(
ca, self.config.Runtime_Options__tags_read or list(tags.keys())
ca, [loaded_tags[i] for i in self.config.Runtime_Options__tags_read] or list(loaded_tags.values())
)
tags_read_names = ", ".join(["CLI"] + [tags[t].name() for t in tags_read])
tags_read_names = ", ".join(["CLI"] + [loaded_tags[t].name for t in tags_read])
self.output(f"--------- Combined {tags_read_names} tags ---------")
self.output(md)
tags_read = list(tags.keys())
tags_read = list(loaded_tags.keys())
except Exception as e:
logger.error("Failed to read tags from %s: %s", ca.path, e)
return Result(Action.print, Status.success, ca.path, md=md, tags_read=tags_read)
def delete_tags(self, ca: ComicArchive, tag_id: str) -> Status:
tag_name = tags[tag_id].name()
def delete_tags(self, ca: ComicArchive, tag: Tag) -> Status:
if ca.has_tags(tag_id):
if not self.config.Runtime_Options__dryrun:
if ca.remove_tags(tag_id):
self.output(f"{ca.path}: Removed {tag_name} tags.")
return Status.success
else:
self.output(f"{ca.path}: Tag removal seemed to fail!")
return Status.write_failure
else:
self.output(f"{ca.path}: dry-run. {tag_name} tags not removed")
return Status.success
self.output(f"{ca.path}: This archive doesn't have {tag_name} tags to remove.")
return Status.success
if not ca.has_tags(tag):
self.output(f"{ca.path}: This archive doesn't have {tag.name} tags to remove.")
return Status.success
if self.config.Runtime_Options__dryrun:
self.output(f"{ca.path}: dry-run. {tag.name} tags would be removed")
return Status.success
try:
ca.remove_tags(tag)
self.output(f"{ca.path}: Removed {tag.name} tags.")
return Status.success
except Exception:
self.output(f"{ca.path}: Tag removal seemed to fail!")
return Status.write_failure
def delete(self, ca: ComicArchive) -> Result:
res = Result(Action.delete, Status.success, ca.path)
for tag_id in self.config.Runtime_Options__tags_write:
status = self.delete_tags(ca, tag_id)
tag = loaded_tags[tag_id]
status = self.delete_tags(ca, tag)
if status == Status.success:
res.tags_deleted.append(tag_id)
res.tags_deleted.append(tag.id)
else:
res.status = status
return res
def _copy_tags(self, ca: ComicArchive, md: GenericMetadata, source_names: str, dst_tag_id: str) -> Status:
dst_tag_name = tags[dst_tag_id].name()
if self.config.Runtime_Options__skip_existing_tags and ca.has_tags(dst_tag_id):
self.output(f"{ca.path}: Already has {dst_tag_name} tags. Not overwriting.")
def _copy_tags(self, ca: ComicArchive, md: GenericMetadata, source_names: str, dst_tag: Tag) -> Status:
if self.config.Runtime_Options__skip_existing_tags and ca.has_tags(dst_tag):
self.output(f"{ca.path}: Already has {dst_tag.name} tags. Not overwriting.")
return Status.existing_tags
if len(self.config.Commands__copy) == 1 and dst_tag_id in self.config.Commands__copy:
self.output(f"{ca.path}: Destination and source are same: {dst_tag_name}. Nothing to do.")
if len(self.config.Commands__copy) == 1 and dst_tag.id in self.config.Commands__copy:
self.output(f"{ca.path}: Destination and source are same: {dst_tag.name}. Nothing to do.")
return Status.existing_tags
if not self.config.Runtime_Options__dryrun:
if self.config.Metadata_Options__apply_transform_on_bulk_operation and dst_tag_id == "cbi":
md = CBLTransformer(md, self.config).apply()
if ca.write_tags(md, dst_tag_id):
self.output(f"{ca.path}: Copied {source_names} tags to {dst_tag_name}.")
else:
self.output(f"{ca.path}: Tag copy seemed to fail!")
return Status.write_failure
else:
if self.config.Runtime_Options__dryrun:
self.output(f"{ca.path}: dry-run. {source_names} tags not copied")
return Status.success
return Status.success
if self.config.Metadata_Options__apply_transform_on_bulk_operation and dst_tag.id == "cbi":
md = CBLTransformer(md, self.config).apply()
try:
ca.write_tags(comictaggerlib.ctversion.version, md, dst_tag)
self.output(f"{ca.path}: Copied {source_names} tags to {dst_tag.name}.")
return Status.success
except Exception:
self.output(f"{ca.path}: Tag copy seemed to fail!")
return Status.write_failure
def copy(self, ca: ComicArchive) -> Result:
res = Result(Action.copy, Status.success, ca.path)
src_tag_names = []
for src_tag_id in self.config.Commands__copy:
src_tag_names.append(tags[src_tag_id].name())
if ca.has_tags(src_tag_id):
res.tags_read.append(src_tag_id)
src_tag = loaded_tags[src_tag_id]
src_tag_names.append(src_tag.name)
if ca.has_tags(src_tag):
res.tags_read.append(src_tag.id)
if not res.tags_read:
self.output(f"{ca.path}: This archive doesn't have any {', '.join(src_tag_names)} tags to copy.")
res.status = Status.read_failure
return res
try:
res.md, res.tags_read = self.create_local_metadata(ca, res.tags_read, tags_only=True)
res.md, res.tags_read = self.create_local_metadata(
ca, [loaded_tags[i] for i in res.tags_read], tags_only=True
)
except Exception as e:
logger.error("Failed to read tags from %s: %s", ca.path, e)
return res
@ -420,10 +435,11 @@ class CLI:
for dst_tag_id in self.config.Runtime_Options__tags_write:
if dst_tag_id in self.config.Commands__copy:
continue
dst_tag = loaded_tags[dst_tag_id]
status = self._copy_tags(ca, res.md, ", ".join(src_tag_names), dst_tag_id)
status = self._copy_tags(ca, res.md, ", ".join(src_tag_names), dst_tag)
if status == Status.success:
res.tags_written.append(dst_tag_id)
res.tags_written.append(dst_tag.id)
else:
res.status = status
return res
@ -578,8 +594,9 @@ class CLI:
def save(self, ca: ComicArchive, match_results: OnlineMatchResults) -> tuple[Result, OnlineMatchResults]:
if self.config.Runtime_Options__skip_existing_tags:
for tag_id in self.config.Runtime_Options__tags_write:
if ca.has_tags(tag_id):
self.output(f"{ca.path}: Already has {tags[tag_id].name()} tags. Not overwriting.")
tag = loaded_tags[tag_id]
if ca.has_tags(tag):
self.output(f"{ca.path}: Already has {tag.name} tags. Not overwriting.")
return (
Result(
Action.save,
@ -593,7 +610,7 @@ class CLI:
if self.batch_mode:
self.output(f"Processing {utils.path_to_short_str(ca.path)}...")
md, tags_read = self.create_local_metadata(ca, self.config.Runtime_Options__tags_read)
md, tags_read = self.create_local_metadata(ca, [loaded_tags[i] for i in self.config.Runtime_Options__tags_read])
matches: list[IssueResult] = []
# now, search online
@ -687,8 +704,8 @@ class CLI:
msg_hdr = ""
if self.batch_mode:
msg_hdr = f"{ca.path}: "
md, tags_read = self.create_local_metadata(ca, self.config.Runtime_Options__tags_read)
print(self.config.Runtime_Options__tags_read)
md, tags_read = self.create_local_metadata(ca, [loaded_tags[i] for i in self.config.Runtime_Options__tags_read])
if md.series is None:
logger.error("%sCan't rename without series name", msg_hdr)
@ -770,25 +787,25 @@ class CLI:
delete_success = False
export_success = False
if not self.config.Runtime_Options__dryrun:
if ca.export_as_zip(new_file):
export_success = True
if self.config.Runtime_Options__delete_original:
try:
filename_path.unlink(missing_ok=True)
delete_success = True
except OSError:
logger.exception("%sError deleting original archive after export", msg_hdr)
else:
# last export failed, so remove the zip, if it exists
new_file.unlink(missing_ok=True)
else:
if self.config.Runtime_Options__dryrun:
msg = msg_hdr + f"Dry-run: Would try to create {os.path.split(new_file)[1]}"
if self.config.Runtime_Options__delete_original:
msg += " and delete original."
self.output(msg)
return Result(Action.export, Status.success, ca.path, new_file)
try:
ca.export_as(new_file)
export_success = True
if self.config.Runtime_Options__delete_original:
try:
filename_path.unlink(missing_ok=False)
delete_success = True
except OSError:
logger.exception("%sError deleting original archive after export", msg_hdr)
except Exception:
new_file.unlink(missing_ok=True)
msg = msg_hdr
if export_success:
msg += f"Archive exported successfully to: {os.path.split(new_file)[1]}"
@ -802,14 +819,16 @@ class CLI:
return Result(Action.export, Status.success, ca.path, new_file)
def process_file_cli(
self, command: Action, filename: str, match_results: OnlineMatchResults
self, command: Action, filename: pathlib.Path, 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)), match_results
return Result(command, Status.read_failure, filename), match_results
ca = ComicArchive(
filename, str(graphics_path / "nocover.png"), hash_archive=self.config.Runtime_Options__preferred_hash
filename,
cast(pathlib.Path, graphics_path / "nocover.png"),
hash_archive=self.config.Runtime_Options__preferred_hash,
)
if not ca.seems_to_be_a_comic_archive():

View File

@ -23,7 +23,7 @@ logger = logging.getLogger(__name__)
talkers: dict[str, ComicTalker] = {}
__all__ = [
__all__ = (
"initial_commandline_parser",
"register_commandline_settings",
"register_file_settings",
@ -34,7 +34,7 @@ __all__ = [
"ComicTaggerPaths",
"ct_ns",
"group_for_plugin",
]
)
class SettingsEncoder(json.JSONEncoder):

View File

@ -20,6 +20,7 @@ import argparse
import hashlib
import logging
import os
import pathlib
import platform
import shlex
import subprocess
@ -27,7 +28,7 @@ import subprocess
import settngs
from comicapi import comicarchive, utils
from comicapi.comicarchive import tags
from comicapi.comicarchive import loaded_tags
from comictaggerlib import ctversion, quick_tag
from comictaggerlib.ctsettings.settngs_namespace import SettngsNS as ct_ns
from comictaggerlib.ctsettings.types import ComicTaggerPaths, tag
@ -167,7 +168,7 @@ def register_runtime(parser: settngs.Manager) -> None:
parser.add_setting(
"-t",
"--tags-read",
metavar=f"{{{','.join(tags).upper()}}}",
metavar=f"{{{','.join(loaded_tags).upper()}}}",
default=[],
type=tag,
help="""Specify the tags to read.\nUse commas for multiple tags.\nSee --list-plugins for the available tags.\nThe tags used will be 'overlaid' 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""",
@ -175,7 +176,7 @@ def register_runtime(parser: settngs.Manager) -> None:
)
parser.add_setting(
"--tags-write",
metavar=f"{{{','.join(tags).upper()}}}",
metavar=f"{{{','.join(loaded_tags).upper()}}}",
default=[],
type=tag,
help="""Specify the tags to write.\nUse commas for multiple tags.\nRead tags will be used if unspecified\nSee --list-plugins for the available tags.\n\n""",
@ -188,7 +189,7 @@ def register_runtime(parser: settngs.Manager) -> None:
help="""Skip archives that already have tags specified with -t,\notherwise merges new tags with existing tags (relevant for -s or -c).\ndefault: %(default)s""",
file=False,
)
parser.add_setting("files", nargs="*", default=[], file=False)
parser.add_setting("files", nargs="*", default=[], type=pathlib.Path, file=False)
def register_commands(parser: settngs.Manager) -> None:
@ -218,7 +219,7 @@ def register_commands(parser: settngs.Manager) -> None:
"--copy",
type=tag,
default=[],
metavar=f"{{{','.join(tags).upper()}}}",
metavar=f"{{{','.join(loaded_tags).upper()}}}",
help="Copy the specified source tags to\ndestination tags specified via --tags-write\n(potentially lossy operation).\n\n",
file=False,
)
@ -283,14 +284,14 @@ def validate_commandline_settings(config: settngs.Config[ct_ns], parser: settngs
+ "Distributed under Apache License 2.0 (http://www.apache.org/licenses/LICENSE-2.0)\n",
)
enabled_tags = {tag for tag in comicarchive.tags if comicarchive.tags[tag].enabled}
enabled_tags = {tag for tag in comicarchive.loaded_tags if comicarchive.loaded_tags[tag].enabled}
if (
(not config[0].Metadata_Options__cr)
and "cr" in comicarchive.tags
and comicarchive.tags["cr"].enabled
and "cr" in comicarchive.loaded_tags
and comicarchive.loaded_tags["cr"].enabled
and len(enabled_tags) > 1
):
comicarchive.tags["cr"].enabled = False
type(comicarchive.loaded_tags["cr"]).enabled = False
config[0].Runtime_Options__no_gui = any(
(config[0].Commands__command != Action.gui, config[0].Runtime_Options__no_gui, config[0].Commands__copy)
@ -303,7 +304,7 @@ def validate_commandline_settings(config: settngs.Config[ct_ns], parser: settngs
globs = config[0].Runtime_Options__files
config[0].Runtime_Options__files = []
for item in globs:
config[0].Runtime_Options__files.extend(glob.glob(item))
config[0].Runtime_Options__files.extend(pathlib.Path(x) for x in glob.glob(str(item)))
if config[0].Runtime_Options__json and config[0].Runtime_Options__interactive:
config[0].Runtime_Options__json = False
@ -311,7 +312,7 @@ def validate_commandline_settings(config: settngs.Config[ct_ns], parser: settngs
if config[0].Runtime_Options__tags_read and not config[0].Runtime_Options__tags_write:
config[0].Runtime_Options__tags_write = config[0].Runtime_Options__tags_read
disabled_tags = {tag for tag in comicarchive.tags if not comicarchive.tags[tag].enabled}
disabled_tags = {tag for tag in comicarchive.loaded_tags if not comicarchive.loaded_tags[tag].enabled}
to_be_removed = (
set(config[0].Runtime_Options__tags_read)
.union(config[0].Runtime_Options__tags_write)
@ -328,7 +329,7 @@ def validate_commandline_settings(config: settngs.Config[ct_ns], parser: settngs
if (
config[0].Runtime_Options__no_gui
and not [tag.id for tag in tags.values() if tag.enabled]
and not [tag.id for tag in loaded_tags.values() if tag.enabled]
and config[0].Commands__command != Action.list_plugins
):
parser.exit(status=1, message="There are no tags enabled see --list-plugins\n")

View File

@ -9,17 +9,17 @@ import settngs
import comicapi.comicarchive
import comicapi.utils
import comictaggerlib.ctsettings
from comicapi.comicarchive import Archiver
from comicapi.comicarchive import ComicFile
from comictaggerlib.ctsettings.settngs_namespace import SettngsNS as ct_ns
from comictalker.comictalker import ComicTalker
logger = logging.getLogger("comictagger")
def group_for_plugin(plugin: Archiver | ComicTalker | type[Archiver]) -> str:
def group_for_plugin(plugin: ComicFile | ComicTalker | type[ComicFile]) -> str:
if isinstance(plugin, ComicTalker):
return f"Source {plugin.id}"
if isinstance(plugin, Archiver) or plugin == Archiver:
if hasattr(plugin, "exe") or hasattr(plugin, "tag_locations"):
return "Archive"
raise NotImplementedError(f"Invalid plugin received: {plugin=}")
@ -64,7 +64,7 @@ def register_talker_settings(manager: settngs.Manager, talkers: dict[str, ComicT
def validate_archive_settings(config: settngs.Config[ct_ns]) -> settngs.Config[ct_ns]:
cfg = settngs.normalize_config(config, file=True, cmdline=True, default=False)
for archiver in comicapi.comicarchive.archivers:
group = group_for_plugin(archiver())
group = group_for_plugin(archiver)
exe_name = settngs.sanitize_name(archiver.exe)
if not exe_name:
continue

View File

@ -1,5 +1,6 @@
from __future__ import annotations
import pathlib
import typing
import settngs
@ -10,6 +11,7 @@ import comicapi.merge
import comicapi.utils
import comictaggerlib.ctsettings.types
import comictaggerlib.defaults
import comictaggerlib.quick_tag
import comictaggerlib.resulttypes
@ -39,7 +41,7 @@ class SettngsNS(settngs.TypedNS):
Runtime_Options__tags_read: list[str]
Runtime_Options__tags_write: list[str]
Runtime_Options__skip_existing_tags: bool
Runtime_Options__files: list[str]
Runtime_Options__files: list[pathlib.Path]
Quick_Tag__url: urllib3.util.url.Url
Quick_Tag__max: int
@ -163,7 +165,7 @@ class Runtime_Options(typing.TypedDict):
tags_read: list[str]
tags_write: list[str]
skip_existing_tags: bool
files: list[str]
files: list[pathlib.Path]
class Quick_Tag(typing.TypedDict):

View File

@ -13,7 +13,7 @@ import yaml
from appdirs import AppDirs
from comicapi import utils
from comicapi.comicarchive import tags
from comicapi.comicarchive import loaded_tags
from comicapi.genericmetadata import REMOVE, GenericMetadata
logger = logging.getLogger(__name__)
@ -152,14 +152,14 @@ class ComicTaggerPaths(AppDirs):
def tag(types: str) -> list[str]:
enabled_tags = [tag for tag in tags if tags[tag].enabled]
enabled_tags = [tag for tag in loaded_tags if loaded_tags[tag].enabled]
result = []
types = types.casefold()
for typ in utils.split(types, ","):
if typ not in enabled_tags:
choices = ", ".join(enabled_tags)
raise argparse.ArgumentTypeError(f"invalid choice: {typ} (choose from {choices.upper()})")
result.append(tags[typ].id)
result.append(loaded_tags[typ].id)
return result

View File

@ -20,11 +20,11 @@ import logging
import os
import pathlib
import platform
from collections.abc import Iterable
from typing import Callable, cast
from PyQt6 import QtCore, QtGui, QtWidgets, uic
from comicapi import utils
from comicapi.comicarchive import ComicArchive
from comictaggerlib.ctsettings import ct_ns
from comictaggerlib.graphics import graphics_path
@ -177,10 +177,10 @@ class FileSelectionList(QtWidgets.QWidget):
else:
self.listCleared.emit()
def add_path_list(self, pathlist: list[str]) -> None:
if not pathlist:
def add_path_list(self, pathlist: Iterable[pathlib.Path]) -> None:
filelist = list(pathlist)
if not filelist:
return
filelist = utils.get_recursive_filelist(pathlist)
# we now have a list of files to add
progdialog = None
@ -203,11 +203,11 @@ class FileSelectionList(QtWidgets.QWidget):
if progdialog.wasCanceled():
break
progdialog.setValue(idx + 1)
progdialog.setLabelText(f)
progdialog.setLabelText(str(f))
row, ca = self.add_path_item(f)
if row is not None:
rar_added_ro = bool(ca and ca.archiver.name() == "RAR" and not ca.archiver.is_writable())
rar_added_ro = bool(ca and ca.Archiver.name == "RAR" and not ca.Archiver(ca.path).is_writable())
if first_added is None and row != -1:
first_added = row
@ -218,7 +218,7 @@ class FileSelectionList(QtWidgets.QWidget):
if first_added is not None:
self.twList.selectRow(first_added)
else:
if len(pathlist) == 1 and os.path.isfile(pathlist[0]):
if len(filelist) == 1 and os.path.isfile(filelist[0]):
QtWidgets.QMessageBox.information(
self, "File Open", "Selected file doesn't seem to be a comic archive."
)
@ -260,28 +260,26 @@ class FileSelectionList(QtWidgets.QWidget):
)
self.rar_ro_shown = True
def get_current_list_row(self, path: str) -> tuple[int, ComicArchive]:
pl = pathlib.Path(path)
if pl not in self.loaded_paths:
def get_current_list_row(self, path: pathlib.Path) -> tuple[int, ComicArchive]:
if path not in self.loaded_paths:
return -1, None # type: ignore[return-value]
for r in range(self.twList.rowCount()):
ca = cast(ComicArchive, self.get_archive_by_row(r))
if ca.path == pl:
if ca.path == path:
return r, ca
return -1, None # type: ignore[return-value]
def add_path_item(self, path: str) -> tuple[int, ComicArchive]:
path = str(path)
path = os.path.abspath(path)
def add_path_item(self, path: pathlib.Path) -> tuple[int, ComicArchive]:
current_row, ca = self.get_current_list_row(path)
if current_row >= 0:
return current_row, ca
ca = ComicArchive(
path, str(graphics_path / "nocover.png"), hash_archive=self.config.Runtime_Options__preferred_hash
path,
cast(pathlib.Path, graphics_path / "nocover.png"),
hash_archive=self.config.Runtime_Options__preferred_hash,
)
if ca.seems_to_be_a_comic_archive():
@ -313,7 +311,7 @@ class FileSelectionList(QtWidgets.QWidget):
type_item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
self.twList.setItem(row, FileSelectionList.typeColNum, type_item)
md_item.setText(", ".join(x for x in ca.get_supported_tags() if ca.has_tags(x)))
md_item.setText(", ".join(x.name for x in ca.get_supported_tags() if ca.has_tags(x)))
md_item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
md_item.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignHCenter)
self.twList.setItem(row, FileSelectionList.MDFlagColNum, md_item)
@ -352,11 +350,11 @@ class FileSelectionList(QtWidgets.QWidget):
folder_item.setText(item_text)
folder_item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
item_text = ca.archiver.name()
item_text = ca.Archiver.name
type_item.setText(item_text)
type_item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
md_item.setText(", ".join(x for x in ca.get_supported_tags() if ca.has_tags(x)))
md_item.setText(", ".join(x.name for x in ca.get_supported_tags() if ca.has_tags(x)))
if not ca.is_writable():
readonly_item.setCheckState(QtCore.Qt.CheckState.Checked)

View File

@ -2,6 +2,7 @@ from __future__ import annotations
import logging.handlers
import os
import pathlib
import platform
import sys
import traceback
@ -110,9 +111,14 @@ def open_tagger_window(
show_exception_box(error[0], " ")
if error[1]:
raise SystemExit(1)
# needed to catch initial open file events (macOS)
app.openFileRequest.connect(lambda x: config[0].Runtime_Options__files.append(x.toLocalFile()))
app.openFileRequest.connect(
lambda x: (
config[0].Runtime_Options__files.append(pathlib.Path(x.toLocalFile()))
if x.toLocalFile() not in sys.argv
else ""
)
)
# The window Icon needs to be set here. It's also set in taggerwindow.ui but it doesn't seem to matter
app.setWindowIcon(QtGui.QIcon(":/graphics/app.png"))

View File

@ -17,6 +17,7 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from PyQt6 import QtCore, QtGui, QtWidgets, uic
@ -28,6 +29,10 @@ from comictaggerlib.ui import qtutils, ui_path
from comictaggerlib.ui.qtutils import new_web_view
from comictalker.comictalker import ComicTalker, TalkerError
if TYPE_CHECKING:
from PyQt6.QtWebEngineWidgets import QWebEngineView
logger = logging.getLogger(__name__)
@ -168,12 +173,11 @@ class IssueSelectionWindow(QtWidgets.QDialog):
def cell_double_clicked(self, r: int, c: int) -> None:
self.accept()
def set_description(self, widget: QtWidgets.QWidget, text: str) -> None:
def set_description(self, widget: QtWidgets.QTextEdit | QWebEngineView, text: str) -> None:
if isinstance(widget, QtWidgets.QTextEdit):
widget.setText(text.replace("</figure>", "</div>").replace("<figure", "<div"))
else:
html = text
widget.setHtml(html, QtCore.QUrl(self.talker.website))
widget.setContent(text.encode("utf-8"), "text/html;charset=UTF-8", QtCore.QUrl(self.talker.website))
def update_row(self, row: int, issue: GenericMetadata) -> None:
item_text = issue.issue or ""

View File

@ -152,7 +152,7 @@ class App:
def list_plugins(
self,
talkers: Collection[comictalker.ComicTalker],
archivers: Collection[type[comicapi.comicarchive.Archiver]],
archivers: Collection[type[comicapi.comicarchive.ComicFile]],
tags: Collection[comicapi.comicarchive.Tag],
) -> None:
if self.config[0].Runtime_Options__json:
@ -170,14 +170,14 @@ class App:
for archiver in archivers:
try:
a = archiver()
a = archiver
print( # noqa: T201
json.dumps(
{
"type": "archiver",
"enabled": a.enabled,
"name": a.name(),
"extension": a.extension(),
"name": a.name,
"extension": a.extension,
"exe": a.exe,
}
)
@ -201,7 +201,7 @@ class App:
{
"type": "tag",
"enabled": tag.enabled,
"name": tag.name(),
"name": tag.name,
"id": tag.id,
}
)
@ -213,12 +213,12 @@ class App:
print("\nComic Archive: (Enabled, Name: extension, exe)") # noqa: T201
for archiver in archivers:
a = archiver()
print(f"{a.enabled!s:<5}, {a.name():<10}: {a.extension():<5}, {a.exe}") # noqa: T201
a = archiver
print(f"{a.enabled!s:<5}, {a.name:<10}: {a.extension:<5}, {a.exe}") # noqa: T201
print("\nTags: (Enabled, ID: Name)") # noqa: T201
for tag in tags:
print(f"{tag.enabled!s:<5}, {tag.id:<10}: {tag.name()}") # noqa: T201
print(f"{tag.enabled!s:<5}, {tag.id:<10}: {tag.name}") # noqa: T201
def initialize(self) -> argparse.Namespace:
conf, _ = self.initial_arg_parser.parse_known_intermixed_args()
@ -292,7 +292,7 @@ class App:
self.list_plugins(
list(self.talkers.values()),
comicapi.comicarchive.archivers,
comicapi.comicarchive.tags.values(),
comicapi.comicarchive.loaded_tags.values(),
)
return

View File

@ -20,8 +20,9 @@ import logging
from PyQt6 import QtCore, QtGui, QtWidgets, uic
from comicapi.comicarchive import ComicArchive, tags
from comicapi.comicarchive import ComicArchive
from comicapi.genericmetadata import GenericMetadata, PageMetadata, PageType
from comicapi.tags.tag import Tag
from comictaggerlib.coverimagewidget import CoverImageWidget
from comictaggerlib.ui import ui_path
from comictaggerlib.ui.qtutils import enable_widget
@ -119,7 +120,7 @@ class PageListEditor(QtWidgets.QWidget):
self.comic_archive: ComicArchive | None = None
self.pages_list: list[PageMetadata] = []
self.tag_ids: list[str] = []
self.tags: list[Tag] = []
def set_blur(self, blur: bool) -> None:
self.pageWidget.blur = self.blur = blur
@ -351,7 +352,7 @@ class PageListEditor(QtWidgets.QWidget):
self.comic_archive = comic_archive
self.pages_list = pages_list
if pages_list:
self.select_read_tags(self.tag_ids)
self.select_read_tags(self.tags)
else:
self.cbPageType.setEnabled(False)
self.chkDoublePage.setEnabled(False)
@ -396,18 +397,18 @@ class PageListEditor(QtWidgets.QWidget):
self.first_front_page = self.get_first_front_cover()
self.firstFrontCoverChanged.emit(self.first_front_page)
def select_read_tags(self, tag_ids: list[str]) -> None:
def select_read_tags(self, tags: list[Tag]) -> None:
# depending on the current tags, certain fields are disabled
if not tag_ids:
if not tags:
return
enabled_widgets = set()
for tag_id in tag_ids:
if not tags[tag_id].enabled:
for tag in tags:
if not tag.enabled:
continue
enabled_widgets.update(tags[tag_id].supported_attributes)
enabled_widgets.update(tag.supported_attributes)
self.tag_ids = tag_ids
self.tags = tags
for md_field, widget in self.md_attributes.items():
enable_widget(widget, md_field in enabled_widgets)

View File

@ -22,8 +22,9 @@ import settngs
from PyQt6 import QtCore, QtWidgets, uic
from comicapi import utils
from comicapi.comicarchive import ComicArchive, tags
from comicapi.comicarchive import ComicArchive
from comicapi.genericmetadata import GenericMetadata
from comicapi.tags.tag import Tag
from comictaggerlib.ctsettings import ct_ns
from comictaggerlib.filerenamer import FileRenamer, get_rename_dir
from comictaggerlib.settingswindow import SettingsWindow
@ -39,7 +40,7 @@ class RenameWindow(QtWidgets.QDialog):
self,
parent: QtWidgets.QWidget,
comic_archive_list: list[ComicArchive],
read_tag_ids: list[str],
read_tags: list[Tag],
config: settngs.Config[ct_ns],
talkers: dict[str, ComicTalker],
) -> None:
@ -48,7 +49,7 @@ class RenameWindow(QtWidgets.QDialog):
with (ui_path / "renamewindow.ui").open(encoding="utf-8") as uifile:
uic.loadUi(uifile, self)
self.label.setText(f"Preview (based on {', '.join(tags[tag].name() for tag in read_tag_ids)} tags):")
self.label.setText(f"Preview (based on {', '.join(tag.name for tag in read_tags)} tags):")
self.setWindowFlags(
QtCore.Qt.WindowType(
@ -61,7 +62,7 @@ class RenameWindow(QtWidgets.QDialog):
self.config = config
self.talkers = talkers
self.comic_archive_list = comic_archive_list
self.read_tag_ids = read_tag_ids
self.read_tags = read_tags
self.rename_list: list[str] = []
self.btnSettings.clicked.connect(self.modify_settings)
@ -82,7 +83,7 @@ class RenameWindow(QtWidgets.QDialog):
new_ext = ca.extension()
if md is None or md.is_empty:
md, error = self.parent().read_selected_tags(self.read_tag_ids, ca)
md, error = self.parent().read_selected_tags(self.read_tags, ca)
if error is not None:
logger.error("Failed to load tags from %s: %s", ca.path, error)
QtWidgets.QMessageBox.warning(

View File

@ -19,10 +19,11 @@ from __future__ import annotations
import difflib
import itertools
import logging
from typing import TYPE_CHECKING
import natsort
from PyQt6 import QtCore, QtGui, QtWidgets, uic
from PyQt6.QtCore import QUrl, pyqtSignal
from PyQt6.QtCore import pyqtSignal
from comicapi import utils
from comicapi.comicarchive import ComicArchive
@ -37,6 +38,9 @@ from comictaggerlib.resulttypes import IssueResult
from comictaggerlib.ui import qtutils, ui_path
from comictalker.comictalker import ComicTalker, TalkerError
if TYPE_CHECKING:
from PyQt6.QtWebEngineWidgets import QWebEngineView
logger = logging.getLogger(__name__)
@ -509,12 +513,11 @@ class SeriesSelectionWindow(QtWidgets.QDialog):
def cell_double_clicked(self, r: int, c: int) -> None:
self.show_issues()
def set_description(self, widget: QtWidgets.QWidget, text: str) -> None:
def set_description(self, widget: QtWidgets.QTextEdit | QWebEngineView, text: str) -> None:
if isinstance(widget, QtWidgets.QTextEdit):
widget.setText(text.replace("</figure>", "</div>").replace("<figure", "<div"))
else:
html = text
widget.setHtml(html, QUrl(self.talker.website))
widget.setContent(text.encode("utf-8"), "text/html;charset=UTF-8", QtCore.QUrl(self.talker.website))
def update_row(self, row: int, series: ComicSeries) -> None:
item_text = series.name

View File

@ -30,7 +30,7 @@ from PyQt6 import QtCore, QtGui, QtWidgets, uic
import comictaggerlib.ui.talkeruigenerator
from comicapi import merge, utils
from comicapi.archivers.archiver import Archiver
from comicapi.comic import ComicFile
from comicapi.genericmetadata import md_test
from comictaggerlib import ctsettings
from comictaggerlib.ctsettings import ct_ns
@ -428,7 +428,7 @@ class SettingsWindow(QtWidgets.QDialog):
def settings_to_form(self) -> None:
self.disconnect_signals()
# Copy values from settings to form
archive_group = group_for_plugin(Archiver)
archive_group = group_for_plugin(ComicFile)
if archive_group in self.config[1] and "rar" in self.config[1][archive_group].v:
self.leRarExePath.setText(getattr(self.config[0], self.config[1][archive_group].v["rar"].internal_name))
else:
@ -560,7 +560,7 @@ class SettingsWindow(QtWidgets.QDialog):
)
# Copy values from form to settings and save
archive_group = group_for_plugin(Archiver)
archive_group = group_for_plugin(ComicFile)
if archive_group in self.config[1] and "rar" in self.config[1][archive_group].v:
setattr(self.config[0], self.config[1][archive_group].v["rar"].internal_name, str(self.leRarExePath.text()))

View File

@ -21,6 +21,7 @@ import hashlib
import logging
import operator
import os
import pathlib
import pickle
import platform
import re
@ -37,10 +38,11 @@ import comicapi.merge
import comictaggerlib.graphics.resources
import comictaggerlib.ui
from comicapi import utils
from comicapi.comicarchive import ComicArchive, tags
from comicapi.comicarchive import ComicArchive, loaded_tags
from comicapi.filenameparser import FileNameParser
from comicapi.genericmetadata import Credit, FileHash, GenericMetadata
from comicapi.issuestring import IssueString
from comicapi.tags.tag import Tag
from comictaggerlib import ctsettings, ctversion
from comictaggerlib.applicationlogwindow import ApplicationLogWindow, QTextEditLogger
from comictaggerlib.autotagmatchwindow import AutoTagMatchWindow
@ -82,7 +84,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
def __init__(
self,
file_list: list[str],
file_list: list[pathlib.Path],
config: settngs.Config[ct_ns],
talkers: dict[str, ComicTalker],
parent: QtWidgets.QWidget | None = None,
@ -167,7 +169,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
if not socket.waitForBytesWritten(3000):
logger.error(socket.errorString())
socket.disconnectFromServer()
sys.exit()
raise SystemExit(0)
else:
# listen on a socket to prevent multiple instances
self.socketServer = QtNetwork.QLocalServer(self)
@ -183,7 +185,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
config[0].internal__install_id,
self.socketServer.errorString(),
)
sys.exit()
raise SystemExit(0)
self.archiveCoverWidget = CoverImageWidget(self.coverImageContainer, CoverImageWidget.ArchiveMode, None)
grid_layout = QtWidgets.QGridLayout(self.coverImageContainer)
@ -226,18 +228,22 @@ class TaggerWindow(QtWidgets.QMainWindow):
if config[0].Runtime_Options__tags_read:
config[0].internal__read_tags = config[0].Runtime_Options__tags_read
enabled_tags = self.enabled_tags()
for tag_id in config[0].internal__write_tags.copy():
if tag_id not in self.enabled_tags():
if tag_id not in enabled_tags:
config[0].internal__write_tags.remove(tag_id)
for tag_id in config[0].internal__read_tags.copy():
if tag_id not in self.enabled_tags():
if tag_id not in enabled_tags:
config[0].internal__read_tags.remove(tag_id)
if self.config[0].Runtime_Options__preferred_hash:
self.config[0].internal__embedded_hash_type = self.config[0].Runtime_Options__preferred_hash
self.selected_write_tags: list[str] = config[0].internal__write_tags or list(self.enabled_tags())
self.selected_read_tags: list[str] = config[0].internal__read_tags or list(self.enabled_tags())
self.selected_write_tags: list[Tag] = [loaded_tags[tag_id] for tag_id in config[0].internal__write_tags]
self.selected_read_tags: list[Tag] = [loaded_tags[tag_id] for tag_id in config[0].internal__read_tags]
self.selected_write_tags = self.selected_write_tags or list(enabled_tags)
self.selected_read_tags = self.selected_read_tags or list(enabled_tags)
self.setAcceptDrops(True)
self.view_tag_actions, self.remove_tag_actions = self.tag_actions()
@ -372,22 +378,22 @@ class TaggerWindow(QtWidgets.QMainWindow):
if self.config[0].General__check_for_new_version:
self.check_latest_version_online()
def enabled_tags(self) -> Sequence[str]:
return [tag.id for tag in tags.values() if tag.enabled]
def enabled_tags(self) -> Sequence[Tag]:
return [tag for tag in loaded_tags.values() if tag.enabled]
def tag_actions(self) -> tuple[dict[str, QtGui.QAction], dict[str, QtGui.QAction]]:
view_raw_tags: dict[str, QtGui.QAction] = {}
remove_raw_tags: dict[str, QtGui.QAction] = {}
for tag in tags.values():
view_raw_tags[tag.id] = self.menuViewRawTags.addAction(f"View Raw {tag.name()} Tags")
for tag in loaded_tags.values():
view_raw_tags[tag.id] = self.menuViewRawTags.addAction(f"View Raw {tag.name} Tags")
view_raw_tags[tag.id].setEnabled(tag.enabled)
view_raw_tags[tag.id].setStatusTip(f"View raw {tag.name()} tag block from file")
view_raw_tags[tag.id].triggered.connect(functools.partial(self.view_raw_tags, tag.id))
view_raw_tags[tag.id].setStatusTip(f"View raw {tag.name} tag block from file")
view_raw_tags[tag.id].triggered.connect(functools.partial(self.view_raw_tags, tag))
remove_raw_tags[tag.id] = self.menuRemove.addAction(f"Remove Raw {tag.name()} Tags")
remove_raw_tags[tag.id] = self.menuRemove.addAction(f"Remove Raw {tag.name} Tags")
remove_raw_tags[tag.id].setEnabled(tag.enabled)
remove_raw_tags[tag.id].setStatusTip(f"Remove {tag.name()} tags from comic archive")
remove_raw_tags[tag.id].triggered.connect(functools.partial(self.remove_tags, [tag.id]))
remove_raw_tags[tag.id].setStatusTip(f"Remove {tag.name} tags from comic archive")
remove_raw_tags[tag.id].triggered.connect(functools.partial(self.remove_tags, [tag]))
return view_raw_tags, remove_raw_tags
@ -399,7 +405,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
def open_file_event(self, url: QtCore.QUrl) -> None:
logger.info(url.toLocalFile())
self.fileSelectionList.add_path_list([url.toLocalFile()])
self.fileSelectionList.add_path_list([pathlib.Path(url.toLocalFile())])
def sigint_handler(self, *args: Any) -> None:
# defer the actual close in the app loop thread
@ -455,10 +461,10 @@ class TaggerWindow(QtWidgets.QMainWindow):
def toggle_enable_embedding_hashes(self) -> None:
self.config[0].Runtime_Options__enable_embedding_hashes = self.actionEnableEmbeddingHashes.isChecked()
enabled_widgets = set()
for tag_id in self.selected_write_tags:
if not tags[tag_id].enabled:
for tag in self.selected_write_tags:
if not tag.enabled:
continue
enabled_widgets.update(tags[tag_id].supported_attributes)
enabled_widgets.update(tag.supported_attributes)
enable_widget(
self.md_attributes["original_hash"],
self.config[0].Runtime_Options__enable_embedding_hashes and "original_hash" in enabled_widgets,
@ -533,7 +539,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
def repackage_archive(self) -> None:
ca_list = self.fileSelectionList.get_selected_archive_list()
non_zip_count = 0
to_zip = []
to_zip: list[ComicArchive] = []
largest_page_size = 0
for ca in ca_list:
largest_page_size = max(largest_page_size, len(ca.get_page_name_list()))
@ -578,10 +584,10 @@ class TaggerWindow(QtWidgets.QMainWindow):
center_window_on_parent(prog_dialog)
QtCore.QCoreApplication.processEvents()
new_archives_to_add = []
archives_to_remove = []
new_archives_to_add: list[pathlib.Path] = []
archives_to_remove: list[ComicArchive] = []
skipped_list = []
failed_list = []
failed_list: list[Exception] = []
success_count = 0
logger.debug("Exporting %d comics to zip", len(to_zip))
@ -607,17 +613,17 @@ class TaggerWindow(QtWidgets.QMainWindow):
if export:
logger.debug("Exporting %s to %s", ca.path, export_name)
if ca.export_as_zip(export_name):
try:
ca.export_as(export_name)
success_count += 1
if EW.addToList:
new_archives_to_add.append(str(export_name))
new_archives_to_add.append(export_name)
if EW.deleteOriginal:
archives_to_remove.append(ca)
ca.path.unlink(missing_ok=True)
else:
# last export failed, so remove the zip, if it exists
failed_list.append(ca.path)
except Exception as e:
failed_list.append(OSError(f"Failed to export {ca.path} to {export_name}: {e}"))
if export_name.exists():
export_name.unlink(missing_ok=True)
@ -633,11 +639,9 @@ class TaggerWindow(QtWidgets.QMainWindow):
for f in skipped_list:
summary += f"\t{f}\n"
if failed_list:
summary += (
f"\n\nThe following {len(failed_list)} archive(s) failed to export due to read/write errors:\n"
)
for f in failed_list:
summary += f"\t{f}\n"
summary += f"\n\nThe following {len(failed_list)} archive(s) failed to export:\n"
for ex in failed_list:
summary += f"\t{ex}\n"
logger.info(summary)
dlg = LogWindow(self)
@ -682,7 +686,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
event.accept()
def dropEvent(self, event: QtGui.QDropEvent) -> None:
self.fileSelectionList.add_path_list(self.droppedFiles)
self.fileSelectionList.add_path_list(pathlib.Path(x) for x in self.droppedFiles)
event.accept()
def update_ui_for_archive(self, parse_filename: bool = True) -> None:
@ -726,9 +730,13 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.menuRemove.setEnabled(enabled)
self.menuViewRawTags.setEnabled(enabled)
if self.comic_archive is not None:
for tag_id in tags:
self.view_tag_actions[tag_id].setEnabled(tags[tag_id].enabled and self.comic_archive.has_tags(tag_id))
self.remove_tag_actions[tag_id].setEnabled(tags[tag_id].enabled and self.comic_archive.has_tags(tag_id))
for tag in loaded_tags.values():
self.view_tag_actions[tag.id].setEnabled(
loaded_tags[tag.id].enabled and self.comic_archive.has_tags(tag)
)
self.remove_tag_actions[tag.id].setEnabled(
loaded_tags[tag.id].enabled and self.comic_archive.has_tags(tag)
)
self.actionWrite_Tags.setEnabled(writeable)
@ -748,7 +756,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.lblFilename.setText(filename)
self.lblArchiveType.setText(ca.archiver.name() + " archive")
self.lblArchiveType.setText(ca.Archiver.name + " archive")
page_count = f" ({ca.get_number_of_pages()} pages)"
self.lblPageCount.setText(page_count)
@ -757,7 +765,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
tag_info = ""
for md in supported_md:
if ca.has_tags(md):
tag_info += "" + tags[md].name() + "\n"
tag_info += f"{md.name}\n"
self.lblTagList.setText(tag_info)
@ -1075,13 +1083,15 @@ class TaggerWindow(QtWidgets.QMainWindow):
if dialog.exec():
file_list = dialog.selectedFiles()
if file_list:
self.fileSelectionList.twList.selectRow(self.fileSelectionList.add_path_item(file_list[0])[0])
self.fileSelectionList.twList.selectRow(
self.fileSelectionList.add_path_item(pathlib.Path(file_list[0]))[0]
)
def select_file(self, folder_mode: bool = False) -> None:
dialog = self.file_dialog(folder_mode=folder_mode)
if dialog.exec():
file_list = dialog.selectedFiles()
self.fileSelectionList.add_path_list(file_list)
self.fileSelectionList.add_path_list([pathlib.Path(x) for x in file_list])
def file_dialog(self, folder_mode: bool = False) -> QtWidgets.QFileDialog:
dialog = QtWidgets.QFileDialog(self)
@ -1178,68 +1188,71 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.metadata_to_form()
def write_tags(self) -> None:
if self.metadata is not None and self.comic_archive is not None:
if self.config[0].General__prompt_on_save:
reply = QtWidgets.QMessageBox.question(
self,
"Save Tags",
f"Are you sure you wish to save {', '.join([tags[tag_id].name() for tag_id in self.selected_write_tags])} tags to this archive?",
QtWidgets.QMessageBox.StandardButton.Yes,
QtWidgets.QMessageBox.StandardButton.No,
)
else:
reply = QtWidgets.QMessageBox.StandardButton.Yes
if self.metadata is None or self.comic_archive is None:
QtWidgets.QMessageBox.information(self, "Whoops!", "No data to write!")
return
if reply != QtWidgets.QMessageBox.StandardButton.Yes:
return
QtWidgets.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.CursorShape.WaitCursor))
self.form_to_metadata()
tags = self.selected_write_tags
if self.config[0].General__prompt_on_save:
reply = QtWidgets.QMessageBox.question(
self,
"Save Tags",
f"Are you sure you wish to save {', '.join([tag.name for tag in tags])} tags to this archive?",
QtWidgets.QMessageBox.StandardButton.Yes,
QtWidgets.QMessageBox.StandardButton.No,
)
else:
reply = QtWidgets.QMessageBox.StandardButton.Yes
failed_tag: str = ""
# Save each tag
for tag_id in self.selected_write_tags:
success = self.comic_archive.write_tags(self.metadata, tag_id)
if not success:
failed_tag = tags[tag_id].name()
break
if reply != QtWidgets.QMessageBox.StandardButton.Yes:
return
QtWidgets.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.CursorShape.WaitCursor))
self.form_to_metadata()
self.comic_archive.load_cache(set(tags))
QtWidgets.QApplication.restoreOverrideCursor()
failed_tag: str = ""
# Save each tag
for tag in tags:
try:
self.comic_archive.write_tags(ctversion.version, self.metadata, tag)
except Exception:
failed_tag = tag.name
break
if failed_tag:
# self.comic_archive.load_cache(set(loaded_tags))
QtWidgets.QApplication.restoreOverrideCursor()
if failed_tag:
QtWidgets.QMessageBox.warning(
self,
"Save failed",
f"The tag save operation seemed to fail for: {failed_tag}",
)
else:
self.clear_dirty_flag()
self.update_info_box()
self.update_menus()
# Only try to read if write was successful
self.metadata, error = self.read_selected_tags(self.selected_read_tags, self.comic_archive)
if error is not None:
QtWidgets.QMessageBox.warning(
self,
"Save failed",
f"The tag save operation seemed to fail for: {failed_tag}",
"Read Failed!",
f"One or more of the selected read tags failed to load for {self.comic_archive.path}, check log for details",
)
else:
self.clear_dirty_flag()
self.update_info_box()
self.update_menus()
logger.error("Failed to load metadata for %s: %s", self.comic_archive.path, error)
# Only try to read if write was successful
self.metadata, error = self.read_selected_tags(self.selected_read_tags, self.comic_archive)
if error is not None:
QtWidgets.QMessageBox.warning(
self,
"Read Failed!",
f"One or more of the selected read tags 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()
self.fileSelectionList.update_current_row()
self.update_ui_for_archive()
else:
QtWidgets.QMessageBox.information(self, "Whoops!", "No data to write!")
def select_read_tags(self, tag_ids: list[str]) -> None:
def select_read_tags(self, tags: list[Tag]) -> None:
"""Should only be called from the combobox signal"""
if self.dirty_flag_verification(
"Change Read Tags",
"If you change read tag(s) now, data in the form will be lost. Are you sure?",
):
self.selected_read_tags = list(reversed(tag_ids))
self.config[0].internal__read_tags = self.selected_read_tags
self.selected_read_tags = list(reversed(tags))
self.config[0].internal__read_tags = [tag.id for tag in self.selected_read_tags]
self.update_menus()
if self.comic_archive is not None:
self.load_archive(self.comic_archive)
@ -1250,7 +1263,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
def select_write_tags(self) -> None:
self.selected_write_tags = self.cbSelectedWriteTags.currentData()
self.config[0].internal__write_tags = self.selected_write_tags
self.config[0].internal__write_tags = [tag.id for tag in self.selected_write_tags]
self.update_tag_tweaks()
self.update_menus()
@ -1258,16 +1271,15 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.config[0].Sources__source = self.cbx_sources.itemData(s)
def update_credit_colors(self) -> None:
selected_tags = [tags[tag_id] for tag_id in self.selected_write_tags]
enabled = set()
for tag in selected_tags:
for tag in self.selected_write_tags:
enabled.update(tag.supported_attributes)
credit_attributes = [x for x in self.md_attributes.items() if "credits." in x[0]]
for r in range(self.twCredits.rowCount()):
w = self.twCredits.item(r, 1)
supports_role = any(tag.supports_credit_role(str(w.text())) for tag in selected_tags)
supports_role = any(tag.supports_credit_role(str(w.text())) for tag in self.selected_write_tags)
for credit in credit_attributes:
widget_enabled = credit[0] in enabled
widget = self.twCredits.item(r, credit[1])
@ -1278,10 +1290,10 @@ class TaggerWindow(QtWidgets.QMainWindow):
def update_tag_tweaks(self) -> None:
# depending on the current data tag, certain fields are disabled
enabled_widgets = set()
for tag_id in self.selected_write_tags:
if not tags[tag_id].enabled:
for tag in self.selected_write_tags:
if not tag.enabled:
continue
enabled_widgets.update(tags[tag_id].supported_attributes)
enabled_widgets.update(tag.supported_attributes)
for md_field, widget in self.md_attributes.items():
if widget is not None and not isinstance(widget, (int)):
@ -1427,25 +1439,25 @@ class TaggerWindow(QtWidgets.QMainWindow):
def adjust_tags_combo(self) -> None:
"""Select the enabled tags. Since tags are merged in an overlay fashion the last item in the list takes priority. We reverse the order for display to the user"""
unchecked = set(self.enabled_tags()) - set(self.selected_read_tags)
for i, tag_id in enumerate(reversed(self.selected_read_tags)):
if not tags[tag_id].enabled:
for i, tag in enumerate(reversed(self.selected_read_tags)):
if not tag.enabled:
continue
item_idx = self.cbSelectedReadTags.findData(tag_id)
self.cbSelectedReadTags.setItemChecked(item_idx, True)
itemx = self.cbSelectedReadTags.findData(tag)
self.cbSelectedReadTags.setItemChecked(itemx, True)
# Order matters, move items to list order
if item_idx != i:
self.cbSelectedReadTags.moveItem(item_idx, row=i)
for tag_id in unchecked:
self.cbSelectedReadTags.setItemChecked(self.cbSelectedReadTags.findData(tag_id), False)
if itemx != i:
self.cbSelectedReadTags.moveItem(itemx, row=i)
for tag in unchecked:
self.cbSelectedReadTags.setItemChecked(self.cbSelectedReadTags.findData(tag), False)
# select the current tag_id
# select the current tag
unchecked = set(self.enabled_tags()) - set(self.selected_write_tags)
for tag_id in self.selected_write_tags:
if not tags[tag_id].enabled:
for tag in self.selected_write_tags:
if not tag.enabled:
continue
self.cbSelectedWriteTags.setItemChecked(self.cbSelectedWriteTags.findData(tag_id), True)
for tag_id in unchecked:
self.cbSelectedWriteTags.setItemChecked(self.cbSelectedWriteTags.findData(tag_id), False)
self.cbSelectedWriteTags.setItemChecked(self.cbSelectedWriteTags.findData(tag), True)
for tag in unchecked:
self.cbSelectedWriteTags.setItemChecked(self.cbSelectedWriteTags.findData(tag), False)
self.update_tag_tweaks()
def populate_tag_names(self) -> None:
@ -1453,15 +1465,15 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.cbSelectedWriteTags.clear()
self.cbSelectedReadTags.clear()
# Add the entries to the tag comboboxes
for tag in tags.values():
for tag in loaded_tags.values():
if not tag.enabled:
continue
if self.config[0].Metadata_Options__use_short_tag_names:
self.cbSelectedWriteTags.addItem(tag.id.upper(), tag.id)
self.cbSelectedReadTags.addItem(tag.id.upper(), tag.id)
self.cbSelectedWriteTags.addItem(tag.id.upper(), tag)
self.cbSelectedReadTags.addItem(tag.id.upper(), tag)
else:
self.cbSelectedWriteTags.addItem(tag.name(), tag.id)
self.cbSelectedReadTags.addItem(tag.name(), tag.id)
self.cbSelectedWriteTags.addItem(tag.name, tag)
self.cbSelectedReadTags.addItem(tag.name, tag)
def populate_combo_boxes(self) -> None:
self.populate_tag_names()
@ -1569,24 +1581,24 @@ class TaggerWindow(QtWidgets.QMainWindow):
def remove_auto(self) -> None:
self.remove_tags(self.selected_write_tags)
def remove_tags(self, tag_ids: list[str]) -> None:
def remove_tags(self, tags: list[Tag]) -> None:
# remove the indicated tag_ids from the archive
ca_list = self.fileSelectionList.get_selected_archive_list()
has_md_count = 0
file_md_count = {}
for tag_id in tag_ids:
file_md_count[tag_id] = 0
for tag in tags:
file_md_count[tag.id] = 0
for ca in ca_list:
for tag_id in tag_ids:
if ca.has_tags(tag_id):
for tag in tags:
if ca.has_tags(tag):
has_md_count += 1
file_md_count[tag_id] += 1
file_md_count[tag.id] += 1
if has_md_count == 0:
QtWidgets.QMessageBox.information(
self,
"Remove Tags",
f"No archives with {', '.join([tags[tag_id].name() for tag_id in tag_ids])} tags selected!",
f"No archives with {', '.join([tag.name for tag in tags])} tags selected!",
)
return
@ -1599,7 +1611,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
reply = QtWidgets.QMessageBox.question(
self,
"Remove Tags",
f"Are you sure you wish to remove {', '.join([f'{tags[tag_id].name()} tags from {count} files' for tag_id, count in file_md_count.items()])} removing a total of {has_md_count} tag(s)?",
f"Are you sure you wish to remove {', '.join([f'{loaded_tags[tag_id].name} tags from {count} files' for tag_id, count in file_md_count.items()])} removing a total of {has_md_count} tag(s)?", # TODO: Remove loaded_tags
QtWidgets.QMessageBox.StandardButton.Yes,
QtWidgets.QMessageBox.StandardButton.No,
)
@ -1611,7 +1623,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
progdialog.setMinimumDuration(300)
center_window_on_parent(progdialog)
failed_list = []
failed_list: list[Exception] = []
success_count = 0
for prog_idx, ca in enumerate(ca_list, 1):
if prog_idx % 10 == 0:
@ -1620,16 +1632,17 @@ class TaggerWindow(QtWidgets.QMainWindow):
break
progdialog.setValue(prog_idx)
progdialog.setLabelText(str(ca.path))
for tag_id in tag_ids:
if ca.has_tags(tag_id) and ca.is_writable():
if ca.remove_tags(tag_id):
for tag in tags:
if ca.has_tags(tag) and ca.is_writable():
try:
ca.remove_tags(tag)
success_count += 1
else:
failed_list.append(ca.path)
except Exception as e:
failed_list.append(OSError(f"Failed to remove {tag.name} from {ca.path}: {e}"))
# Abandon any further tag removals to prevent any greater damage to archive
break
ca.reset_cache()
ca.load_cache(self.enabled_tags())
# ca.load_cache(self.enabled_tags())
progdialog.hide()
QtCore.QCoreApplication.processEvents()
@ -1640,8 +1653,8 @@ class TaggerWindow(QtWidgets.QMainWindow):
summary = f"Successfully removed {success_count} tags in archive(s)."
if failed_list:
summary += f"\n\nThe remove operation failed in the following {len(failed_list)} archive(s):\n"
for f in failed_list:
summary += f"\t{f}\n"
for ex in failed_list:
summary += f"\t{ex}\n"
dlg = LogWindow(self)
dlg.set_text(summary)
@ -1653,22 +1666,24 @@ class TaggerWindow(QtWidgets.QMainWindow):
ca_list = self.fileSelectionList.get_selected_archive_list()
has_src_count = 0
src_tag_ids: list[str] = self.selected_read_tags
dest_tag_ids: list[str] = self.selected_write_tags
src_tags: list[Tag] = self.selected_read_tags
src_tag_names: list[str] = [tag.name for tag in src_tags]
dest_tags: list[Tag] = self.selected_write_tags
dest_tag_names: list[str] = [tag.name for tag in dest_tags]
if len(src_tag_ids) == 1 and src_tag_ids[0] in dest_tag_ids:
if len(src_tags) == 1 and src_tags[0] in dest_tags:
# Remove the read tag from the write tag
dest_tag_ids.remove(src_tag_ids[0])
dest_tags.remove(src_tags[0])
if not dest_tag_ids:
if not dest_tags:
QtWidgets.QMessageBox.information(
self, "Copy Tags", "Can't copy tag tag onto itself. Read tag and modify tag must be different."
)
return
for ca in ca_list:
for tag_id in src_tag_ids:
if ca.has_tags(tag_id):
for tag in src_tags:
if ca.has_tags(tag):
has_src_count += 1
continue
@ -1676,7 +1691,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
QtWidgets.QMessageBox.information(
self,
"Copy Tags",
f"No archives with {', '.join([tags[tag_id].name() for tag_id in src_tag_ids])} tags selected!",
f"No archives with {', '.join(src_tag_names)} tags selected!",
)
return
@ -1690,9 +1705,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
self,
"Copy Tags",
f"Are you sure you wish to copy the combined (with overlay order) tags of "
f"{', '.join([tags[tag_id].name() for tag_id in src_tag_ids])} "
f"to {', '.join([tags[tag_id].name() for tag_id in dest_tag_ids])} tags in "
f"{has_src_count} archive(s)?",
f"{', '.join(src_tag_names)} to {', '.join(dest_tag_names)} tags in {has_src_count} archive(s)?",
QtWidgets.QMessageBox.StandardButton.Yes,
QtWidgets.QMessageBox.StandardButton.No,
)
@ -1705,21 +1718,21 @@ class TaggerWindow(QtWidgets.QMainWindow):
center_window_on_parent(prog_dialog)
QtCore.QCoreApplication.processEvents()
failed_list = []
failed_list: list[Exception] = []
success_count = 0
for prog_idx, ca in enumerate(ca_list, 1):
if prog_idx % 10 == 0:
QtCore.QCoreApplication.processEvents()
ca_saved = False
md, error = self.read_selected_tags(src_tag_ids, ca)
md, error = self.read_selected_tags(src_tags, ca)
if error is not None:
failed_list.append(ca.path)
failed_list.append(error)
continue
if md.is_empty:
continue
for tag_id in dest_tag_ids:
if ca.has_tags(tag_id):
for tag in dest_tags:
if ca.has_tags(tag):
if prog_dialog.wasCanceled():
break
@ -1727,18 +1740,20 @@ class TaggerWindow(QtWidgets.QMainWindow):
prog_dialog.setLabelText(str(ca.path))
center_window_on_parent(prog_dialog)
if tag_id == "cbi" and self.config[0].Metadata_Options__apply_transform_on_bulk_operation:
md = CBLTransformer(md, self.config[0]).apply()
if ca.write_tags(md, tag_id):
try:
ca.write_tags(ctversion.version, md, tag)
if not ca_saved:
success_count += 1
ca_saved = True
else:
failed_list.append(ca.path)
except Exception as e:
failed_list.append(
OSError(
f"Failed to copy {', '.join(src_tag_names)} to {', '.join(dest_tag_names)} tags for {ca.path}: {e}"
)
)
ca.reset_cache()
ca.load_cache({*self.selected_read_tags, *self.selected_write_tags})
# ca.load_cache({*self.selected_read_tags, *self.selected_write_tags})
prog_dialog.hide()
QtCore.QCoreApplication.processEvents()
@ -1749,8 +1764,8 @@ class TaggerWindow(QtWidgets.QMainWindow):
summary = f"Successfully copied tags in {success_count} archive(s)."
if failed_list:
summary += f"\n\nThe copy operation failed in the following {len(failed_list)} archive(s):\n"
for f in failed_list:
summary += f"\t{f}\n"
for ex in failed_list:
summary += f"\t{ex}\n"
dlg = LogWindow(self)
dlg.set_text(summary)
@ -1778,7 +1793,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
"Aborting...",
f"One or more of the read tags failed to load for {ca.path}. Aborting to prevent any possible further damage. Check log for details.",
)
logger.error("Failed to load tags from %s: %s", self.ca.path, error)
logger.error("Failed to load tags from %s: %s", ca.path, error)
return False, match_results
if md.is_empty:
@ -1923,16 +1938,16 @@ class TaggerWindow(QtWidgets.QMainWindow):
online_results=matches,
match_status=MatchStatus.good_match,
md=md,
tags_written=self.selected_write_tags,
tags_written=[tag.id for tag in self.selected_write_tags],
)
def write_Tags() -> bool:
for tag_id in self.selected_write_tags:
for tag in self.selected_write_tags:
# write out the new data
if not ca.write_tags(md, tag_id):
self.auto_tag_log(
f"{tags[tag_id].name()} save failed! Aborting any additional tag saves.\n"
)
try:
ca.write_tags(ctversion.version, md, tag)
except Exception as e:
self.auto_tag_log(f"{tag.name} save failed! {e}\nAborting any additional tag saves.\n")
return False
return True
@ -1946,13 +1961,14 @@ class TaggerWindow(QtWidgets.QMainWindow):
match_results.write_failures.append(res)
ca.reset_cache()
ca.load_cache({*self.selected_read_tags, *self.selected_write_tags})
# ca.load_cache({*self.selected_read_tags, *self.selected_write_tags})
return success, match_results
def auto_tag(self) -> None:
ca_list = self.fileSelectionList.get_selected_archive_list()
tag_names = ", ".join([tags[tag_id].name() for tag_id in self.selected_write_tags])
tags = self.selected_write_tags
tag_names = ", ".join([tag.name for tag in tags])
if not ca_list:
QtWidgets.QMessageBox.information(self, "Auto-Tag", "No archives selected!")
@ -1995,7 +2011,8 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.auto_tag_log(f"{ca.path}\n")
QtCore.QCoreApplication.processEvents()
try:
cover_idx = ca.read_tags(self.selected_read_tags[0]).get_cover_page_index_list()[0]
md, exc = self.read_selected_tags(tags, ca)
cover_idx = md.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)
@ -2063,7 +2080,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
matchdlg = AutoTagMatchWindow(
self,
match_results.multiple_matches,
self.selected_write_tags,
tags,
lambda match: self.current_talker().fetch_comic_data(match.issue_id),
self.config[0],
self.current_talker(),
@ -2137,12 +2154,11 @@ class TaggerWindow(QtWidgets.QMainWindow):
def page_browser_closed(self) -> None:
self.page_browser = None
def view_raw_tags(self, tag_id: str) -> None:
tag = tags[tag_id]
if self.comic_archive is not None and self.comic_archive.has_tags(tag.id):
def view_raw_tags(self, tag: Tag) -> None:
if self.comic_archive is not None and self.comic_archive.has_tags(tag):
dlg = LogWindow(self)
dlg.set_text(self.comic_archive.read_raw_tags(tag.id))
dlg.setWindowTitle(f"Raw {tag.name()} Tag View")
dlg.set_text(self.comic_archive.read_raw_tags(tag))
dlg.setWindowTitle(f"Raw {tag.name} Tag View")
dlg.exec()
def show_wiki(self) -> None:
@ -2222,12 +2238,12 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.update_ui_for_archive()
def read_selected_tags(self, tag_ids: list[str], ca: ComicArchive) -> tuple[GenericMetadata, Exception | None]:
def read_selected_tags(self, tags: list[Tag], ca: ComicArchive) -> tuple[GenericMetadata, Exception | None]:
md = GenericMetadata()
error = None
try:
for tag_id in tag_ids:
metadata = ca.read_tags(tag_id)
for tag in tags:
metadata = ca.read_tags(tag)
md.overlay(
metadata,
mode=self.config[0].Metadata_Options__tag_merge,

View File

@ -82,7 +82,8 @@ class ModifyStyleItemDelegate(QtWidgets.QStyledItemDelegate):
# Multiselect combobox from: https://gis.stackexchange.com/a/351152 (with custom changes)
class CheckableComboBox(QtWidgets.QComboBox):
itemChecked = pyqtSignal(str, bool)
itemChecked = pyqtSignal(object, bool)
# This would be (Tag, bool) but pyqt enforces types so we use object to allow duck-typing
def __init__(self, *args: Any, **kwargs: Any):
super().__init__(*args, **kwargs)

View File

@ -15,10 +15,10 @@ from comictalker.comictalker import ComicTalker, TalkerError
logger = logging.getLogger(__name__)
__all__ = [
__all__ = (
"ComicTalker",
"TalkerError",
]
)
def get_talkers(

View File

@ -107,7 +107,7 @@ class ComicTalker:
name: str = "Example"
id: str = "example"
comictagger_min_ver: str = "1.6.0a7" # The ComicTagger minimum version required by the talker
comictagger_min_ver: str = "" # The ComicTagger minimum version required by the talker
website: str = "https://example.com"
logo_url: str = f"{website}/logo.png"
attribution: str = f"Metadata provided by <a href='{website}'>{name}</a>"

View File

@ -46,7 +46,7 @@ install_requires =
pyrate-limiter>=2.6,<3
pyyaml
requests==2.*
settngs==0.11.0
settngs==0.11.1
text2digits
typing-extensions>=4.3.0
wordninja
@ -62,10 +62,10 @@ exclude =
[options.entry_points]
console_scripts = comictagger=comictaggerlib.main:main
comicapi.archiver =
zip = comicapi.archivers.zip:ZipArchiver
sevenzip = comicapi.archivers.sevenzip:SevenZipArchiver
rar = comicapi.archivers.rar:RarArchiver
folder = comicapi.archivers.folder:FolderArchiver
zip = comicapi.comic.zip:ZipComic
sevenzip = comicapi.comic.sevenzip:SevenZipComic
rar = comicapi.comic.rar:RarComic
folder = comicapi.comic.folder:FolderComic
comicapi.tags =
cr = comicapi.tags.comicrack:ComicRack
comictagger.talker =

View File

@ -4,18 +4,21 @@ import os
import pathlib
import platform
import shutil
from contextlib import nullcontext as does_not_raise
import pytest
from importlib_metadata import entry_points
import comicapi.archivers.rar
import comicapi.archivers.zip
import comicapi.comic.rar
import comicapi.comicarchive
import comicapi.genericmetadata
import comicapi.tags
import comicapi.tags.comicrack
import comictaggerlib.ctversion
from testing.filenames import datadir
@pytest.mark.xfail(not comicapi.archivers.rar.rar_support, reason="rar support")
@pytest.mark.xfail(not comicapi.comic.rar.rar_support, reason="rar support")
def test_getPageNameList():
c = comicapi.comicarchive.ComicArchive(pathlib.Path(str(datadir)) / "fake_cbr.cbr")
assert c.seems_to_be_a_comic_archive()
@ -34,35 +37,40 @@ def test_getPageNameList():
def test_page_type_read(cbz):
md = cbz.read_tags("cr")
md = cbz.read_tags(comicapi.tags.comicrack.ComicRack())
assert md.pages[0].type == comicapi.genericmetadata.PageType.FrontCover
def test_read_tags(cbz, md_saved):
md = cbz.read_tags("cr")
md = cbz.read_tags(comicapi.tags.comicrack.ComicRack())
assert md == md_saved
def test_write_cr(tmp_comic):
md = tmp_comic.read_tags("cr")
def test_write_cr(tmp_comic_path):
tmp_comic = comicapi.comicarchive.ComicArchive(tmp_comic_path)
md = tmp_comic.read_tags(comicapi.tags.comicrack.ComicRack())
md.apply_default_page_list(tmp_comic.get_page_name_list())
assert tmp_comic.write_tags(md, "cr")
with does_not_raise():
tmp_comic.write_tags(comictaggerlib.ctversion.version, md, comicapi.tags.comicrack.ComicRack())
md = tmp_comic.read_tags("cr")
md = tmp_comic.read_tags(comicapi.tags.comicrack.ComicRack())
@pytest.mark.xfail(not (comicapi.archivers.rar.rar_support and shutil.which("rar")), reason="rar support")
@pytest.mark.xfail(not (comicapi.comic.rar.rar_support and shutil.which("rar")), reason="rar support")
def test_save_cr_rar(tmp_path, md_saved):
cbr_path = datadir / "fake_cbr.cbr"
shutil.copy(cbr_path, tmp_path)
tmp_comic = comicapi.comicarchive.ComicArchive(tmp_path / cbr_path.name)
assert tmp_comic.seems_to_be_a_comic_archive()
assert tmp_comic.write_tags(comicapi.genericmetadata.md_test, "cr")
with does_not_raise():
tmp_comic.write_tags(
comictaggerlib.ctversion.version, comicapi.genericmetadata.md_test, comicapi.tags.comicrack.ComicRack()
)
md = tmp_comic.read_tags("cr")
md = tmp_comic.read_tags(comicapi.tags.comicrack.ComicRack())
# This is a fake CBR we don't need to care about the pages for this test
md.pages = []
@ -70,31 +78,36 @@ def test_save_cr_rar(tmp_path, md_saved):
assert md == md_saved
def test_page_type_write(tmp_comic):
md = tmp_comic.read_tags("cr")
def test_page_type_write(tmp_comic_path):
tmp_comic = comicapi.comicarchive.ComicArchive(tmp_comic_path)
md = tmp_comic.read_tags(comicapi.tags.comicrack.ComicRack())
t = md.pages[0]
t.type = ""
assert tmp_comic.write_tags(md, "cr")
with does_not_raise():
tmp_comic.write_tags(comictaggerlib.ctversion.version, md, comicapi.tags.comicrack.ComicRack())
md = tmp_comic.read_tags("cr")
md = tmp_comic.read_tags(comicapi.tags.comicrack.ComicRack())
def test_invalid_zip(tmp_comic: comicapi.comicarchive.ComicArchive):
with open(tmp_comic.path, mode="b+r") as f:
def test_invalid_zip(tmp_comic_path):
with open(tmp_comic_path, mode="b+r") as f:
# Corrupting the first file only breaks the first file. If it is never read then no exception will be raised
f.seek(-10, os.SEEK_END) # seek to a probably bad place in th Central Directory and write some bytes
f.write(b"PK\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000")
result = tmp_comic.write_tags(comicapi.genericmetadata.md_test, "cr") # This is not the first file
assert result
tmp_comic = comicapi.comicarchive.ComicArchive(tmp_comic_path)
with pytest.raises(comicapi.comic.comicfile.BadComic, match="^Error listing files in zip archive"):
tmp_comic.write_tags(
comictaggerlib.ctversion.version, comicapi.genericmetadata.md_test, comicapi.tags.comicrack.ComicRack()
) # This is not the first file
assert not tmp_comic.seems_to_be_a_comic_archive() # Calls archiver.is_valid
archivers = []
for x in entry_points(group="comicapi.archiver"):
archiver = x.load()
archiver: comicapi.comic.ComicFile = x.load()
supported = archiver.enabled
exe_found = True
if archiver.exe != "":
@ -104,24 +117,39 @@ for x in entry_points(group="comicapi.archiver"):
)
def export(existing: comicapi.comic.ComicFile, new: comicapi.comic.ComicFile) -> None:
"""
Copies all content from the current archive to
"""
new.write_files(files=existing.read_files(existing.get_filename_list()), filenames=existing.get_filename_list())
if (
comicapi.tags.TagLocation.COMMENT in new.tag_locations
and comicapi.tags.TagLocation.COMMENT in existing.tag_locations
):
new.write_comment(existing.read_comment())
@pytest.mark.parametrize("archiver", archivers)
def test_copy_from_archive(archiver, tmp_path, cbz, md_saved):
comic_path = tmp_path / cbz.path.with_suffix("").name
comic_path = tmp_path / cbz.path.with_suffix(f".new{archiver.extension}").name
archive = archiver.open(comic_path)
archive = archiver(comic_path)
assert archive.copy_from_archive(cbz.archiver)
with does_not_raise():
export(existing=cbz.Archiver(cbz.path), new=archive)
comic_archive = comicapi.comicarchive.ComicArchive(comic_path)
assert comic_archive.seems_to_be_a_comic_archive()
assert set(cbz.archiver.get_filename_list()) == set(comic_archive.archiver.get_filename_list())
assert set(cbz.Archiver(cbz.path).get_filename_list()) == set(comic_archive.archiver.get_filename_list())
md = comic_archive.read_tags("cr")
md = comic_archive.read_tags(comicapi.tags.comicrack.ComicRack)
assert md == md_saved
def test_rename(tmp_comic, tmp_path):
def test_rename(tmp_comic_path, tmp_path):
tmp_comic = comicapi.comicarchive.ComicArchive(tmp_comic_path)
old_path = tmp_comic.path
tmp_comic.rename(tmp_path / "test.cbz")
assert not old_path.exists()
@ -129,8 +157,9 @@ def test_rename(tmp_comic, tmp_path):
assert tmp_comic.path != old_path
def test_rename_ro_dest(tmp_comic, tmp_path):
old_path = tmp_comic.path
def test_rename_ro_dest(tmp_comic_path, tmp_path):
tmp_comic = comicapi.comicarchive.ComicArchive(tmp_comic_path)
dest = tmp_path / "tmp"
dest.mkdir(mode=0o000)
with pytest.raises(OSError):
@ -138,6 +167,6 @@ def test_rename_ro_dest(tmp_comic, tmp_path):
raise OSError("Windows sucks")
tmp_comic.rename(dest / "test.cbz")
dest.chmod(mode=0o777)
assert old_path.exists()
assert tmp_comic_path.exists()
assert tmp_comic.path.exists()
assert tmp_comic.path == old_path
assert tmp_comic.path == tmp_comic_path

View File

@ -3,6 +3,7 @@ from __future__ import annotations
import copy
import datetime
import io
import pathlib
import shutil
import unittest.mock
from argparse import Namespace
@ -34,17 +35,20 @@ except ImportError:
@pytest.fixture
def cbz():
yield comicapi.comicarchive.ComicArchive(filenames.cbz_path)
yield comicapi.comicarchive.ComicArchive(
pathlib.Path(str(filenames.cbz_path))
) # When testing these always refer to a file on a filesystem
@pytest.fixture
def tmp_comic(tmp_path):
shutil.copy(filenames.cbz_path, tmp_path)
yield comicapi.comicarchive.ComicArchive(tmp_path / filenames.cbz_path.name)
def tmp_comic_path(tmp_path: pathlib.Path):
shutil.copy(str(filenames.cbz_path), str(tmp_path)) # When testing these always refer to a file on a filesystem
yield (tmp_path / filenames.cbz_path.name)
@pytest.fixture
def cbz_double_cover(tmp_path, tmp_comic):
def cbz_double_cover(tmp_path, tmp_comic_path):
tmp_comic = comicapi.comicarchive.ComicArchive(tmp_comic_path)
cover = Image.open(io.BytesIO(tmp_comic.get_page(0)))
other_page = Image.open(io.BytesIO(tmp_comic.get_page(tmp_comic.get_number_of_pages() - 1)))
@ -54,7 +58,6 @@ def cbz_double_cover(tmp_path, tmp_comic):
double_cover.paste(cover, (cover.width, 0))
tmp_comic.archiver.write_file("double_cover.jpg", double_cover.tobytes("jpeg", "RGB"))
yield tmp_comic
@pytest.fixture
@ -174,7 +177,7 @@ def mock_now(monkeypatch):
monkeypatch.setattr(comictaggerlib.md, "datetime", mydatetime)
@pytest.fixture
@pytest.fixture(autouse=True)
def mock_version(monkeypatch):
version = "1.3.2a5"
version_tuple = (1, 3, 2)

View File

@ -1,11 +1,12 @@
from __future__ import annotations
from comicapi.comicarchive import ComicArchive
from comicapi.tags.comicrack import ComicRack
from comictaggerlib.imagehasher import ImageHasher
def test_ahash(cbz: ComicArchive):
md = cbz.read_tags("cr")
md = cbz.read_tags(ComicRack())
covers = md.get_cover_page_index_list()
assert covers
cover = cbz.get_page(covers[0])
@ -16,7 +17,7 @@ def test_ahash(cbz: ComicArchive):
def test_dhash(cbz: ComicArchive):
md = cbz.read_tags("cr")
md = cbz.read_tags(ComicRack())
covers = md.get_cover_page_index_list()
assert covers
cover = cbz.get_page(covers[0])
@ -27,7 +28,7 @@ def test_dhash(cbz: ComicArchive):
def test_phash(cbz: ComicArchive):
md = cbz.read_tags("cr")
md = cbz.read_tags(ComicRack())
covers = md.get_cover_page_index_list()
assert covers
cover = cbz.get_page(covers[0])

View File

@ -4,7 +4,9 @@ import settngs
import comicapi.comicarchive
import comicapi.genericmetadata
import comictaggerlib.ctversion
import comictaggerlib.resulttypes
from comicapi.tags.comicrack import ComicRack
from comictaggerlib import ctsettings
from comictaggerlib.cli import CLI
from comictalker.comictalker import ComicTalker
@ -12,21 +14,19 @@ from comictalker.comictalker import ComicTalker
def test_save(
plugin_config: tuple[settngs.Config[ctsettings.ct_ns], dict[str, ComicTalker]],
tmp_comic,
tmp_comic_path,
md_saved,
mock_now,
) -> None:
tmp_comic = comicapi.comicarchive.ComicArchive(tmp_comic_path)
# Overwrite the series so it has definitely changed
tmp_comic.write_tags(md_saved.replace(series="nothing"), "cr")
tmp_comic.write_tags(comictaggerlib.ctversion.version, md_saved.replace(series="nothing"), ComicRack())
md = tmp_comic.read_tags("cr")
md = tmp_comic.read_tags(ComicRack())
# Check that it changed
assert md != md_saved
# Clear the cached tags
tmp_comic.reset_cache()
# Setup the app
config = plugin_config[0]
talkers = plugin_config[1]
@ -37,17 +37,20 @@ def test_save(
# Check online, should be intercepted by comicvine_api
config[0].Auto_Tag__online = True
# Use the temporary comic we created
config[0].Runtime_Options__files = [tmp_comic.path]
config[0].Runtime_Options__files = [tmp_comic_path]
# Read and save ComicRack tags
config[0].Runtime_Options__tags_read = ["cr"]
config[0].Runtime_Options__tags_write = ["cr"]
# Search using the correct series since we just put the wrong series name in the CBZ
config[0].Auto_Tag__metadata = comicapi.genericmetadata.GenericMetadata(series=md_saved.series)
# Run ComicTagger
CLI(config[0], talkers).run()
assert CLI(config[0], talkers).run() == 0
# tmp_comic is invalid it can't handle outside changes so we need a new one
tmp_comic = comicapi.comicarchive.ComicArchive(tmp_comic_path)
# Read the CBZ
md = tmp_comic.read_tags("cr")
md = tmp_comic.read_tags(ComicRack())
# This is inserted here because otherwise several other tests
# unrelated to comicvine need to be re-worked
@ -68,18 +71,16 @@ def test_save(
def test_delete(
plugin_config: tuple[settngs.Config[ctsettings.ct_ns], dict[str, ComicTalker]],
tmp_comic,
tmp_comic_path,
md_saved,
mock_now,
) -> None:
md = tmp_comic.read_tags("cr")
tmp_comic = comicapi.comicarchive.ComicArchive(tmp_comic_path)
md = tmp_comic.read_tags(ComicRack())
# Check that the metadata starts correct
assert md == md_saved
# Clear the cached metadata
tmp_comic.reset_cache()
# Setup the app
config = plugin_config[0]
talkers = plugin_config[1]
@ -88,14 +89,17 @@ def test_delete(
config[0].Commands__command = comictaggerlib.resulttypes.Action.delete
# Use the temporary comic we created
config[0].Runtime_Options__files = [tmp_comic.path]
config[0].Runtime_Options__files = [tmp_comic_path]
# Delete ComicRack tags
config[0].Runtime_Options__tags_write = ["cr"]
# Run ComicTagger
CLI(config[0], talkers).run()
assert CLI(config[0], talkers).run() == 0
# tmp_comic is invalid it can't handle outside changes so we need a new one
tmp_comic = comicapi.comicarchive.ComicArchive(tmp_comic_path)
# Read the CBZ
md = tmp_comic.read_tags("cr")
md = tmp_comic.read_tags(ComicRack())
# The default page list is set on load if the comic has the requested tags
empty_md = comicapi.genericmetadata.GenericMetadata()
@ -106,40 +110,40 @@ def test_delete(
def test_rename(
plugin_config: tuple[settngs.Config[ctsettings.ct_ns], dict[str, ComicTalker]],
tmp_comic,
tmp_comic_path,
md_saved,
mock_now,
) -> None:
md = tmp_comic.read_tags("cr")
tmp_comic = comicapi.comicarchive.ComicArchive(tmp_comic_path)
md = tmp_comic.read_tags(ComicRack())
# Check that the metadata starts correct
assert md == md_saved
# Clear the cached metadata
tmp_comic.reset_cache()
# Setup the app
config = plugin_config[0]
talkers = plugin_config[1]
# Delete
# rename
config[0].Commands__command = comictaggerlib.resulttypes.Action.rename
# Use the temporary comic we created
config[0].Runtime_Options__files = [tmp_comic.path]
config[0].Runtime_Options__files = [tmp_comic_path]
# Read ComicRack tags
config[0].Runtime_Options__tags_read = ["cr"]
# Set the template
config[0].File_Rename__template = "{series}"
# Use the current directory
config[0].File_Rename__dir = ""
# Run ComicTagger
CLI(config[0], talkers).run()
assert CLI(config[0], talkers).run() == 0
# Update the comic path
tmp_comic.path = tmp_comic.path.parent / (md.series + ".cbz")
# tmp_comic is invalid it can't handle outside changes so we need a new one
tmp_comic = comicapi.comicarchive.ComicArchive(tmp_comic.path.parent / ((md.series or "comic") + ".cbz"))
# Read the CBZ
md = tmp_comic.read_tags("cr")
md = tmp_comic.read_tags(ComicRack())
# Validate that we got the correct metadata back
assert md == md_saved

View File

@ -5,23 +5,26 @@ import io
import pytest
from PIL import Image
import comicapi.comicarchive
import comictaggerlib.imagehasher
import comictaggerlib.issueidentifier
import testing.comicdata
import testing.comicvine
from comicapi.genericmetadata import ImageHash
from comicapi.tags.comicrack import ComicRack
from comictaggerlib.resulttypes import IssueResult
def test_crop(cbz_double_cover, config, tmp_path, comicvine_api):
def test_crop(cbz_double_cover, config, tmp_path, comicvine_api, tmp_comic_path):
tmp_comic = comicapi.comicarchive.ComicArchive(tmp_comic_path)
config, definitions = config
ii = comictaggerlib.issueidentifier.IssueIdentifier(cbz_double_cover, config, comicvine_api)
ii = comictaggerlib.issueidentifier.IssueIdentifier(tmp_comic, config, comicvine_api)
im = Image.open(io.BytesIO(cbz_double_cover.archiver.read_file("double_cover.jpg")))
im = Image.open(io.BytesIO(tmp_comic.Archiver(tmp_comic.path).read_file("double_cover.jpg")))
cropped = ii._crop_double_page(im)
original = cbz_double_cover.get_page(0)
original = tmp_comic.get_page(0)
original_hash = comictaggerlib.imagehasher.ImageHasher(data=original).average_hash()
cropped_hash = comictaggerlib.imagehasher.ImageHasher(image=cropped).average_hash()
@ -58,7 +61,7 @@ def test_get_issue_cover_match_score(
def test_search(cbz, config, comicvine_api):
config, definitions = config
ii = comictaggerlib.issueidentifier.IssueIdentifier(cbz, config, comicvine_api)
result, issues = ii.identify(cbz, cbz.read_tags("cr"))
result, issues = ii.identify(cbz, cbz.read_tags(ComicRack))
cv_expected = IssueResult(
series=f"{testing.comicvine.cv_volume_result['results']['name']} ({testing.comicvine.cv_volume_result['results']['start_year']})",
distance=0,

View File

@ -5,6 +5,8 @@ from importlib_metadata import entry_points
import comicapi.genericmetadata
import testing.comicdata
from comicapi.comicarchive import ComicArchive
from comicapi.tags.tag import Tag
from comictaggerlib.md import prepare_metadata
tags = []
@ -20,29 +22,20 @@ if not tags:
@pytest.mark.parametrize("tag_type", tags)
def test_metadata(mock_version, tmp_comic, md_saved, tag_type):
tag = tag_type(mock_version[0])
supported_attributes = tag.supported_attributes
tag.write_tags(comicapi.genericmetadata.md_test, tmp_comic.archiver)
written_metadata = tag.read_tags(tmp_comic.archiver)
md = md_saved._get_clean_metadata(*supported_attributes)
def test_metadata(mock_version, tmp_comic_path, md_saved, tag_type: type[Tag]):
comic = ComicArchive(tmp_comic_path)
tag = tag_type()
comic.remove_tags(tag)
# Hack back in the pages variable because CoMet supports identifying the cover by the filename
if tag.id == "comet":
md.pages = [
comicapi.genericmetadata.PageMetadata(
archive_index=0,
bookmark="",
display_index=0,
filename="!cover.jpg",
type=comicapi.genericmetadata.PageType.FrontCover,
)
]
written_metadata = written_metadata._get_clean_metadata(*supported_attributes).replace(
pages=written_metadata.pages
)
else:
written_metadata = written_metadata._get_clean_metadata(*supported_attributes)
no_tags = comic.read_tags(tag)
assert no_tags == comicapi.genericmetadata.GenericMetadata()
comic.write_tags(mock_version[0], comicapi.genericmetadata.md_test, tag)
written_metadata = comic.read_tags(tag)
md = md_saved._get_clean_metadata(*tag.supported_attributes)
written_metadata = written_metadata._get_clean_metadata(*tag.supported_attributes)
assert written_metadata == md

View File

@ -71,8 +71,8 @@ def test_recursive_list_with_file(tmp_path) -> None:
glob_in_name = tmp_path / "[e-b]"
glob_in_name.mkdir()
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, str(glob_in_name)]))
expected_result = {foo_png, temp_cbr, temp_file, temp_txt, temp_txt2}
result = set(comicapi.utils.get_recursive_filelist([temp_txt2, tmp_path, glob_in_name]))
assert result == expected_result