diff --git a/.editorconfig b/.editorconfig index cf714c0..03b345d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,9 +9,25 @@ trim_trailing_whitespace = true insert_final_newline = true max_line_length = 100 +[{*.html,*.jte,*.kte,*.jinja}] +indent_size = 4 +insert_final_newline = false +max_line_length = off + +[*.json] +insert_final_newline = false + [*.md] trim_trailing_whitespace = false max_line_length = off [*.py] indent_size = 4 + +[{*.xml,*.xsd}] +indent_size = 4 +insert_final_newline = false +max_line_length = off + +[{*.yaml,*.yml}] +indent_style = tab diff --git a/.gitattributes b/.gitattributes index b83e3eb..0f5d2c2 100644 --- a/.gitattributes +++ b/.gitattributes @@ -32,13 +32,6 @@ *.tgz binary *.zip binary -# C# -*.cs text diff=csharp -*.cshtml text diff=html -*.csproj text eol=crlf -*.csx text diff=csharp -*.sln text eol=crlf - # Java *.gradle text diff=java *.gradle.kts text diff=kotlin diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml new file mode 100644 index 0000000..d9dc209 --- /dev/null +++ b/.github/workflows/testing.yaml @@ -0,0 +1,50 @@ +name: Testing + +on: + push: + branches: + - main + paths-ignore: + - docs/** + pull_request: + branches: + - main + paths-ignore: + - docs/** + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + pytest: + strategy: + fail-fast: false + matrix: + python-version: + - '3.10' + - '3.11' + - '3.12' + - '3.13' + os: + - ubuntu-latest + - macos-latest + - windows-latest + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - name: Setup Python + run: uv python install ${{ matrix.python-version }} + - name: Install project + run: uv sync --group tests + - name: Run tests + run: uv run pytest + collector: + needs: [pytest] + if: always() + runs-on: ubuntu-latest + steps: + - name: Check for failures + if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') + run: echo job failed && exit 1 diff --git a/.gitignore b/.gitignore index f5e1db6..cf93059 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ logs/ # =====Files===== *.iml *.log +.coverage .envrc .pdm-python .python-version diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9d0649f..0adadbc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.0 + rev: v0.9.3 hooks: - id: ruff-format - id: ruff diff --git a/README.md b/README.md index c3a8a3c..7dfc31f 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,9 @@ [![Ruff](https://img.shields.io/badge/ruff-enabled-informational?logo=ruff&style=flat-square)](https://github.com/astral-sh/ruff) [![Github - Contributors](https://img.shields.io/github/contributors/Buried-In-Code/Perdoo.svg?logo=Github&label=Contributors&style=flat-square)](https://github.com/Buried-In-Code/Perdoo/graphs/contributors) +[![Github Action - Testing](https://img.shields.io/github/actions/workflow/status/Buried-In-Code/Perdoo/testing.yaml?branch=main&logo=Github&label=Testing&style=flat-square)](https://github.com/Buried-In-Code/Perdoo/actions/workflows/testing.yaml) +[![Github Action - Publishing](https://img.shields.io/github/actions/workflow/status/Buried-In-Code/Perdoo/publishing.yaml?branch=main&logo=Github&label=Publishing&style=flat-square)](https://github.com/Buried-In-Code/Perdoo/actions/workflows/publishing.yaml) + Perdoo is designed to assist in sorting and organizing your comic collection by utilizing metadata files stored within comic archives.\ Perdoo standardizes all your digital comics into a unified format (cb7, cbt, or cbz).\ @@ -93,44 +96,53 @@ Unlike other tagging tools, Perdoo employs a manual approach when metadata files - [Marvel](https://www.marvel.com/comics) using the [Esak](https://github.com/Metron-Project/Esak) library. - [Metron](https://metron.cloud) using the [Mokkari](https://github.com/Metron-Project/Mokkari) library. -## File Organization - -### Series Naming - -Series with a volume greater than 1 will display its volume in the title. - -### Comic Naming - -The files are named based on the format of the comic: - -- **_Default_**: `{Series Title}_#{Issue Number}.cbz` -- Annual: `{Series Title}_Annual_#{Issue Number}.cbz` -- Digital Chapter: `{Series Title}_Chapter_#{Issue Number}.cbz` -- Graphic Novel: `{Series Title}_#{Issue Number}_GN.cbz` -- Hardcover: `{Series Title}_#{Issue Number}_HC.cbz` -- Omnibus: `{Series Title}_#{Issue Number}.cbz` -- Trade Paperback: `{Series Title}_#{Issue Number}_TPB.cbz` - -### Folder Structure - -``` -Collection Root -+-- Publisher -| +-- Series -| | +-- Series_#001.cbz -| | +-- Series_Annual_#01.cbz -| | +-- Series_Chapter_#01.cbz -| | +-- Series_#01_GN.cbz -| | +-- Series_#01_HC.cbz -| | +-- Series_#01_TPB.cbz -| +-- Series-v2 -| | +-- Series-v2_#001.cbz -| | +-- Series-v2_Annual_#01.cbz -| | +-- Series-v2_Chapter_#01.cbz -| | +-- Series-v2_#01_GN.cbz -| | +-- Series-v2_#01_HC.cbz -| | +-- Series-v2_#01_TPB.cbz -``` +## File Renaming and Organization + +File naming and organization uses a pattern-based approach, it tries to name based on the MetronInfo data with a fallback to ComicInfo. +Naming is done based on the Comic Format, set the value to `""` and it will fallback to the default setting. + +- **_Default_**: `{publisher-name}/{series-name}-v{volume}/{series-name}-v{volume}_#{number:3}` +- **Annual**: `{publisher-name}/{series-name}-v{volume}/{series-name}-v{volume}_Annual_#{number:2}` +- **Digital Chapter**: `{publisher-name}/{series-name}-v{volume}/{series-name}-v{volume}_Chapter_#{number:3}` +- **Graphic Novel**: `{publisher-name}/{series-name}-v{volume}/{series-name}-v{volume}_GN_#{number:2}` +- **Hardcover**: `{publisher-name}/{series-name}-v{volume}/{series-name}-v{volume}_HC_#{number:2}` +- **Limited Series**: `""` _Falls back to Default_ +- **Omnibus**: `{publisher-name}/{series-name}-v{volume}/{series-name}-v{volume}_OB_#{number:2}` +- **One-Shot**: `""` _Falls back to Default_ +- **Single Issue**: `""` _Falls back to Default_ +- **Trade Paperback**: `{publisher-name}/{series-name}-v{volume}/{series-name}-v{volume}_TPB_#{number:2}` + +### Options + +- **Padding**: Int and Int-like fields, such as `{number}`, can include optional zero-padding by specifying the length (e.g. `{number:3}` will pad 0's to be atleast 3 digits long, `12` => `012`). +- **Sanitization**: All metadata values are sanitized to remove characters outside the set `0-9a-zA-Z&!-`. Custom characters can still be added directly to patterns. + +| Pattern Key | Description | +| -------------------- | ------------------------------------------------------ | +| `{cover-date}` | The issue cover date in `yyyy-mm-dd` format. | +| `{cover-day}` | The day from the issue cover date. | +| `{cover-month}` | The month from the issue cover date. | +| `{cover-year}` | The year from the issue cover date. | +| `{format}` | The full format name of the series. | +| `{id}` | The primary id of the issue. | +| `{imprint}` | The publisher's imprint. | +| `{isbn}` | The issue's ISBN. | +| `{issue-count}` | The total number of issues in the series. | +| `{lang}` | The issue's language. | +| `{number}` | The issue number. | +| `{publisher-id}` | The publisher's unique id. | +| `{publisher-name}` | The full name of the publisher. | +| `{series-id}` | The series' unique id. | +| `{series-name}` | The full name of the series. | +| `{series-sort-name}` | Sort-friendly name (omits leading "The", "A", etc...). | +| `{series-year}` | The year the series started. | +| `{store-date}` | The store date of the issue in `yyyy-mm-dd` format. | +| `{store-day}` | The day from the issue store date. | +| `{store-month}` | The month from the issue store date. | +| `{store-year}` | The year from the issue store date. | +| `{title}` | The issue title. | +| `{upc}` | The issue's UPC. | +| `{volume}` | The volume of the series. | ## Socials diff --git a/docs/img/perdoo-import.svg b/docs/img/perdoo-import.svg index c50e134..55fadf3 100644 --- a/docs/img/perdoo-import.svg +++ b/docs/img/perdoo-import.svg @@ -1,4 +1,4 @@ - + - - + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - - - - - - - + - + - - -Usage: Perdoo import [OPTIONS] TARGET - - Import comics into your collection using Perdoo.                                - -╭─ Arguments ──────────────────────────────────────────────────────────────────╮ -*    target      PATH  Import comics from the specified file/folder.          -[required]                                    -╰──────────────────────────────────────────────────────────────────────────────╯ -╭─ Options ────────────────────────────────────────────────────────────────────╮ ---skip-convert  Skip converting comics to     -                                                 the configured format.        ---sync-s[Force|Outdated|Skip]  Sync ComicInfo/MetronInfo     -                                                 with online services.         -[default: Outdated]          ---skip-clean  Skip removing any files not   -                                                 listed in the                 -                                                 'image_extensions' setting.   ---skip-rename  Skip renaming comics based    -                                                 on their                      -                                                 ComicInfo/MetronInfo.         ---skip-organize  Skip organize/moving comics   -                                                 to appropriate directories.   ---clean-c  Clean the cache before        -                                                 starting the synchronization  -                                                 process. Removes all cached   -                                                 files.                        ---debug  Enable debug mode to show     -                                                 extra information.            ---help  Show this message and exit.   -╰──────────────────────────────────────────────────────────────────────────────╯ - + + +Usage: Perdoo import [OPTIONS] TARGET + + Import comics into your collection using Perdoo.                                + +╭─ Arguments ──────────────────────────────────────────────────────────────────╮ +*    target      PATH  Import comics from the specified file/folder.          +[required]                                    +╰──────────────────────────────────────────────────────────────────────────────╯ +╭─ Options ────────────────────────────────────────────────────────────────────╮ +--skip-convert  Skip converting comics to the  +                                                configured format.             +--sync-s[Force|Outdated|Skip]  Sync ComicInfo/MetronInfo      +                                                with online services.          +[default: Outdated]           +--skip-clean  Skip removing any files not    +                                                listed in the                  +                                                'image_extensions' setting.    +--skip-rename  Skip organizing and renaming   +                                                comics based on their          +                                                MetronInfo/ComicInfo.          +--clean-c  Clean the cache before         +                                                starting the synchronization   +                                                process. Removes all cached    +                                                files.                         +--debug  Enable debug mode to show      +                                                extra information.             +--help  Show this message and exit.    +╰──────────────────────────────────────────────────────────────────────────────╯ + diff --git a/perdoo/__init__.py b/perdoo/__init__.py index 1ba2632..16caa6a 100644 --- a/perdoo/__init__.py +++ b/perdoo/__init__.py @@ -55,17 +55,17 @@ def setup_logging(debug: bool = False) -> None: omit_repeated_times=False, show_level=True, show_time=False, - show_path=False, + show_path=True, console=CONSOLE, ) console_handler.setLevel(logging.DEBUG if debug else logging.INFO) console_handler.setFormatter(logging.Formatter("%(message)s")) file_handler = logging.FileHandler(filename=get_state_root() / "perdoo.log") - file_handler.setLevel(logging.DEBUG) + file_handler.setLevel(logging.DEBUG if debug else logging.INFO) logging.basicConfig( format="[%(asctime)s] [%(levelname)-8s] {%(name)s} | %(message)s", datefmt="%Y-%m-%d %H:%M:%S", - level=logging.DEBUG, + level=logging.DEBUG if debug else logging.INFO, handlers=[console_handler, file_handler], ) diff --git a/perdoo/__main__.py b/perdoo/__main__.py index 2f4125e..2eac4e8 100644 --- a/perdoo/__main__.py +++ b/perdoo/__main__.py @@ -12,14 +12,7 @@ from perdoo.archives import CBRArchive, get_archive from perdoo.cli import archive_app, settings_app from perdoo.console import CONSOLE -from perdoo.main import ( - clean_archive, - convert_file, - organize_file, - rename_file, - save_metadata, - sync_metadata, -) +from perdoo.main import clean_archive, convert_file, rename_file, save_metadata, sync_metadata from perdoo.metadata import ComicInfo, MetronInfo, get_metadata from perdoo.metadata.metron_info import InformationSource from perdoo.services import BaseService, Comicvine, Marvel, Metron @@ -159,11 +152,10 @@ def run( ] = False, skip_rename: Annotated[ bool, - Option("--skip-rename", help="Skip renaming comics based on their ComicInfo/MetronInfo."), - ] = False, - skip_organize: Annotated[ - bool, - Option("--skip-organize", help="Skip organize/moving comics to appropriate directories."), + Option( + "--skip-rename", + help="Skip organizing and renaming comics based on their MetronInfo/ComicInfo.", + ), ] = False, clean_cache: Annotated[ bool, @@ -193,7 +185,6 @@ def run( "flags.sync": sync, "flags.skip-clean": skip_clean, "flags.skip-rename": skip_rename, - "flags.skip-organize": skip_organize, "flags.clean-cache": clean_cache, } ) @@ -246,17 +237,8 @@ def run( save_metadata(entry=entry, metadata=metadata, settings=settings) if not skip_rename: - with CONSOLE.status("Renaming to match metadata", spinner="simpleDotsScrolling"): - rename_file(entry=entry, metadata=metadata, settings=settings) - - if not skip_organize: - with CONSOLE.status("Organizing based on metadata", spinner="simpleDotsScrolling"): - organize_file( - entry=entry, - metadata=metadata, - root=settings.output.folder, - target=target.parent, - ) + with CONSOLE.status("Renaming based on metadata", spinner="simpleDotsScrolling"): + rename_file(entry=entry, metadata=metadata, settings=settings, target=target.parent) with CONSOLE.status("Cleaning up empty folders"): delete_empty_folders(folder=target) diff --git a/perdoo/main.py b/perdoo/main.py index 0c8948d..d18ae31 100644 --- a/perdoo/main.py +++ b/perdoo/main.py @@ -8,7 +8,7 @@ from perdoo.metadata.comic_info import Page from perdoo.services import BaseService from perdoo.settings import Service, Settings -from perdoo.utils import Search, list_files, sanitize +from perdoo.utils import Search, list_files LOGGER = logging.getLogger("perdoo") @@ -63,8 +63,8 @@ def save_metadata( entry: BaseArchive, metadata: tuple[MetronInfo | None, ComicInfo | None], settings: Settings ) -> None: metron_info, comic_info = metadata - if comic_info and settings.output.metadata.comic_info.create: - if settings.output.metadata.comic_info.handle_pages: + if comic_info and settings.output.comic_info.create: + if settings.output.comic_info.handle_pages: LOGGER.info("Processing ComicInfo Page data") _load_page_info( image_extensions=settings.image_extensions, entry=entry, comic_info=comic_info @@ -73,7 +73,7 @@ def save_metadata( comic_info.pages = [] LOGGER.info("Writing 'ComicInfo.xml' to '%s'", entry.path.name) entry.write_file("ComicInfo.xml", comic_info.to_bytes().decode()) - if metron_info and settings.output.metadata.metron_info.create: + if metron_info and settings.output.metron_info.create: LOGGER.info("Writing 'MetronInfo.xml' to '%s'", entry.path.name) entry.write_file("MetronInfo.xml", metron_info.to_bytes().decode()) @@ -110,22 +110,40 @@ def _rename_images_in_archive( def rename_file( - entry: BaseArchive, metadata: tuple[MetronInfo | None, ComicInfo | None], settings: Settings + entry: BaseArchive, + metadata: tuple[MetronInfo | None, ComicInfo | None], + settings: Settings, + target: Path, ) -> None: metron_info, comic_info = metadata - new_filename = ( - metron_info.filename if metron_info else comic_info.filename if comic_info else None + new_filepath = ( + metron_info.get_filename(settings=settings.output.naming) + if metron_info + else comic_info.get_filename(settings=settings.output.naming) + if comic_info + else None ) - - if new_filename is None: + if new_filepath is None: LOGGER.warning("Not enough information to rename '%s', skipping", entry.path.stem) return + output = settings.output.folder / f"{new_filepath}.{settings.output.format}" - if new_filename != entry.path.stem: - renamed_file = entry.path.with_stem(new_filename) - LOGGER.info("Renaming '%s' to '%s'", entry.path.stem, renamed_file.stem) - shutil.move(entry.path, renamed_file) - entry.path = renamed_file + if output == entry.path: + return + if output.exists(): + LOGGER.warning("'%s' already exists, skipping", output.relative_to(settings.output.folder)) + return + output.parent.mkdir(parents=True, exist_ok=True) + + LOGGER.info( + "Renaming '%s' to '%s'", + entry.path.relative_to(target), + output.relative_to(settings.output.folder.parent), + ) + shutil.move(entry.path, output) + entry.path = output + + new_filename = output.stem if all( x.startswith(new_filename) @@ -137,46 +155,3 @@ def rename_file( _rename_images_in_archive( image_extensions=settings.image_extensions, entry=entry, filename=new_filename ) - - -def _construct_new_file_path( - metadata: tuple[MetronInfo | None, ComicInfo | None], root: Path -) -> Path | None: - metron_info, comic_info = metadata - output = root - if metron_info: - if metron_info.publisher: - output /= sanitize(metron_info.publisher.name) - output /= metron_info.series.filename - elif comic_info: - if comic_info.publisher: - output /= sanitize(comic_info.publisher) - if comic_info.series_filename: - output /= comic_info.series_filename - return output - - -def organize_file( - entry: BaseArchive, - metadata: tuple[MetronInfo | None, ComicInfo | None], - root: Path, - target: Path, -) -> None: - new_file_path = _construct_new_file_path(metadata=metadata, root=root) - - if not new_file_path or new_file_path == root: - LOGGER.warning("Not enough information to organize '%s', skipping", entry.path.stem) - return - if new_file_path == entry.path.parent: - return - - new_file_path.mkdir(parents=True, exist_ok=True) - organized_file = new_file_path / entry.path.name - if organized_file.exists(): - LOGGER.warning("'%s' already exists, skipping", organized_file.relative_to(root)) - return - LOGGER.info( - "Moving '%s' to '%s'", entry.path.relative_to(target), organized_file.relative_to(root) - ) - shutil.move(entry.path, organized_file) - entry.path = organized_file diff --git a/perdoo/metadata/_base.py b/perdoo/metadata/_base.py index 56275b4..172cb48 100644 --- a/perdoo/metadata/_base.py +++ b/perdoo/metadata/_base.py @@ -1,5 +1,8 @@ -__all__ = ["PascalModel"] +__all__ = ["PascalModel", "sanitize"] +import logging +import re +from collections.abc import Callable from pathlib import Path from pydantic.alias_generators import to_pascal @@ -14,6 +17,17 @@ except ImportError: from typing_extensions import Self # Python < 3.11 +LOGGER = logging.getLogger(__name__) + + +def sanitize(value: str | None) -> str | None: + if not value: + return value + value = str(value) + value = re.sub(r"[^0-9a-zA-Z&! ]+", "", value.replace("-", " ")) + value = " ".join(value.split()) + return value.replace(" ", "-") + class PascalModel( BaseXmlModel, @@ -46,3 +60,20 @@ def display(self) -> None: ] CONSOLE.print(Panel.fit("\n".join(content_vals), title=type(self).__name__)) + + def evaluate_pattern(self, pattern_map: dict[str, Callable[[Self], str]], pattern: str) -> str: + def replace_match(match: re.Match) -> str: + key = match.group("key") + padding = match.group("padding") + + if key not in pattern_map: + LOGGER.warning("Unknown pattern: %s", key) + return key + value = pattern_map[key](self) + + if padding and (isinstance(value, int) or (isinstance(value, str) and value.isdigit())): + return f"{int(value):0{padding}}" + return sanitize(value=value) or "" + + pattern_regex = re.compile(r"{(?P[a-zA-Z-]+)(?::(?P\d+))?}") + return pattern_regex.sub(replace_match, pattern) diff --git a/perdoo/metadata/comic_info.py b/perdoo/metadata/comic_info.py index 26d3ef2..4f4a99d 100644 --- a/perdoo/metadata/comic_info.py +++ b/perdoo/metadata/comic_info.py @@ -1,5 +1,6 @@ __all__ = ["AgeRating", "ComicInfo", "Manga", "Page", "PageType", "YesNo"] +from collections.abc import Callable from datetime import date from enum import Enum from pathlib import Path @@ -11,7 +12,7 @@ from pydantic_xml import attr, computed_attr, element, wrapped from perdoo.metadata._base import PascalModel -from perdoo.utils import sanitize +from perdoo.settings import Naming def str_to_list(value: str | None) -> list[str]: @@ -293,22 +294,48 @@ def story_arc_list(self) -> list[str]: def story_arc_list(self, value: list[str]) -> None: self.story_arc = list_to_str(value=value) - @property - def series_filename(self) -> str | None: - if self.series: - return sanitize( - self.series - if not self.volume or self.volume == 1 - else f"{self.series} v{self.volume}" - ) - return None + def get_filename(self, settings: Naming) -> str: + from perdoo.metadata.metron_info import Format + + return self.evaluate_pattern( + pattern_map=PATTERN_MAP, + pattern={ + Format.ANNUAL.value: settings.annual or settings.default, + Format.DIGITAL_CHAPTER.value: settings.digital_chapter or settings.default, + Format.GRAPHIC_NOVEL.value: settings.graphic_novel or settings.default, + Format.HARDCOVER.value: settings.hardcover or settings.default, + Format.LIMITED_SERIES.value: settings.limited_series or settings.default, + Format.OMNIBUS.value: settings.omnibus or settings.default, + Format.ONE_SHOT.value: settings.one_shot or settings.default, + Format.SINGLE_ISSUE.value: settings.single_issue or settings.default, + Format.TRADE_PAPERBACK.value: settings.trade_paperback or settings.default, + }.get(self.format, settings.default), + ) - @property - def filename(self) -> str | None: - identifier = "" - if self.number: - identifier = f"_#{self.number.zfill(3)}" - elif self.title: - identifier = f"_{sanitize(self.title)}" - - return f"{self.series_filename}{identifier}" if self.series_filename else None + +PATTERN_MAP: dict[str, Callable[[ComicInfo], str | int | None]] = { + "cover-date": lambda x: x.cover_date, + "cover-day": lambda x: x.day, + "cover-month": lambda x: x.month, + "cover-year": lambda x: x.year, + "format": lambda x: x.format, + "id": lambda _: None, + "imprint": lambda x: x.imprint, + "isbn": lambda _: None, + "issue-count": lambda x: x.count, + "lang": lambda x: x.language_iso, + "number": lambda x: x.number, + "publisher-id": lambda _: None, + "publisher-name": lambda x: x.publisher, + "series-id": lambda _: None, + "series-name": lambda x: x.series, + "series-sort-name": lambda _: None, + "series-year": lambda x: x.volume if x.volume > 1900 else None, + "store-date": lambda _: None, + "store-day": lambda _: None, + "store-month": lambda _: None, + "store-year": lambda _: None, + "title": lambda x: x.title, + "upc": lambda _: None, + "volume": lambda x: x.volume if x.volume < 1900 else None, +} diff --git a/perdoo/metadata/metron_info.py b/perdoo/metadata/metron_info.py index f55198b..88ac265 100644 --- a/perdoo/metadata/metron_info.py +++ b/perdoo/metadata/metron_info.py @@ -17,6 +17,7 @@ "Url", ] +from collections.abc import Callable from datetime import date, datetime from decimal import Decimal from enum import Enum @@ -26,7 +27,7 @@ from pydantic_xml import attr, computed_attr, element, wrapped from perdoo.metadata._base import PascalModel -from perdoo.utils import sanitize +from perdoo.settings import Naming T = TypeVar("T") @@ -274,12 +275,6 @@ class Series(PascalModel): volume: NonNegativeInt | None = element(default=None) volume_count: PositiveInt | None = element(default=None) - @property - def filename(self) -> str: - return sanitize( - self.name if not self.volume or self.volume == 1 else f"{self.name} v{self.volume}" - ) - class Universe(PascalModel): designation: str | None = element(default=None) @@ -368,22 +363,46 @@ class MetronInfo(PascalModel): def schema_location(self) -> str: return "https://raw.githubusercontent.com/Metron-Project/metroninfo/master/schema/v1.0/MetronInfo.xsd" - @property - def filename(self) -> str: - identifier = "" - if self.number: - padded_number = self.number.zfill( - {Format.SINGLE_ISSUE: 3, Format.DIGITAL_CHAPTER: 3}.get(self.series.format, 2) - ) - identifier = f"_#{padded_number}" - elif self.collection_title: - identifier = f"_{sanitize(self.collection_title)}" - - return { - Format.ANNUAL: f"{self.series.filename}_Annual{identifier}", - Format.DIGITAL_CHAPTER: f"{self.series.filename}_Chapter{identifier}", - Format.GRAPHIC_NOVEL: f"{self.series.filename}{identifier}_GN", - Format.HARDCOVER: f"{self.series.filename}{identifier}_HC", - Format.OMNIBUS: f"{self.series.filename}{identifier}", - Format.TRADE_PAPERBACK: f"{self.series.filename}{identifier}_TPB", - }.get(self.series.format, f"{self.series.filename}{identifier}") + def get_filename(self, settings: Naming) -> str: + return self.evaluate_pattern( + pattern_map=PATTERN_MAP, + pattern={ + Format.ANNUAL: settings.annual or settings.default, + Format.DIGITAL_CHAPTER: settings.digital_chapter or settings.default, + Format.GRAPHIC_NOVEL: settings.graphic_novel or settings.default, + Format.HARDCOVER: settings.hardcover or settings.default, + Format.LIMITED_SERIES: settings.limited_series or settings.default, + Format.OMNIBUS: settings.omnibus or settings.default, + Format.ONE_SHOT: settings.one_shot or settings.default, + Format.SINGLE_ISSUE: settings.single_issue or settings.default, + Format.TRADE_PAPERBACK: settings.trade_paperback or settings.default, + }.get(self.series.format, settings.default), + ) + + +PATTERN_MAP: dict[str, Callable[[MetronInfo], str | int | None]] = { + "cover-date": lambda x: x.cover_date, + "cover-day": lambda x: x.cover_date.day if x.cover_date else None, + "cover-month": lambda x: x.cover_date.month if x.cover_date else None, + "cover-year": lambda x: x.cover_date.year if x.cover_date else None, + "format": lambda x: x.series.format.value if x.series.format else None, + "id": lambda x: next(iter(i.value for i in x.ids if i.primary), None), + "imprint": lambda x: x.publisher.imprint.value if x.publisher and x.publisher.imprint else None, + "isbn": lambda x: x.gtin.isbn if x.gtin else None, + "issue-count": lambda x: x.series.issue_count, + "lang": lambda x: x.series.lang, + "number": lambda x: x.number, + "publisher-id": lambda x: x.publisher.id if x.publisher else None, + "publisher-name": lambda x: x.publisher.name if x.publisher else None, + "series-id": lambda x: x.series.id, + "series-name": lambda x: x.series.name, + "series-sort-name": lambda x: x.series.sort_name, + "series-year": lambda x: x.series.start_year, + "store-date": lambda x: x.store_date or "", + "store-year": lambda x: x.store_date.year if x.store_date else "", + "store-month": lambda x: x.store_date.month if x.store_date else "", + "store-day": lambda x: x.store_date.day if x.store_date else "", + "title": lambda x: x.collection_title, + "upc": lambda x: x.gtin.upc if x.gtin else None, + "volume": lambda x: x.series.volume, +} diff --git a/perdoo/settings.py b/perdoo/settings.py index 28d17a2..33f8a74 100644 --- a/perdoo/settings.py +++ b/perdoo/settings.py @@ -2,9 +2,9 @@ "ComicInfo", "Comicvine", "Marvel", - "Metadata", "Metron", "MetronInfo", + "Naming", "Output", "Service", "Services", @@ -14,15 +14,15 @@ from enum import Enum from importlib.util import find_spec from pathlib import Path -from typing import Any, ClassVar, Literal +from typing import Annotated, Any, ClassVar, Literal import tomli_w as tomlwriter -from pydantic import BaseModel, field_validator +from pydantic import BaseModel, BeforeValidator, field_validator from rich.panel import Panel from perdoo import get_config_root, get_data_root from perdoo.console import CONSOLE -from perdoo.utils import flatten_dict +from perdoo.utils import blank_is_none, flatten_dict try: import tomllib as tomlreader # Python >= 3.11 @@ -49,15 +49,37 @@ class MetronInfo(SettingsModel): create: bool = True -class Metadata(SettingsModel): - comic_info: ComicInfo = ComicInfo() - metron_info: MetronInfo = MetronInfo() +class Naming(SettingsModel): + default: str = "{publisher-name}/{series-name}-v{volume}/{series-name}-v{volume}_#{number:3}" + annual: Annotated[str | None, BeforeValidator(blank_is_none)] = ( + "{publisher-name}/{series-name}-v{volume}/{series-name}-v{volume}_Annual_#{number:2}" + ) + digital_chapter: Annotated[str | None, BeforeValidator(blank_is_none)] = ( + "{publisher-name}/{series-name}-v{volume}/{series-name}-v{volume}_Chapter_#{number:3}" + ) + graphic_novel: Annotated[str | None, BeforeValidator(blank_is_none)] = ( + "{publisher-name}/{series-name}-v{volume}/{series-name}-v{volume}_GN_#{number:2}" + ) + hardcover: Annotated[str | None, BeforeValidator(blank_is_none)] = ( + "{publisher-name}/{series-name}-v{volume}/{series-name}-v{volume}_HC_#{number:2}" + ) + limited_series: Annotated[str | None, BeforeValidator(blank_is_none)] = None + omnibus: Annotated[str | None, BeforeValidator(blank_is_none)] = ( + "{publisher-name}/{series-name}-v{volume}/{series-name}-v{volume}_OB_#{number:2}" + ) + one_shot: Annotated[str | None, BeforeValidator(blank_is_none)] = None + single_issue: Annotated[str | None, BeforeValidator(blank_is_none)] = None + trade_paperback: Annotated[str | None, BeforeValidator(blank_is_none)] = ( + "{publisher-name}/{series-name}-v{volume}/{series-name}-v{volume}_TPB_#{number:2}" + ) class Output(SettingsModel): + comic_info: ComicInfo = ComicInfo() folder: Path = get_data_root() format: Literal["cb7", "cbt", "cbz"] = "cbz" - metadata: Metadata = Metadata() + metron_info: MetronInfo = MetronInfo() + naming: Naming = Naming() @field_validator("format", mode="before") def validate_format(cls, value: str) -> str: @@ -67,17 +89,17 @@ def validate_format(cls, value: str) -> str: class Comicvine(SettingsModel): - api_key: str | None = None + api_key: Annotated[str | None, BeforeValidator(blank_is_none)] = None class Marvel(SettingsModel): - public_key: str | None = None - private_key: str | None = None + public_key: Annotated[str | None, BeforeValidator(blank_is_none)] = None + private_key: Annotated[str | None, BeforeValidator(blank_is_none)] = None class Metron(SettingsModel): - password: str | None = None - username: str | None = None + password: Annotated[str | None, BeforeValidator(blank_is_none)] = None + username: Annotated[str | None, BeforeValidator(blank_is_none)] = None class Service(str, Enum): diff --git a/perdoo/utils.py b/perdoo/utils.py index 60c5c49..066b900 100644 --- a/perdoo/utils.py +++ b/perdoo/utils.py @@ -2,14 +2,13 @@ "IssueSearch", "Search", "SeriesSearch", + "blank_is_none", "delete_empty_folders", "flatten_dict", "list_files", - "sanitize", ] import logging -import re from dataclasses import dataclass from pathlib import Path from typing import Any @@ -56,14 +55,6 @@ def list_files(path: Path, *extensions: str) -> list[Path]: return humansorted(files, alg=ns.NA | ns.G | ns.P) -def sanitize(value: str | None) -> str | None: - if not value: - return value - value = re.sub(r"[^0-9a-zA-Z&! ]+", "", value.replace("-", " ")) - value = " ".join(value.split()) - return value.replace(" ", "-") - - def flatten_dict(content: dict[str, Any], parent_key: str = "") -> dict[str, Any]: items = {} for key, value in content.items(): @@ -95,3 +86,8 @@ def delete_empty_folders(folder: Path) -> None: if not any(folder.iterdir()): folder.rmdir() LOGGER.info("Deleted empty folder: %s", folder) + + +def blank_is_none(value: str) -> str | None: + """Enforces blank strings to be None.""" + return value if value else None diff --git a/pyproject.toml b/pyproject.toml index e0ff2af..da9305a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,13 +4,13 @@ requires = ["hatchling"] [dependency-groups] dev = [ - "pre-commit >= 4.0.1" + "pre-commit >= 4.1.0" ] tests = [ "pytest >= 8.3.4", "pytest-cov >= 6.0.0", - "tox >= 4.23.2", - "tox-uv >= 1.17.0" + "tox >= 4.24.1", + "tox-uv >= 1.20.1" ] [project] @@ -37,16 +37,16 @@ dependencies = [ "comicfn2dict >= 0.2.4", "esak >= 2.0.0", "lxml >= 5.3.0", - "mokkari >= 3.5.0", + "mokkari >= 3.6.0", "natsort >= 8.4.0", "pillow >= 11.1.0", - "pydantic >= 2.10.5", + "pydantic >= 2.10.6", "pydantic-xml >= 2.14.1", "rarfile >= 4.2", "rich >= 13.9.4", "simyan >= 1.4.0", "tomli >= 2.2.1 ; python_version < '3.11'", - "tomli-w >= 1.1.0", + "tomli-w >= 1.2.0", "typer >= 0.15.1" ] description = "Unify and organize your comic collection." @@ -135,6 +135,7 @@ split-on-trailing-comma = false classmethod-decorators = ["classmethod", "pydantic.field_validator"] [tool.ruff.lint.per-file-ignores] +"tests/*" = ["PLR2004", "S101"] [tool.ruff.lint.pydocstyle] convention = "google" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_naming.py b/tests/test_naming.py new file mode 100644 index 0000000..d3a8263 --- /dev/null +++ b/tests/test_naming.py @@ -0,0 +1,37 @@ +from perdoo.metadata._base import sanitize +from perdoo.metadata.comic_info import ComicInfo +from perdoo.metadata.metron_info import Format, MetronInfo, Publisher, Series +from perdoo.settings import Naming + + +def test_sanitize() -> None: + assert sanitize("Example Title!") == "Example-Title!" + assert sanitize("Example/Title: 123") == "ExampleTitle-123" + assert sanitize("!@#$%^&*()[]{};':,.<>?/") == "!&" + assert sanitize(None) is None + + +def test_metron_info_default_naming() -> None: + obj = MetronInfo( + publisher=Publisher(name="Example Publisher"), + series=Series(name="Example Series", volume=1, format=Format.TRADE_PAPERBACK), + number=2, + ) + assert ( + obj.get_filename(settings=Naming()) + == "Example-Publisher/Example-Series-v1/Example-Series-v1_TPB_#02" + ) + + +def test_comic_info_default_naming() -> None: + obj = ComicInfo( + publisher="Example Publisher", + series="Example Series", + format="Trade Paperback", + volume=1, + number=2, + ) + assert ( + obj.get_filename(settings=Naming()) + == "Example-Publisher/Example-Series-v1/Example-Series-v1_TPB_#02" + )