From c21e26ac53dee7e4d858239bc0ec4730836f81a3 Mon Sep 17 00:00:00 2001 From: Timmy Welch Date: Thu, 12 Sep 2024 12:22:05 -0700 Subject: [PATCH] Initial commit --- .github/workflows/build.yaml | 34 ++++ .github/workflows/release.yaml | 55 +++++ .gitignore | 112 ++++++++++ .pre-commit-config.yaml | 33 +++ LICENSE | 202 ++++++++++++++++++ README.md | 6 + ct_archived_tags/comet.py | 327 ++++++++++++++++++++++++++++++ ct_archived_tags/comicbookinfo.py | 234 +++++++++++++++++++++ pyproject.toml | 106 ++++++++++ 9 files changed, 1109 insertions(+) create mode 100644 .github/workflows/build.yaml create mode 100644 .github/workflows/release.yaml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 ct_archived_tags/comet.py create mode 100644 ct_archived_tags/comicbookinfo.py create mode 100644 pyproject.toml diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..2d50508 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,34 @@ +name: CI + +on: + pull_request: + push: + branches: + - '**' + +jobs: + build-and-publish: + runs-on: ${{ matrix.os }} + strategy: + matrix: + python_version: ['3.9'] + os: [ubuntu-latest] + + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Set up Python ${{ matrix.python_version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python_version }} + + - name: Install tox + run: | + python -m pip install --upgrade --upgrade-strategy eager tox + + - name: Build and install wheel + run: | + tox run -m build + python -m pip install dist/*.whl diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..e2ada11 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,55 @@ +name: CI + +on: + push: + tags: + - "[0-9]+.[0-9]+.[0-9]+*" + +jobs: + build-and-publish: + runs-on: ubuntu-latest + # Specifying a GitHub environment is optional, but strongly encouraged + environment: release + permissions: + # IMPORTANT: this permission is mandatory for trusted publishing + id-token: write + contents: write + + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Set up Python 3.9 + uses: actions/setup-python@v2 + with: + python-version: 3.9 + + - name: Install tox + run: | + python -m pip install --upgrade --upgrade-strategy eager tox + + - name: Build and install wheel + run: | + tox run -m build + python -m pip install dist/*.whl + + - name: "Publish distribution 📦 to PyPI" + if: startsWith(github.ref, 'refs/tags/') + uses: pypa/gh-action-pypi-publish@release/v1 + + - name: Get release name + if: startsWith(github.ref, 'refs/tags/') + shell: bash + run: | + git fetch --depth=1 origin +refs/tags/*:refs/tags/* # github is dumb + echo "release_name=$(git tag -l --format "%(refname:strip=2): %(contents:lines=1)" ${{ github.ref_name }})" >> $GITHUB_ENV + + - name: Release + uses: softprops/action-gh-release@v1 + if: startsWith(github.ref, 'refs/tags/') + with: + name: "${{ env.release_name }}" + draft: false + files: | + dist/*.whl diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..de01d9b --- /dev/null +++ b/.gitignore @@ -0,0 +1,112 @@ +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion + +*.iml + +## Directory-based project format: +.idea/ + +### Other editors +.*.swp +nbproject/ +.vscode + +*.exe +*.zip + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# ruff +.ruff_cache + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# for testing +temp/ +tmp/ + +# General +.DS_Store +.AppleDouble +.LSOverride + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..012ffb8 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,33 @@ +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: debug-statements + - id: double-quote-string-fixer + - id: name-tests-test +- repo: https://github.com/asottile/reorder-python-imports + rev: v3.13.0 + hooks: + - id: reorder-python-imports + args: [--py38-plus, --add-import, 'from __future__ import annotations'] +- repo: https://github.com/hhatto/autopep8 + rev: v2.3.0 + hooks: + - id: autopep8 +- repo: https://github.com/asottile/add-trailing-comma + rev: v3.1.0 + hooks: + - id: add-trailing-comma +- repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.4.9 + hooks: + - id: ruff + args: [ --fix ] +- repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.10.0 + hooks: + - id: mypy diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..85af169 --- /dev/null +++ b/README.md @@ -0,0 +1,6 @@ +# ComicInfo.xml plugin for Comic Tagger + +A plugin for [ComicTagger](https://github.com/comictagger/comictagger/releases) to allow the use of the extensions to the ComicInfo.xml metadata schema by the [Anansi Project] +This plugin is included in binary releases of ComicTagger. + +[Anansi Project]: https://github.com/anansi-project/comicinfo diff --git a/ct_archived_tags/comet.py b/ct_archived_tags/comet.py new file mode 100644 index 0000000..d35b4b8 --- /dev/null +++ b/ct_archived_tags/comet.py @@ -0,0 +1,327 @@ +"""A class to encapsulate CoMet data""" +# +# Copyright 2012-2014 ComicTagger Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +import logging +import os +import xml.etree.ElementTree as ET +from typing import Any +from typing import TYPE_CHECKING + +from comicapi import utils +from comicapi.comicarchive import ComicArchive +from comicapi.genericmetadata import GenericMetadata +from comicapi.genericmetadata import PageMetadata +from comicapi.genericmetadata import PageType +from comicapi.tags import Tag + +if TYPE_CHECKING: + from comicapi.archivers import Archiver + +logger = logging.getLogger(__name__) + + +class CoMet(Tag): + enabled = True + + id = 'comet' + + def __init__(self, version: str) -> None: + super().__init__(version) + + self.comet_filename = 'CoMet.xml' + self.file = 'CoMet.xml' + self.supported_attributes = { + 'series', + 'issue', + 'title', + 'volume', + 'genres', + 'description', + 'publisher', + 'language', + 'format', + 'maturity_rating', + 'month', + 'year', + 'page_count', + 'characters', + 'credits', + 'credits.person', + 'credits.primary', + 'credits.role', + 'price', + 'is_version_of', + 'rights', + 'identifier', + 'last_mark', + 'pages.type', # This is required for setting the cover image none of the other types will be saved + 'pages', + } + + def supports_credit_role(self, role: str) -> bool: + return role.casefold() in self._get_parseable_credits() + + def supports_tags(self, archive: Archiver) -> bool: + return archive.supports_files() + + def has_tags(self, archive: Archiver) -> bool: + if not self.supports_tags(archive): + return False + has_tags = False + # look at all xml files in root, and search for CoMet data, get first + for n in archive.get_filename_list(): + if os.path.dirname(n) == '' and os.path.splitext(n)[1].casefold() == '.xml': + # read in XML file, and validate it + data = b'' + try: + data = archive.read_file(n) + except Exception as e: + logger.warning('Error reading in Comet XML for validation! from %s: %s', archive.path, e) + if self._validate_bytes(data): + # since we found it, save it! + self.file = n + has_tags = True + break + return has_tags + + def remove_tags(self, archive: Archiver) -> bool: + return self.has_tags(archive) and archive.remove_file(self.file) + + def read_tags(self, archive: Archiver) -> GenericMetadata: + if self.has_tags(archive): + metadata = archive.read_file(self.file) or b'' + if self._validate_bytes(metadata): + return self._metadata_from_bytes(metadata, archive) + return GenericMetadata() + + def read_raw_tags(self, archive: Archiver) -> str: + if self.has_tags(archive): + return ET.tostring(ET.fromstring(archive.read_file(self.file)), encoding='unicode', xml_declaration=True) + return '' + + def write_tags(self, metadata: GenericMetadata, archive: Archiver) -> bool: + if self.supports_tags(archive): + success = True + xml = b'' + if self.has_tags(archive): + xml = archive.read_file(self.file) + if self.file != self.comet_filename: + success = self.remove_tags(archive) + + return success and archive.write_file(self.comet_filename, self._bytes_from_metadata(metadata, xml)) + else: + logger.warning(f'Archive ({archive.name()}) does not support {self.name()} metadata') + return False + + def name(self) -> str: + return 'Comic Metadata (CoMet)' + + @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, archive: Archiver) -> GenericMetadata: + tree = ET.ElementTree(ET.fromstring(string)) + return self._convert_xml_to_metadata(tree, archive) + + def _bytes_from_metadata(self, metadata: GenericMetadata, xml: bytes = b'') -> bytes: + tree = self._convert_metadata_to_xml(metadata, xml) + return ET.tostring(tree.getroot(), encoding='utf-8', xml_declaration=True) + + def _convert_metadata_to_xml(self, metadata: GenericMetadata, xml: bytes = b'') -> ET.ElementTree: + # shorthand for the metadata + md = metadata + + if xml: + root = ET.fromstring(xml) + else: + # build a tree structure + root = ET.Element('comet') + root.attrib['xmlns:comet'] = 'http://www.denvog.com/comet/' + root.attrib['xmlns:xsi'] = 'http://www.w3.org/2001/XMLSchema-instance' + root.attrib['xsi:schemaLocation'] = 'http://www.denvog.com http://www.denvog.com/comet/comet.xsd' + + # helper func + def assign(comet_entry: str, md_entry: Any) -> None: + if md_entry is not None: + ET.SubElement(root, comet_entry).text = str(md_entry) + + # title is manditory + assign('title', md.title or '') + assign('series', md.series) + assign('issue', md.issue) # must be int?? + assign('volume', md.volume) + assign('description', md.description) + assign('publisher', md.publisher) + assign('pages', md.page_count) + assign('format', md.format) + assign('language', md.language) + assign('rating', md.maturity_rating) + assign('price', md.price) + assign('isVersionOf', md.is_version_of) + assign('rights', md.rights) + assign('identifier', md.identifier) + assign('lastMark', md.last_mark) + assign('genre', ','.join(md.genres)) # TODO repeatable + + for c in md.characters: + assign('character', c.strip()) + + if md.manga is not None and md.manga == 'YesAndRightToLeft': + assign('readingDirection', 'rtl') + + if md.year is not None: + date_str = f'{md.year:04}' + if md.month is not None: + date_str += f'-{md.month:02}' + assign('date', date_str) + + cover_index = md.get_cover_page_index_list()[0] + assign('coverImage', md.pages[cover_index].filename) + + # loop thru credits, and build a list for each role that CoMet supports + for credit in metadata.credits: + if credit.role.casefold() in set(GenericMetadata.writer_synonyms): + ET.SubElement(root, 'writer').text = str(credit.person) + + if credit.role.casefold() in set(GenericMetadata.penciller_synonyms): + ET.SubElement(root, 'penciller').text = str(credit.person) + + if credit.role.casefold() in set(GenericMetadata.inker_synonyms): + ET.SubElement(root, 'inker').text = str(credit.person) + + if credit.role.casefold() in set(GenericMetadata.colorist_synonyms): + ET.SubElement(root, 'colorist').text = str(credit.person) + + if credit.role.casefold() in set(GenericMetadata.letterer_synonyms): + ET.SubElement(root, 'letterer').text = str(credit.person) + + if credit.role.casefold() in set(GenericMetadata.cover_synonyms): + ET.SubElement(root, 'coverDesigner').text = str(credit.person) + + if credit.role.casefold() in set(GenericMetadata.editor_synonyms): + ET.SubElement(root, 'editor').text = str(credit.person) + + ET.indent(root) + + # wrap it in an ElementTree instance, and save as XML + tree = ET.ElementTree(root) + return tree + + def _convert_xml_to_metadata(self, tree: ET.ElementTree, archive: Archiver) -> GenericMetadata: + root = tree.getroot() + + if root.tag != 'comet': + raise Exception('Not a CoMet file') + + metadata = GenericMetadata() + md = metadata + + # Helper function + def get(tag: str) -> Any: + node = root.find(tag) + if node is not None: + return node.text + return None + + md.series = utils.xlate(get('series')) + md.title = utils.xlate(get('title')) + md.issue = utils.xlate(get('issue')) + md.volume = utils.xlate_int(get('volume')) + md.description = utils.xlate(get('description')) + md.publisher = utils.xlate(get('publisher')) + md.language = utils.xlate(get('language')) + md.format = utils.xlate(get('format')) + md.page_count = utils.xlate_int(get('pages')) + md.maturity_rating = utils.xlate(get('rating')) + md.price = utils.xlate_float(get('price')) + md.is_version_of = utils.xlate(get('isVersionOf')) + md.rights = utils.xlate(get('rights')) + md.identifier = utils.xlate(get('identifier')) + md.last_mark = utils.xlate(get('lastMark')) + + _, md.month, md.year = utils.parse_date_str(utils.xlate(get('date'))) + + ca = ComicArchive(archive) + cover_filename = utils.xlate(get('coverImage')) + page_list = ca.get_page_name_list() + if cover_filename in page_list: + cover_index = page_list.index(cover_filename) + md.pages = [ + PageMetadata( + archive_index=cover_index, + display_index=0, + filename=cover_filename, + type=PageType.FrontCover, + bookmark='', + ), + ] + + reading_direction = utils.xlate(get('readingDirection')) + if reading_direction is not None and reading_direction == 'rtl': + md.manga = 'YesAndRightToLeft' + + # loop for genre tags + for n in root: + if n.tag == 'genre': + md.genres.add((n.text or '').strip()) + + # loop for character tags + for n in root: + if n.tag == 'character': + md.characters.add((n.text or '').strip()) + + # Now extract the credit info + for n in root: + if any( + [ + n.tag == 'writer', + n.tag == 'penciller', + n.tag == 'inker', + n.tag == 'colorist', + n.tag == 'letterer', + n.tag == 'editor', + ], + ): + metadata.add_credit((n.text or '').strip(), n.tag.title()) + + if n.tag == 'coverDesigner': + metadata.add_credit((n.text or '').strip(), 'Cover') + + metadata.is_empty = False + + return metadata + + # verify that the string actually contains CoMet data in XML format + def _validate_bytes(self, string: bytes) -> bool: + try: + tree = ET.ElementTree(ET.fromstring(string)) + root = tree.getroot() + if root.tag != 'comet': + return False + except ET.ParseError: + return False + + return True diff --git a/ct_archived_tags/comicbookinfo.py b/ct_archived_tags/comicbookinfo.py new file mode 100644 index 0000000..6f73509 --- /dev/null +++ b/ct_archived_tags/comicbookinfo.py @@ -0,0 +1,234 @@ +"""A class to encapsulate the ComicBookInfo data""" +# Copyright 2012-2014 ComicTagger Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +import json +import logging +from datetime import datetime +from typing import Any +from typing import Literal +from typing import TYPE_CHECKING +from typing import TypedDict + +from comicapi import utils +from comicapi.genericmetadata import Credit +from comicapi.genericmetadata import GenericMetadata +from comicapi.tags import Tag + +if TYPE_CHECKING: + from comicapi.archivers import Archiver + +logger = logging.getLogger(__name__) + +_CBILiteralType = Literal[ + 'series', + 'title', + 'issue', + 'publisher', + 'publicationMonth', + 'publicationYear', + 'numberOfIssues', + 'comments', + 'genre', + 'volume', + 'numberOfVolumes', + 'language', + 'country', + 'rating', + 'credits', + 'tags', +] + + +class credit(TypedDict): + person: str + role: str + primary: bool + + +class _ComicBookInfoJson(TypedDict, total=False): + series: str + title: str + publisher: str + publicationMonth: int + publicationYear: int + issue: int + numberOfIssues: int + volume: int + numberOfVolumes: int + rating: int + genre: str + language: str + country: str + credits: list[credit] + tags: list[str] + comments: str + + +_CBIContainer = TypedDict('_CBIContainer', {'appID': str, 'lastModified': str, 'ComicBookInfo/1.0': _ComicBookInfoJson}) + + +class ComicBookInfo(Tag): + enabled = True + + id = 'cbi' + + def __init__(self, version: str) -> None: + super().__init__(version) + + self.supported_attributes = { + 'series', + 'issue', + 'issue_count', + 'title', + 'volume', + 'volume_count', + 'genres', + 'description', + 'publisher', + 'month', + 'year', + 'language', + 'country', + 'critical_rating', + 'tags', + 'credits', + 'credits.person', + 'credits.primary', + 'credits.role', + } + + def supports_credit_role(self, role: str) -> bool: + return True + + def supports_tags(self, archive: Archiver) -> bool: + return archive.supports_comment() + + def has_tags(self, archive: Archiver) -> bool: + return self.supports_tags(archive) and self._validate_string(archive.get_comment()) + + def remove_tags(self, archive: Archiver) -> bool: + return archive.set_comment('') + + def read_tags(self, archive: Archiver) -> GenericMetadata: + if self.has_tags(archive): + comment = archive.get_comment() + if self._validate_string(comment): + return self._metadata_from_string(comment) + return GenericMetadata() + + def read_raw_tags(self, archive: Archiver) -> str: + if self.has_tags(archive): + return json.dumps(json.loads(archive.get_comment()), indent=2) + return '' + + def write_tags(self, metadata: GenericMetadata, archive: Archiver) -> bool: + if self.supports_tags(archive): + return archive.set_comment(self._string_from_metadata(metadata)) + else: + logger.warning(f'Archive ({archive.name()}) does not support {self.name()} metadata') + return False + + def name(self) -> str: + return 'ComicBookInfo' + + def _metadata_from_string(self, string: str) -> GenericMetadata: + cbi_container: _CBIContainer = json.loads(string) + + metadata = GenericMetadata() + + cbi = cbi_container['ComicBookInfo/1.0'] + + metadata.series = utils.xlate(cbi.get('series')) + metadata.title = utils.xlate(cbi.get('title')) + metadata.issue = utils.xlate(cbi.get('issue')) + metadata.publisher = utils.xlate(cbi.get('publisher')) + metadata.month = utils.xlate_int(cbi.get('publicationMonth')) + metadata.year = utils.xlate_int(cbi.get('publicationYear')) + metadata.issue_count = utils.xlate_int(cbi.get('numberOfIssues')) + metadata.description = utils.xlate(cbi.get('comments')) + metadata.genres = set(utils.split(cbi.get('genre'), ',')) + metadata.volume = utils.xlate_int(cbi.get('volume')) + metadata.volume_count = utils.xlate_int(cbi.get('numberOfVolumes')) + metadata.language = utils.xlate(cbi.get('language')) + metadata.country = utils.xlate(cbi.get('country')) + metadata.critical_rating = utils.xlate_int(cbi.get('rating')) + + metadata.credits = [ + Credit( + person=x['person'] if 'person' in x else '', + role=x['role'] if 'role' in x else '', + primary=x['primary'] if 'primary' in x else False, + ) + for x in cbi.get('credits', []) + ] + metadata.tags.update(cbi.get('tags', set())) + + # need the language string to be ISO + if metadata.language: + metadata.language = utils.get_language_iso(metadata.language) + + metadata.is_empty = False + + return metadata + + def _string_from_metadata(self, metadata: GenericMetadata) -> str: + cbi_container = self._create_json_dictionary(metadata) + return json.dumps(cbi_container) + + def _validate_string(self, string: bytes | str) -> bool: + """Verify that the string actually contains CBI data in JSON format""" + + try: + cbi_container = json.loads(string) + except json.JSONDecodeError: + return False + + return 'ComicBookInfo/1.0' in cbi_container + + def _create_json_dictionary(self, metadata: GenericMetadata) -> _CBIContainer: + """Create the dictionary that we will convert to JSON text""" + + cbi_container = _CBIContainer( + { + 'appID': 'ComicTagger/1.0.0', + 'lastModified': str(datetime.now()), + 'ComicBookInfo/1.0': {}, + }, + ) # TODO: ctversion.version, + + # helper func + def assign(cbi_entry: _CBILiteralType, md_entry: Any) -> None: + if md_entry is not None or isinstance(md_entry, str) and md_entry != '': + cbi_container['ComicBookInfo/1.0'][cbi_entry] = md_entry + + assign('series', utils.xlate(metadata.series)) + assign('title', utils.xlate(metadata.title)) + assign('issue', utils.xlate(metadata.issue)) + assign('publisher', utils.xlate(metadata.publisher)) + assign('publicationMonth', utils.xlate_int(metadata.month)) + assign('publicationYear', utils.xlate_int(metadata.year)) + assign('numberOfIssues', utils.xlate_int(metadata.issue_count)) + assign('comments', utils.xlate(metadata.description)) + assign('genre', utils.xlate(','.join(metadata.genres))) + assign('volume', utils.xlate_int(metadata.volume)) + assign('numberOfVolumes', utils.xlate_int(metadata.volume_count)) + assign('language', utils.xlate(utils.get_language_from_iso(metadata.language))) + assign('country', utils.xlate(metadata.country)) + assign('rating', utils.xlate_int(metadata.critical_rating)) + assign('credits', [credit(person=c.person, role=c.role, primary=c.primary) for c in metadata.credits]) + assign('tags', list(metadata.tags)) + + return cbi_container diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ddb7f28 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,106 @@ +[build-system] +requires = ["setuptools>=61.2", "wheel", "setuptools_scm[toml]>=3.4"] +build-backend = "setuptools.build_meta" + +[project] +name = "ct-archived-tags" +description = "Archived tag formats for ComicTagger" +authors = [{name = "Timmy Welch", email = "timmy@narnian.us"}] +license = {text = "Apache-2.0"} +classifiers = [ + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] +urls = {Homepage = "https://github.com/comictagger/archived-tags"} +requires-python = ">=3.8" +dependencies = ["typing-extensions>=4.3.0;python_version < '3.11'"] +dynamic = ["version"] + +[project.readme] +file = "README.md" +content-type = "text/markdown" + +[tool.setuptools_scm] +local_scheme = "no-local-version" + +[tool.setuptools] +include-package-data = true +license-files = ["LICENSE"] + +[project.entry-points."comicapi.tags"] +cbi = "ct_archived_tags.comicbookinfo:ComicBookInfo" +comet = "ct_archived_tags.comet:CoMet" + +[tool.tox] +legacy_tox_ini = """ +[tox:tox] +envlist = py3.9 + +[testenv:wheel] +description = Generate wheel and tar.gz +labels = + release + build +skip_install = true +deps = + build +commands_pre = + -python -c 'import shutil,pathlib; \ + shutil.rmtree("./build/", ignore_errors=True); \ + shutil.rmtree("./dist/", ignore_errors=True)' +commands = + python -m build + +[testenv:pypi-upload] +description = Upload wheel to PyPi +platform = Linux +labels = + release +skip_install = true +depends = wheel +deps = + twine +passenv = + TWINE_* +setenv = + TWINE_NON_INTERACTIVE=true +commands = + python -m twine upload dist/*.whl dist/*.tar.gz +""" + + +[tool.pep8] +ignore = "E265,E501" +max_line_length = "120" + +[tool.autopep8] +max_line_length = 120 +ignore = "E265,E501" + +[tool.mypy] +check_untyped_defs = true +disallow_any_generics = true +warn_return_any = false +disallow_incomplete_defs = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true + +[[tool.mypy.overrides]] +module = ["testing.*"] +warn_return_any = false +disallow_untyped_defs = false + +[[tool.mypy.overrides]] +module = ["tests.*"] +warn_return_any = false +disallow_untyped_defs = false + +[tool.ruff] +line-length = 120 +lint.extend-safe-fixes = ["TCH"] +lint.extend-select = ["COM812", "TCH"]