Compare commits
9 Commits
9c2a2cbafd
...
plugin-rev
Author | SHA1 | Date | |
---|---|---|---|
2aff026ebc | |||
b8dff44e54 | |||
71fa3a57e0 | |||
88908691d1 | |||
19495b6e36 | |||
91b5d3ce5d | |||
371c457d5b | |||
acb1d2951f | |||
8261e98ae1 |
BIN
comicapi/.DS_Store
vendored
Normal file
BIN
comicapi/.DS_Store
vendored
Normal file
Binary file not shown.
@ -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"]
|
@ -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
|
@ -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()
|
@ -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
|
@ -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)
|
@ -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
|
16
comicapi/comic/__init__.py
Normal file
16
comicapi/comic/__init__.py
Normal 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
220
comicapi/comic/comicfile.py
Normal 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
61
comicapi/comic/folder.py
Normal 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
304
comicapi/comic/rar.py
Normal 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
152
comicapi/comic/sevenzip.py
Normal 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
143
comicapi/comic/zip.py
Normal 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
|
@ -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())
|
||||
|
@ -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):
|
||||
|
@ -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"]
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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()
|
||||
|
@ -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():
|
||||
|
@ -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):
|
||||
|
@ -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")
|
||||
|
@ -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
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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"))
|
||||
|
||||
|
@ -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 ""
|
||||
|
@ -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
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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(
|
||||
|
@ -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
|
||||
|
@ -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()))
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
|
@ -15,10 +15,10 @@ from comictalker.comictalker import ComicTalker, TalkerError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
__all__ = (
|
||||
"ComicTalker",
|
||||
"TalkerError",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def get_talkers(
|
||||
|
@ -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>"
|
||||
|
10
setup.cfg
10
setup.cfg
@ -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 =
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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])
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
Reference in New Issue
Block a user