Compare commits

..

47 Commits

Author SHA1 Message Date
4608b97e23 v1.2.0
Separate comicapi into it's own package
Add support for tar files
Insert standard gitignore
Use suggested _version from setuptools-scm
Cleanup setup.py

Fix formatting in the rename template help
2021-08-05 22:42:13 -07:00
d18e94cd4e Update gitignore 2021-08-02 11:33:59 -07:00
feb206c165 Remove pyinstaller from requirements
pyinstaller is only needed for a specific deploy scenario
2021-07-11 05:54:05 +00:00
31e567fe1f Update current_version.txt 2021-07-02 03:42:52 +00:00
cb2031f2e7 Merge remote-tracking branch 'origin/un-cffi' 2021-07-01 19:20:38 -07:00
4ae1632aba Update usage of filetype
Fix an index out of range error when there are no duplicates to display
Exit immediately if there are no duplicates found
2021-03-07 19:59:39 -08:00
737ddd2486 Fix remove_ads.py 2021-03-07 19:41:54 -08:00
f31abe2df9 Add a shortcut for marking a page as an ad 2021-03-07 19:40:25 -08:00
fbc1137e0f Revert "Changed: use unrar-cffi for cbr handling (#151)"
This reverts commit 83d2557af3.
2020-08-14 22:15:52 -07:00
be698a17d6 Remove setuptools_scm 2020-07-26 19:28:45 -07:00
29be759c9c Implement isort and black 2020-07-06 16:11:15 -07:00
39052b58c6 Remove PyPDF2 2020-06-29 19:07:14 -07:00
83d2557af3 Changed: use unrar-cffi for cbr handling (#151) 2020-06-02 21:26:11 -07:00
a3d0b3372b fix broken drag & drop on macOS (#142) 2020-06-02 21:10:38 -07:00
9b097c80eb Increase comicvine search results per request to max (#164) 2020-06-02 21:09:32 -07:00
bbf9e6e38f Fix an issue with reducing the returned search results 2020-06-02 20:21:05 -07:00
6ab03cc3d3 Update natsort usage 2020-06-02 20:09:32 -07:00
8b227afbb2 Add a literal search option
In order to bypass processing of search results and search terms
2020-06-02 20:09:02 -07:00
3a3d67cbd0 update requirements.txt 2020-06-02 20:05:31 -07:00
676ecaf547 Update comic publisher imprints 2020-06-02 20:03:48 -07:00
931df0109d duplicate script and fixes 2020-05-30 15:30:27 -07:00
475540560a Merge branch 'seriesSearch' 2020-02-13 00:30:21 -08:00
7aa4e1c4ed Improve searchForSeries
Refactor removearticles to only remove articles
Add normalization on the search string and the series name results

Searching now only compares ASCII a-z and 0-9 and all other characters
are replaced with single space, this is done to both the search string
and the result. This fixes an with names that are separated by a
hyphen (-) in the filename but in the Comic Vine name are separated by a
slash (/) and other similar issues.
2020-02-13 00:27:08 -08:00
f5e88d07bb Fix errors
Libraries updated and these are no longer needed
2020-02-13 00:04:44 -08:00
f634783d26 Merge branch 'requests' 2020-02-12 23:44:14 -08:00
ebfbbacc16 Add requests to requirements.txt 2020-02-12 23:30:04 -08:00
76b524c767 Merge branch 'Renaming' 2019-09-23 17:48:19 -07:00
fbb234a527 Add template error checking and other small issues
Error checking has been added in CLI mode, the settings window and when
running a rename operation from the GUI.
The Template Help window is now non-modal
Discrepancies between the tool tip and help window have been resolved
2019-09-23 17:47:59 -07:00
ce021d82cf Change filename parsing to default to the issue number
e.g. 123.cbr parses with series: 123, issue number: 123
2019-09-11 14:45:55 -07:00
cd4097f0c0 Fix seriesYear handling 2019-09-11 14:45:50 -07:00
cb9db19073 Fix requirements
pip doesn't recognize my PyQt5 install (Void Linux) and pip complains
on the git+https for pyinstaller
2019-09-11 14:45:45 -07:00
abd8019bf9 Add seriesYear attribute
Attribute is only serialized in ComicRack style metadata
2019-09-11 14:45:40 -07:00
c596062f55 Merge branch 'requests' into temp 2019-09-11 14:43:52 -07:00
c26f33e3d5 Merge branch 'duplicateFinder' into temp 2019-09-11 14:43:28 -07:00
37e6e81894 Merge branch 'Renaming' into temp 2019-09-11 14:43:21 -07:00
f73324f003 Merge branch 'AutoImprint' into temp 2019-09-11 14:43:05 -07:00
f0f8a061b5 Add publisher and imprint handling
Imprint handling has been added to utils and uses a subclassed dict to
return tuples for imprint matching may not be the best idea but it works
for now.

Add settings option auto_imprint
Add cli flag -a, --auto-import
2019-09-11 14:42:29 -07:00
944c0b9b2e Move to python requests module
requests is much simpler and fixes all ssl errors.
Comic Vine now requires a unique useragent string
2019-09-10 14:52:59 -07:00
8752e26476 Merge branch 'PageListEditorExtendedSelection' 2019-09-05 15:10:43 -07:00
146e7b21c6 Merge branch 'IssueString' 2019-09-05 15:10:39 -07:00
60e962b903 Merge branch 'FixLanguageSort' 2019-09-05 15:10:33 -07:00
0747a6b0ef Improve file renaming
Moves to Python format strings for renaming, handles directory
structures, moving of files to a destination directory, sanitizes
file paths with pathvalidate and takes a different approach to
smart filename cleanup using the Python string.Formatter class

Moving to Python format strings means we can point to python
documentation for syntax and all we have to do is document the
properties and types that are attached to the GenericMetadata class.

Switching to pathvalidate allows comictagger to more simply handle both
directories and symbols in filenames.

The only changes to the string.Formatter class is:
1. format_field returns
an empty string if the value is none or an empty string regardless of
the format specifier.
2. _vformat drops the previous literal text if the field value
is an empty string and lstrips the following literal text of closing
special characters.
2019-09-05 14:40:17 -07:00
51132c061b Cleanup metadata handling
Mainly corrects for consistency in most situations
CoMet is not touched as there is no support in the gui and has an odd requirements on attributes
2019-09-05 14:40:14 -07:00
3b01a9b58b convert duplicate finder script to Python 3, provide JSON output and call a script to actually handle the duplicate comics 2019-09-05 13:58:46 -07:00
7f79c2b024 Allow extended selection in the page list editor 2019-08-16 13:45:00 -07:00
0069d7fdc5 issue string parsing now strips off (# of #) (e.g. 1 of 45) 2019-08-16 13:43:43 -07:00
3606cbd0f2 Sort language correctly 2019-08-16 13:22:15 -07:00
130 changed files with 7438 additions and 13664 deletions

View File

@ -1,6 +0,0 @@
[flake8]
max-line-length = 120
extend-ignore = E203, E501, A003
extend-exclude = venv, scripts, build, dist, comictaggerlib/ctversion.py
per-file-ignores =
comictaggerlib/cli.py: T20

View File

@ -1,103 +0,0 @@
name: CI
env:
PIP: pip
PYTHON: python
on:
pull_request:
push:
branches:
- '**'
tags-ignore:
- '**'
jobs:
lint:
permissions:
checks: write
contents: read
pull-requests: write
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 }}
- uses: syphar/restore-virtualenv@v1.2
id: cache-virtualenv
- uses: syphar/restore-pip-download-cache@v1
if: steps.cache-virtualenv.outputs.cache-hit != 'true'
- name: Install build dependencies
run: |
python -m pip install --upgrade --upgrade-strategy eager -r requirements_dev.txt
- uses: reviewdog/action-setup@v1
with:
reviewdog_version: nightly
- run: flake8 | reviewdog -f=flake8 -reporter=github-pr-review -tee -level=error -fail-on-error
env:
REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-and-test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
python-version: [3.9]
os: [ubuntu-latest, macos-10.15, windows-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 }}
- uses: syphar/restore-virtualenv@v1.2
id: cache-virtualenv
- uses: syphar/restore-pip-download-cache@v1
if: steps.cache-virtualenv.outputs.cache-hit != 'true'
- name: Install build dependencies
run: |
python -m pip install --upgrade --upgrade-strategy eager -r requirements_dev.txt
- name: Install Windows build dependencies
run: |
choco install -y zip
if: runner.os == 'Windows'
- name: Build and install PyPi packages
run: |
make clean pydist
python -m pip install "dist/$(python setup.py --fullname)-py3-none-any.whl[all]"
- name: build
run: |
make dist
- name: Archive production artifacts
uses: actions/upload-artifact@v2
if: runner.os != 'Linux' # linux binary currently has a segfault when running on latest fedora
with:
name: "${{ format('ComicTagger-{0}', runner.os) }}"
path: |
dist/*.zip
- name: PyTest
run: |
python -m pytest

View File

@ -1,72 +0,0 @@
name: Package
env:
PIP: pip
PYTHON: python
on:
push:
tags:
- "[0-9]+.[0-9]+.[0-9]+*"
jobs:
package:
permissions:
contents: write
runs-on: ${{ matrix.os }}
strategy:
matrix:
python-version: [3.9]
os: [ubuntu-latest, macos-10.15, windows-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 }}
- uses: syphar/restore-virtualenv@v1.2
id: cache-virtualenv
- uses: syphar/restore-pip-download-cache@v1
if: steps.cache-virtualenv.outputs.cache-hit != 'true'
- name: Install build dependencies
run: |
python -m pip install --upgrade --upgrade-strategy eager -r requirements_dev.txt
- name: Install Windows build dependencies
run: |
choco install -y zip
if: runner.os == 'Windows'
- name: Build, Install and Test PyPi packages
run: |
make clean pydist
python -m pip install "dist/$(python setup.py --fullname)-py3-none-any.whl[all]"
echo "CT_FULL_NAME=$(python setup.py --fullname)" >> $GITHUB_ENV
python -m flake8
python -m pytest
- name: "Publish distribution 📦 to PyPI"
if: startsWith(github.ref, 'refs/tags/') && runner.os == 'Linux'
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.PYPI_API_TOKEN }}
packages_dir: dist
- name: Build PyInstaller package
run: |
make dist
- name: Release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
prerelease: "${{ contains(github.ref, '-') }}" # alpha-releases should be 1.3.0-alpha.x full releases should be 1.3.0
draft: false
files: |
dist/!(*Linux).zip
dist/*.whl

3
.gitignore vendored
View File

@ -1,6 +1,3 @@
# generated by setuptools_scm
ctversion.py
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion
*.iml

View File

@ -1,46 +0,0 @@
exclude: ^scripts
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.3.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: debug-statements
- id: name-tests-test
- id: requirements-txt-fixer
- repo: https://github.com/asottile/setup-cfg-fmt
rev: v2.0.0
hooks:
- id: setup-cfg-fmt
- repo: https://github.com/PyCQA/isort
rev: 5.10.1
hooks:
- id: isort
args: [--af,--add-import, 'from __future__ import annotations']
- repo: https://github.com/asottile/pyupgrade
rev: v2.37.3
hooks:
- id: pyupgrade
args: [--py39-plus]
- repo: https://github.com/psf/black
rev: 22.6.0
hooks:
- id: black
- repo: https://github.com/PyCQA/autoflake
rev: v1.4
hooks:
- id: autoflake
args: [-i]
- repo: https://github.com/PyCQA/flake8
rev: 5.0.4
hooks:
- id: flake8
additional_dependencies: [flake8-encodings, flake8-warnings, flake8-builtins, flake8-length, flake8-print]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v0.971
hooks:
- id: mypy
additional_dependencies: [types-setuptools, types-requests]
ci:
skip: [mypy]

View File

@ -1,59 +0,0 @@
language: python
# Only build tags
if: type = pull_request OR tag IS present
branches:
only:
- develop
- /^\d+\.\d+\.\d+.*$/
env:
global:
- PYTHON=python3
- PIP=pip3
- SETUPTOOLS_SCM_PRETEND_VERSION=$TRAVIS_TAG
- MAKE=make
matrix:
include:
- os: linux
python: 3.8
- name: "Python: 3.7"
os: osx
language: shell
python: 3.7
env: PYTHON=python3 PIP="python3 -m pip"
cache:
- directories:
- $HOME/Library/Caches/pip
- os: windows
language: bash
env: PATH=/C/Python37:/C/Python37/Scripts:$PATH MAKE=mingw32-make PIP=pip PYTHON=python
before_install:
- if [ "$TRAVIS_OS_NAME" = "windows" ]; then choco install -y python --version 3.7.9; choco install -y mingw zip; fi
install:
- $PIP install -r requirements_dev.txt
- $PIP install -r requirements-GUI.txt
- $PIP install -r requirements-CBR.txt
script:
- if [ "$TRAVIS_OS_NAME" != "linux" ]; then $MAKE dist ; fi
deploy:
- name: "$TRAVIS_TAG"
body: Released ComicTagger $TRAVIS_TAG
provider: releases
skip_cleanup: true
api_key:
secure: RgohcOJOfLhXXT12bMWaLwOqhe+ClSCYXjYuUJuWK4/E1fdd1xu1ebdQU+MI/R8cZ0Efz3sr2n3NkO/Aa8gN68xEfuF7RVRMm64P9oPrfZgGdsD6H43rU/6kN8bgaDRmCYpLTfXaJ+/gq0x1QDkhWJuceF2BYEGGvL0BvS/TUsLyjVxs8ujTplLyguXHNEv4/7Yz7SBNZZmUHjBuq/y+l8ds3ra9rSgAVAN1tMXoFKJPv+SNNkpTo5WUNMPzBnN041F1rzqHwYDLog2V7Krp9JkXzheRFdAr51/tJBYzEd8AtYVdYvaIvoO6A4PiTZ7MpsmcZZPAWqLQU00UTm/PhT/LVR+7+f8lOBG07RgNNHB+edjDRz3TAuqyuZl9wURWTZKTPuO49TkZMz7Wm0DRNZHvBm1IXLeSG7Tll2YL1+WpZNZg+Dhro2J1QD3vxDXafhMdTCB4z0q5aKpG93IT0p6oXOO0oEGOPZYbA2c5R3SXWSyqd1E1gdhbVjIZr59h++TEf1zz07tvWHqPuAF/Ly/j+dIcY2wj0EzRWaSASWgUpTnMljAkHtWhqDw4GXGDRkRUWRJl1d0/JyVqCeIdRzDQNl8/q7BcO3F1zqr1PgnYdz0lfwWxL1/ekw2vHOJE/GOdkyvX0aJrnaOV338mjJbfGHYv4ESc9ow1kdtIbiU=
file_glob: true
file: dist/*.zip
draft: true
on:
tags: true
condition: $TRAVIS_OS_NAME != "linux"
- provider: pypi
user: __token__
password:
secure: h+y5WkE8igf864dnsbGPFvOBkyPkuBYtnDRt+EgxHd71EZnV2YP7ns2Cx12su/SVVDdZCBlmHVtkhl6Jmqy+0rTkSYx+3mlBOqyl8Cj5+BlP/dP7Bdmhs2uLZk2YYL1avbC0A6eoNJFtCkjurnB/jCGE433rvMECWJ5x2HsQTKchCmDAEdAZbRBJrzLFsrIC+6NXW1IJZjd+OojbhLSyVar2Jr32foh6huTcBu/x278V1+zIC/Rwy3W67+3c4aZxYrI47FoYFza0jjFfr3EoSkKYUSByMTIvhWaqB2gIsF0T160jgDd8Lcgej+86ACEuG0v01VE7xoougqlOaJ94eAmapeM7oQXzekSwSAxcK3JQSfgWk/AvPhp07T4pQ8vCZmky6yqvVp1EzfKarTeub1rOnv+qo1znKLrBtOoq6t8pOAeczDdIDs51XT/hxaijpMRCM8vHxN4Kqnc4DY+3KcF7UFyH1ifQJHQe71tLBsM/GnAcJM5/3ykFVGvRJ716p4aa6IoGsdNk6bqlysNh7nURDl+bfm+CDXRkO2jkFwUFNqPHW7JwY6ZFx+b5SM3TzC3obJhfMS7OC37fo2geISOTR0xVie6NvpN6TjNAxFTfDxWJI7yH3Al2w43B3uYDd97WeiN+B+HVWtdaER87IVSRbRqFrRub+V+xrozT0y0=
skip_existing: true
skip_cleanup: true
on:
tags: true
condition: $TRAVIS_OS_NAME = "linux"

View File

@ -1,137 +0,0 @@
# How to contribute
If your not sure what you can do or you need to ask a question or just want to talk about ComicTagger head over to the [discussions tab](https://github.com/comictagger/comictagger/discussions/categories/general) and start a discussion
## Tests
We have tests written using pytest! Some of them even pass! If you are contributing code any tests you can write are appreciated.
A great place to start is extending the tests that are already made.
For example the file tests/filenames.py has lists of filenames to be parsed in the format:
```py
pytest.param(
"Star Wars - War of the Bounty Hunters - IG-88 (2021) (Digital) (Kileko-Empire).cbz",
"number ends series, no-issue",
{
"issue": "",
"series": "Star Wars - War of the Bounty Hunters - IG-88",
"volume": "",
"year": "2021",
"remainder": "(Digital) (Kileko-Empire)",
"issue_count": "",
},
marks=pytest.mark.xfail,
)
```
A test consists of 3-4 parts
1. The filename to be parsed
2. The reason it might fail
3. What the result of parsing the filename should be
4. `marks=pytest.mark.xfail` This marks the test as expected to fail
If you are not comfortable creating a pull request you can [open an issue](https://github.com/comictagger/comictagger/issues/new/choose) or [start a discussion](https://github.com/comictagger/comictagger/discussions/new)
## Submitting changes
Please open a [GitHub Pull Request](https://github.com/comictagger/comictagger/pull/new/develop) with a clear list of what you've done (read more about [pull requests](http://help.github.com/pull-requests/)). When you send a pull request, we will love you forever if you include tests. We can always use more test coverage. Please run the code tools below and make sure all of your commits are atomic (one feature per commit).
## Contributing Code
Currently only python 3.9 is supported however 3.10 will probably work if you try it
Those on linux should install `Pillow` from the system package manager if possible and if the GUI and/or the CBR/RAR comicbooks are going to be used `pyqt5` and `unrar-cffi` should be installed from the system package manager
Those on macOS will need to ensure that you are using python3 in x86 mode either by installing an x86 only version of python or using the universal installer and using `python3-intel64` instead of `python3`
1. Clone the repository
```
git clone https://github.com/comictagger/comictagger.git
```
2. It is preferred to use a virtual env for running from source, adding the `--system-site-packages` allows packages already installed via the system package manager to be used:
```
python3 -m venv --system-site-packages venv
```
3. Activate the virtual env:
```
. venv/bin/activate
```
or if on windows PowerShell
```
. venv/bin/activate.ps1
```
4. install dependencies:
```bash
pip install -r requirements_dev.txt -r requirements.txt
# if installing optional dependencies
pip install -r requirements-GUI.txt -r requirements-CBR.txt
```
5. install ComicTagger
```
pip install .
```
6. (optionally) run pytest to ensure that their are no failures (xfailed means expected failure)
```
$ pytest
============================= test session starts ==============================
platform darwin -- Python 3.9.12, pytest-7.1.1, pluggy-1.0.0
rootdir: /Users/timmy/build/source/comictagger
collected 61 items
tests/test_FilenameParser.py ..x......x.xxx.xx....xxxxxx.xx.x..xxxxxxx [ 67%]
tests/test_comicarchive.py x... [ 73%]
tests/test_rename.py ..xxx.xx..XXX.XX [100%]
================== 27 passed, 29 xfailed, 5 xpassed in 2.68s ===================
```
7. Make your changes
8. run code tools and correct any issues
```bash
black .
isort .
flake8 .
pytest
```
black: formats all of the code consistently so there are no surprises<br>
isort: sorts imports so that you can always find where an import is located<br>
flake8: checks for code quality and style (warns for unused imports and similar issues)<br>
pytest: runs tests for ComicTagger functionality
if on mac or linux most of this can be accomplished by running
```
make install
# or make PYTHON=python3-intel64 install
. venv/bin/activate
make CI
```
There is also `make check` which will run all of the code tools in a read-only capacity
```
$ make check
venv/bin/black --check .
All done! ✨ 🍰 ✨
52 files would be left unchanged.
venv/bin/isort --check .
Skipped 6 files
venv/bin/flake8 .
venv/bin/pytest
============================= test session starts ==============================
platform darwin -- Python 3.9.12, pytest-7.1.1, pluggy-1.0.0
rootdir: /Users/timmy/build/source/comictagger
collected 61 items
tests/test_FilenameParser.py ..x......x.xxx.xx....xxxxxx.xx.x..xxxxxxx [ 67%]
tests/test_comicarchive.py x... [ 73%]
tests/test_rename.py ..xxx.xx..XXX.XX [100%]
================== 27 passed, 29 xfailed, 5 xpassed in 2.68s ===================
```

View File

@ -1,7 +0,0 @@
include README.md
include release_notes.txt
include requirements.txt
recursive-include scripts *.py *.txt
recursive-include desktop-integration *
include windows/app.ico
include mac/app.icns

View File

@ -1,64 +1,46 @@
PIP ?= pip3
PYTHON ?= python3
VERSION_STR := $(shell $(PYTHON) setup.py --version)
SITE_PACKAGES := $(shell $(PYTHON) -c 'import sysconfig; print(sysconfig.get_paths()["purelib"])')
PACKAGE_PATH = $(SITE_PACKAGES)/comictagger.egg-link
VENV := $(shell echo $${VIRTUAL_ENV-venv})
PY3 := $(shell command -v $(PYTHON) 2> /dev/null)
PYTHON_VENV := $(VENV)/bin/python
INSTALL_STAMP := $(VENV)/.install.stamp
VERSION_STR := $(shell python -c 'import comictaggerlib._version; print( comictaggerlib._version.version)')
ifeq ($(OS),Windows_NT)
PYTHON_VENV := $(VENV)/Scripts/python.exe
OS_VERSION=win-$(PROCESSOR_ARCHITECTURE)
APP_NAME=comictagger.exe
FINAL_NAME=ComicTagger-$(VERSION_STR)-$(OS_VERSION).exe
FINAL_NAME=ComicTagger-$(VERSION_STR).exe
ICON_PATH="windows/app.ico"
else ifeq ($(shell uname -s),Darwin)
OS_VERSION=osx-$(shell defaults read loginwindow SystemVersionStampAsString)-$(shell uname -m)
APP_NAME=ComicTagger.app
FINAL_NAME=ComicTagger-$(VERSION_STR)-$(OS_VERSION).app
FINAL_NAME=ComicTagger-$(VERSION_STR).app
ICON_PATH="mac/app.icns"
else
APP_NAME=comictagger
FINAL_NAME=ComicTagger-$(VERSION_STR)-$(shell uname -s)
FINAL_NAME=ComicTagger-$(VERSION_STR)
ICON_PATH="windows/app.ico"
endif
.PHONY: all clean pydist dist CI check
.PHONY: all clean pydist upload dist
all: clean dist
$(PYTHON_VENV):
@if [ -z $(PY3) ]; then echo "Python 3 could not be found."; exit 2; fi
$(PY3) -m venv $(VENV)
clean:
find . -maxdepth 4 -type d -name "__pycache__"
rm -rf $(PACKAGE_PATH) $(INSTALL_STAMP) build dist MANIFEST comictaggerlib/ctversion.py
$(MAKE) -C mac clean
CI: install
$(PYTHON_VENV) -m black .
$(PYTHON_VENV) -m isort .
$(PYTHON_VENV) -m flake8 .
$(PYTHON_VENV) -m pytest
check: install
$(PYTHON_VENV) -m black --check .
$(PYTHON_VENV) -m isort --check .
$(PYTHON_VENV) -m flake8 .
$(PYTHON_VENV) -m pytest
rm -rf *~ *.pyc *.pyo
rm -rf scripts/*.pyc
cd comictaggerlib; rm -f *~ *.pyc *.pyo
rm -rf dist MANIFEST
rm -rf *.deb
rm -rf logdict*.log
$(MAKE) -C mac clean
rm -rf build
rm -rf comictaggerlib/ui/__pycache__
pydist:
$(PYTHON_VENV) -m build
install: $(INSTALL_STAMP)
$(INSTALL_STAMP): $(PYTHON_VENV) requirements.txt requirements_dev.txt
$(PYTHON_VENV) -m pip install -r requirements_dev.txt
$(PYTHON_VENV) -m pip install -e .
touch $(INSTALL_STAMP)
make clean
mkdir -p piprelease
rm -f comictagger-$(VERSION_STR).zip
python setup.py sdist --formats=zip #,gztar
mv dist/comictagger-$(VERSION_STR).zip piprelease
rm -rf comictagger.egg-info dist
upload:
python setup.py register
python setup.py sdist --formats=zip upload
dist:
pyinstaller -y comictagger.spec
cd dist && zip -m -r $(FINAL_NAME).zip $(APP_NAME)
pyinstaller.exe --name="comictagger" --windowed --add-data 'comictaggerlib/ui/*.ui;ui' --add-data 'comictaggerlib/graphics;graphics' -i windows/app.ico --version-file file_version_info.py comictagger.py
mv dist/$(APP_NAME) dist/$(FINAL_NAME)

View File

@ -1,63 +1,16 @@
[![CI](https://github.com/comictagger/comictagger/actions/workflows/build.yaml/badge.svg?branch=develop&event=push)](https://github.com/comictagger/comictagger/actions/workflows/build.yaml)
[![GitHub release (latest by date)](https://img.shields.io/github/downloads/comictagger/comictagger/latest/total)](https://github.com/comictagger/comictagger/releases/latest)
[![PyPI](https://img.shields.io/pypi/v/comictagger)](https://pypi.org/project/comictagger/)
[![PyPI - Downloads](https://img.shields.io/pypi/dm/comictagger)](https://pypistats.org/packages/comictagger)
[![Chocolatey package](https://img.shields.io/chocolatey/dt/comictagger?color=blue&label=chocolatey)](https://community.chocolatey.org/packages/comictagger)
[![PyPI - License](https://img.shields.io/pypi/l/comictagger)](https://opensource.org/licenses/Apache-2.0)
[![GitHub Discussions](https://img.shields.io/github/discussions/comictagger/comictagger)](https://github.com/comictagger/comictagger/discussions)
[![Gitter chat](https://badges.gitter.im/gitterHQ/gitter.png)](https://gitter.im/comictagger/community)
[![Google Group](https://img.shields.io/badge/discuss-on%20groups-%23207de5)](https://groups.google.com/forum/#!forum/comictagger)
[![Twitter](https://img.shields.io/badge/%40comictagger-twitter-lightgrey)](https://twitter.com/comictagger)
[![Facebook](https://img.shields.io/badge/comictagger-facebook-lightgrey)](https://www.facebook.com/ComicTagger-139615369550787/)
# ComicTagger
ComicTagger is a **multi-platform** app for **writing metadata to digital comics**, written in Python and PyQt.
![ComicTagger logo](https://raw.githubusercontent.com/comictagger/comictagger/develop/comictaggerlib/graphics/app.png)
## Features
* Runs on macOS, Microsoft Windows, and Linux systems
* Get comic information from [Comic Vine](https://comicvine.gamespot.com/)
* **Automatic issue matching** using advanced image processing techniques
* **Batch processing** in the GUI for tagging hundreds or more comics at a time
* Support for **ComicRack** and **ComicBookLover** tagging formats
* Native full support for **CBZ** digital comics
* Native read only support for **CBR** digital comics: full support enabled installing additional [rar tools](https://www.rarlab.com/download.htm)
* Command line interface (CLI) enabling **custom scripting** and **batch operations on large collections**
For details, screen-shots, and more, visit [the Wiki](https://github.com/comictagger/comictagger/wiki)
## Installation
### Binaries
Windows and macOS binaries are provided in the [Releases Page](https://github.com/comictagger/comictagger/releases).
Just unzip the archive in any folder and run, no additional installation steps are required.
### PIP installation
A pip package is provided, you can install it with:
```
$ pip3 install comictagger[GUI]
```
There are two optional dependencies GUI and CBR. You can install the optional dependencies by specifying one or more of `GUI`,`CBR` or `all` in braces e.g. `comictagger[CBR,GUI]`
### Chocolatey installation (Windows only)
A [Chocolatey package](https://community.chocolatey.org/packages/comictagger), maintained by @Xav83, is provided, you can install it with:
```powershell
choco install comictagger
```
### From source
1. Ensure you have python 3.9 installed
2. Clone this repository `git clone https://github.com/comictagger/comictagger.git`
3. `pip3 install -r requirements_dev.txt`
7. `pip3 install .` or `pip3 install .[GUI]`
A fork from https://github.com/comictagger/comictagger
Changes:
- switched to rarfile, makes dependencies simpler and I had issues using unrar-cffi with python<6.7
- Move to Python requests module, requests is much simpler and fixes all ssl errors.
- Moved to using Python format strings and use pathvalidate to handle filenames, supports directory structures
- Issue string parsing now strips off (# of #) (e.g. 1 of 45)
- Add publisher and imprint handling, currently hardcoded
Notes:
- I did some testing with the pyinstaller build, and it worked on both platforms. I did encounter two problems:
- Mac build showed the wrong widget set. I found a solution here that seemed to work: https://stackoverflow.com/questions/48626999/packaging-with-pyinstaller-pyqt5-setstyle-ignored
- Windows build had problems grabbing images from ComicVine using SSL. It think that some libraries are missing from the monolithic exe, but I couldn't figure out how to fix the problem.
- In setup.py you can also find the remains of an attempt to do some desktop integration from a pip install. It does work, but can cause problems with wheel installs, and I don't know if it's worth the bother. I kept the commented-out code in place, just in case.
With Python 3, it's much easier to get the app working from scratch on a new distro, as all of the dependencies are available as wheels, including PyQt5, so just a simple "pip install comictagger.zip" is all that's needed.

6
appveyor.yml Normal file
View File

@ -0,0 +1,6 @@
version: 1.0.{build}
build_script:
- cmd: powershell -exec bypass -File windows\fullbuild.ps1
artifacts:
- path: dist\*.exe
name: ComicTagger

View File

@ -1,3 +0,0 @@
from __future__ import annotations
__author__ = "dromanin"

View File

@ -1,215 +0,0 @@
"""A class to encapsulate CoMet data"""
#
# Copyright 2012-2014 Anthony Beville
#
# 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 xml.etree.ElementTree as ET
from typing import Any
from comicapi import utils
from comicapi.genericmetadata import GenericMetadata
logger = logging.getLogger(__name__)
class CoMet:
writer_synonyms = ["writer", "plotter", "scripter"]
penciller_synonyms = ["artist", "penciller", "penciler", "breakdowns"]
inker_synonyms = ["inker", "artist", "finishes"]
colorist_synonyms = ["colorist", "colourist", "colorer", "colourer"]
letterer_synonyms = ["letterer"]
cover_synonyms = ["cover", "covers", "coverartist", "cover artist"]
editor_synonyms = ["editor"]
def metadata_from_string(self, string: str) -> GenericMetadata:
tree = ET.ElementTree(ET.fromstring(string))
return self.convert_xml_to_metadata(tree)
def string_from_metadata(self, metadata: GenericMetadata) -> str:
tree = self.convert_metadata_to_xml(metadata)
return str(ET.tostring(tree.getroot(), encoding="utf-8", xml_declaration=True).decode("utf-8"))
def convert_metadata_to_xml(self, metadata: GenericMetadata) -> ET.ElementTree:
# shorthand for the metadata
md = metadata
# 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
if md.title is None:
md.title = ""
assign("title", md.title)
assign("series", md.series)
assign("issue", md.issue) # must be int??
assign("volume", md.volume)
assign("description", md.comments)
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", md.genre) # TODO repeatable
if md.characters is not None:
char_list = [c.strip() for c in md.characters.split(",")]
for c in char_list:
assign("character", c)
if md.manga is not None and md.manga == "YesAndRightToLeft":
assign("readingDirection", "rtl")
if md.year is not None:
date_str = str(md.year).zfill(4)
if md.month is not None:
date_str += "-" + str(md.month).zfill(2)
assign("date", date_str)
assign("coverImage", md.cover_image)
# loop thru credits, and build a list for each role that CoMet supports
for credit in metadata.credits:
if credit["role"].casefold() in set(self.writer_synonyms):
ET.SubElement(root, "writer").text = str(credit["person"])
if credit["role"].casefold() in set(self.penciller_synonyms):
ET.SubElement(root, "penciller").text = str(credit["person"])
if credit["role"].casefold() in set(self.inker_synonyms):
ET.SubElement(root, "inker").text = str(credit["person"])
if credit["role"].casefold() in set(self.colorist_synonyms):
ET.SubElement(root, "colorist").text = str(credit["person"])
if credit["role"].casefold() in set(self.letterer_synonyms):
ET.SubElement(root, "letterer").text = str(credit["person"])
if credit["role"].casefold() in set(self.cover_synonyms):
ET.SubElement(root, "coverDesigner").text = str(credit["person"])
if credit["role"].casefold() in set(self.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) -> 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(get("volume"), True)
md.comments = 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(get("pages"), True)
md.maturity_rating = utils.xlate(get("rating"))
md.price = utils.xlate(get("price"), is_float=True)
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.genre = utils.xlate(get("genre")) # TODO - repeatable field
_, md.month, md.year = utils.parse_date_str(utils.xlate(get("date")))
md.cover_image = utils.xlate(get("coverImage"))
reading_direction = utils.xlate(get("readingDirection"))
if reading_direction is not None and reading_direction == "rtl":
md.manga = "YesAndRightToLeft"
# loop for character tags
char_list = []
for n in root:
if n.tag == "character":
char_list.append((n.text or "").strip())
md.characters = ", ".join(char_list)
# 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_string(self, string: str) -> bool:
try:
tree = ET.ElementTree(ET.fromstring(string))
root = tree.getroot()
if root.tag != "comet":
return False
except ET.ParseError:
return False
return True
def write_to_external_file(self, filename: str, metadata: GenericMetadata) -> None:
tree = self.convert_metadata_to_xml(metadata)
tree.write(filename, encoding="utf-8")
def read_from_external_file(self, filename: str) -> GenericMetadata:
tree = ET.parse(filename)
return self.convert_xml_to_metadata(tree)

File diff suppressed because it is too large Load Diff

View File

@ -1,175 +0,0 @@
"""A class to encapsulate the ComicBookInfo data"""
# Copyright 2012-2014 Anthony Beville
#
# 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 collections import defaultdict
from datetime import datetime
from typing import Any, Literal, TypedDict
from comicapi import utils
from comicapi.genericmetadata import GenericMetadata
logger = logging.getLogger(__name__)
CBILiteralType = Literal[
"series",
"title",
"issue",
"publisher",
"publicationMonth",
"publicationYear",
"numberOfIssues",
"comments",
"genre",
"volume",
"numberOfVolumes",
"language",
"country",
"rating",
"credits",
"tags",
]
class Credits(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[Credits]
tags: list[str]
comments: str
CBIContainer = TypedDict("CBIContainer", {"appID": str, "lastModified": str, "ComicBookInfo/1.0": ComicBookInfoJson})
class ComicBookInfo:
def metadata_from_string(self, string: str) -> GenericMetadata:
cbi_container = json.loads(string)
metadata = GenericMetadata()
cbi = defaultdict(lambda: None, cbi_container["ComicBookInfo/1.0"])
metadata.series = utils.xlate(cbi["series"])
metadata.title = utils.xlate(cbi["title"])
metadata.issue = utils.xlate(cbi["issue"])
metadata.publisher = utils.xlate(cbi["publisher"])
metadata.month = utils.xlate(cbi["publicationMonth"], True)
metadata.year = utils.xlate(cbi["publicationYear"], True)
metadata.issue_count = utils.xlate(cbi["numberOfIssues"], True)
metadata.comments = utils.xlate(cbi["comments"])
metadata.genre = utils.xlate(cbi["genre"])
metadata.volume = utils.xlate(cbi["volume"], True)
metadata.volume_count = utils.xlate(cbi["numberOfVolumes"], True)
metadata.language = utils.xlate(cbi["language"])
metadata.country = utils.xlate(cbi["country"])
metadata.critical_rating = utils.xlate(cbi["rating"], True)
metadata.credits = [
Credits(
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["credits"]
]
metadata.tags = set(cbi["tags"]) if cbi["tags"] is not None else set()
# make sure credits and tags are at least empty lists and not None
if metadata.credits is None:
metadata.credits = []
# need the language string to be ISO
if metadata.language is not None:
metadata.language = utils.get_language(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(metadata.month, True))
assign("publicationYear", utils.xlate(metadata.year, True))
assign("numberOfIssues", utils.xlate(metadata.issue_count, True))
assign("comments", utils.xlate(metadata.comments))
assign("genre", utils.xlate(metadata.genre))
assign("volume", utils.xlate(metadata.volume, True))
assign("numberOfVolumes", utils.xlate(metadata.volume_count, True))
assign("language", utils.xlate(utils.get_language_from_iso(metadata.language)))
assign("country", utils.xlate(metadata.country))
assign("rating", utils.xlate(metadata.critical_rating, True))
assign("credits", metadata.credits)
assign("tags", list(metadata.tags))
return cbi_container
def write_to_external_file(self, filename: str, metadata: GenericMetadata) -> None:
cbi_container = self.create_json_dictionary(metadata)
with open(filename, "w", encoding="utf-8") as f:
f.write(json.dumps(cbi_container, indent=4))

View File

@ -1,276 +0,0 @@
"""A class to encapsulate ComicRack's ComicInfo.xml data"""
# Copyright 2012-2014 Anthony Beville
#
# 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 xml.etree.ElementTree as ET
from collections import OrderedDict
from typing import Any, cast
from xml.etree.ElementTree import ElementTree
from comicapi import utils
from comicapi.genericmetadata import GenericMetadata, ImageMetadata
from comicapi.issuestring import IssueString
logger = logging.getLogger(__name__)
class ComicInfoXml:
writer_synonyms = ["writer", "plotter", "scripter"]
penciller_synonyms = ["artist", "penciller", "penciler", "breakdowns"]
inker_synonyms = ["inker", "artist", "finishes"]
colorist_synonyms = ["colorist", "colourist", "colorer", "colourer"]
letterer_synonyms = ["letterer"]
cover_synonyms = ["cover", "covers", "coverartist", "cover artist"]
editor_synonyms = ["editor"]
def get_parseable_credits(self) -> list[str]:
parsable_credits = []
parsable_credits.extend(self.writer_synonyms)
parsable_credits.extend(self.penciller_synonyms)
parsable_credits.extend(self.inker_synonyms)
parsable_credits.extend(self.colorist_synonyms)
parsable_credits.extend(self.letterer_synonyms)
parsable_credits.extend(self.cover_synonyms)
parsable_credits.extend(self.editor_synonyms)
return parsable_credits
def metadata_from_string(self, string: bytes) -> GenericMetadata:
tree = ET.ElementTree(ET.fromstring(string))
return self.convert_xml_to_metadata(tree)
def string_from_metadata(self, metadata: GenericMetadata, xml: bytes = b"") -> str:
tree = self.convert_metadata_to_xml(metadata, xml)
tree_str = ET.tostring(tree.getroot(), encoding="utf-8", xml_declaration=True).decode("utf-8")
return str(tree_str)
def convert_metadata_to_xml(self, metadata: GenericMetadata, xml: bytes = b"") -> ElementTree:
# shorthand for the metadata
md = metadata
if xml:
root = ET.ElementTree(ET.fromstring(xml)).getroot()
else:
# build a tree structure
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(cix_entry: str, md_entry: Any) -> None:
if md_entry is not None and md_entry:
et_entry = root.find(cix_entry)
if et_entry is not None:
et_entry.text = str(md_entry)
else:
ET.SubElement(root, cix_entry).text = str(md_entry)
else:
et_entry = root.find(cix_entry)
if et_entry is not None:
root.remove(et_entry)
assign("Title", md.title)
assign("Series", md.series)
assign("Number", md.issue)
assign("Count", md.issue_count)
assign("Volume", md.volume)
assign("AlternateSeries", md.alternate_series)
assign("AlternateNumber", md.alternate_number)
assign("StoryArc", md.story_arc)
assign("SeriesGroup", md.series_group)
assign("AlternateCount", md.alternate_count)
assign("Summary", md.comments)
assign("Notes", md.notes)
assign("Year", md.year)
assign("Month", md.month)
assign("Day", md.day)
# need to specially process the credits, since they are structured
# differently than CIX
credit_writer_list = []
credit_penciller_list = []
credit_inker_list = []
credit_colorist_list = []
credit_letterer_list = []
credit_cover_list = []
credit_editor_list = []
# first, loop thru credits, and build a list for each role that CIX
# supports
for credit in metadata.credits:
if credit["role"].casefold() in set(self.writer_synonyms):
credit_writer_list.append(credit["person"].replace(",", ""))
if credit["role"].casefold() in set(self.penciller_synonyms):
credit_penciller_list.append(credit["person"].replace(",", ""))
if credit["role"].casefold() in set(self.inker_synonyms):
credit_inker_list.append(credit["person"].replace(",", ""))
if credit["role"].casefold() in set(self.colorist_synonyms):
credit_colorist_list.append(credit["person"].replace(",", ""))
if credit["role"].casefold() in set(self.letterer_synonyms):
credit_letterer_list.append(credit["person"].replace(",", ""))
if credit["role"].casefold() in set(self.cover_synonyms):
credit_cover_list.append(credit["person"].replace(",", ""))
if credit["role"].casefold() in set(self.editor_synonyms):
credit_editor_list.append(credit["person"].replace(",", ""))
# second, convert each list to string, and add to XML struct
assign("Writer", ", ".join(credit_writer_list))
assign("Penciller", ", ".join(credit_penciller_list))
assign("Inker", ", ".join(credit_inker_list))
assign("Colorist", ", ".join(credit_colorist_list))
assign("Letterer", ", ".join(credit_letterer_list))
assign("CoverArtist", ", ".join(credit_cover_list))
assign("Editor", ", ".join(credit_editor_list))
assign("Publisher", md.publisher)
assign("Imprint", md.imprint)
assign("Genre", md.genre)
assign("Web", md.web_link)
assign("PageCount", md.page_count)
assign("LanguageISO", md.language)
assign("Format", md.format)
assign("AgeRating", md.maturity_rating)
assign("CommunityRating", md.critical_rating)
assign("BlackAndWhite", "Yes" if md.black_and_white else None)
assign("Manga", md.manga)
assign("Characters", md.characters)
assign("Teams", md.teams)
assign("Locations", md.locations)
assign("ScanInformation", md.scan_info)
# loop and add the page entries under pages node
pages_node = root.find("Pages")
if pages_node is not None:
pages_node.clear()
else:
pages_node = ET.SubElement(root, "Pages")
for page_dict in md.pages:
page_node = ET.SubElement(pages_node, "Page")
page_node.attrib = OrderedDict(sorted((k, str(v)) for k, v in page_dict.items()))
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: ElementTree) -> GenericMetadata:
root = tree.getroot()
if root.tag != "ComicInfo":
raise Exception("Not a ComicInfo file")
def get(name: str) -> str | None:
tag = root.find(name)
if tag is None:
return None
return tag.text
md = GenericMetadata()
md.series = utils.xlate(get("Series"))
md.title = utils.xlate(get("Title"))
md.issue = IssueString(utils.xlate(get("Number"))).as_string()
md.issue_count = utils.xlate(get("Count"), True)
md.volume = utils.xlate(get("Volume"), True)
md.alternate_series = utils.xlate(get("AlternateSeries"))
md.alternate_number = IssueString(utils.xlate(get("AlternateNumber"))).as_string()
md.alternate_count = utils.xlate(get("AlternateCount"), True)
md.comments = utils.xlate(get("Summary"))
md.notes = utils.xlate(get("Notes"))
md.year = utils.xlate(get("Year"), True)
md.month = utils.xlate(get("Month"), True)
md.day = utils.xlate(get("Day"), True)
md.publisher = utils.xlate(get("Publisher"))
md.imprint = utils.xlate(get("Imprint"))
md.genre = utils.xlate(get("Genre"))
md.web_link = utils.xlate(get("Web"))
md.language = utils.xlate(get("LanguageISO"))
md.format = utils.xlate(get("Format"))
md.manga = utils.xlate(get("Manga"))
md.characters = utils.xlate(get("Characters"))
md.teams = utils.xlate(get("Teams"))
md.locations = utils.xlate(get("Locations"))
md.page_count = utils.xlate(get("PageCount"), True)
md.scan_info = utils.xlate(get("ScanInformation"))
md.story_arc = utils.xlate(get("StoryArc"))
md.series_group = utils.xlate(get("SeriesGroup"))
md.maturity_rating = utils.xlate(get("AgeRating"))
md.critical_rating = utils.xlate(get("CommunityRating"), is_float=True)
tmp = utils.xlate(get("BlackAndWhite"))
if tmp is not None and tmp.casefold() in ["yes", "true", "1"]:
md.black_and_white = True
# 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",
]
):
if n.text is not None:
for name in n.text.split(","):
md.add_credit(name.strip(), n.tag)
if n.tag == "CoverArtist":
if n.text is not None:
for name in n.text.split(","):
md.add_credit(name.strip(), "Cover")
# parse page data now
pages_node = root.find("Pages")
if pages_node is not None:
for page in pages_node:
p: dict[str, Any] = page.attrib
if "Image" in p:
p["Image"] = int(p["Image"])
if "DoublePage" in p:
p["DoublePage"] = True if p["DoublePage"].casefold() in ("yes", "true", "1") else False
md.pages.append(cast(ImageMetadata, p))
md.is_empty = False
return md
def write_to_external_file(self, filename: str, metadata: GenericMetadata, xml: bytes = b"") -> None:
tree = self.convert_metadata_to_xml(metadata, xml)
tree.write(filename, encoding="utf-8", xml_declaration=True)
def read_from_external_file(self, filename: str) -> GenericMetadata:
tree = ET.parse(filename)
return self.convert_xml_to_metadata(tree)

View File

@ -1,130 +0,0 @@
{
"Marvel":{
"marvel comics": "",
"aircel comics": "Aircel Comics",
"aircel": "Aircel Comics",
"atlas comics": "Atlas Comics",
"atlas": "Atlas Comics",
"crossgen comics": "CrossGen comics",
"crossgen": "CrossGen comics",
"curtis magazines": "Curtis Magazines",
"disney books group": "Disney Books Group",
"disney books": "Disney Books Group",
"disney kingdoms": "Disney Kingdoms",
"epic comics group": "Epic Comics",
"epic comics": "Epic Comics",
"epic": "Epic Comics",
"eternity comics": "Eternity Comics",
"humorama": "Humorama",
"icon comics": "Icon Comics",
"infinite comics": "Infinite Comics",
"malibu comics": "Malibu Comics",
"malibu": "Malibu Comics",
"marvel 2099": "Marvel 2099",
"marvel absurd": "Marvel Absurd",
"marvel adventures": "Marvel Adventures",
"marvel age": "Marvel Age",
"marvel books": "Marvel Books",
"marvel comics 2": "Marvel Comics 2",
"marvel digital comics unlimited": "Marvel Unlimited",
"marvel edge": "Marvel Edge",
"marvel frontier": "Marvel Frontier",
"marvel illustrated": "Marvel Illustrated",
"marvel knights": "Marvel Knights",
"marvel magazine group": "Marvel Magazine Group",
"marvel mangaverse": "Marvel Mangaverse",
"marvel monsters group": "Marvel Monsters Group",
"marvel music": "Marvel Music",
"marvel next": "Marvel Next",
"marvel noir": "Marvel Noir",
"marvel press": "Marvel Press",
"marvel uk": "Marvel UK",
"marvel unlimited": "Marvel Unlimited",
"max": "MAX",
"mc2": "Marvel Comics 2",
"new universe": "New Universe",
"non-pareil publishing corp.": "Non-Pareil Publishing Corp.",
"paramount comics": "Paramount Comics",
"power comics": "Power Comics",
"razorline": "Razorline",
"star comics": "Star Comics",
"timely comics": "Timely Comics",
"timely": "Timely Comics",
"tsunami": "Tsunami",
"ultimate comics": "Ultimate Comics",
"ultimate marvel": "Ultimate Marvel",
"vital publications, inc.": "Vital Publications, Inc."
},
"DC Comics":{
"dc_comics": "",
"dc": "",
"dccomics": "",
"!mpact comics": "Impact Comics",
"all star dc": "All-Star",
"all star": "All-Star",
"all-star dc": "All-Star",
"all-star": "All-Star",
"america's best comics": "America's Best Comics",
"black label": "DC Black Label",
"cliffhanger": "Cliffhanger",
"cmx manga": "CMX Manga",
"dc black label": "DC Black Label",
"dc focus": "DC Focus",
"dc ink": "DC Ink",
"dc zoom": "DC Zoom",
"earth m": "Earth M",
"earth one": "Earth One",
"earth-m": "Earth M",
"elseworlds": "Elseworlds",
"eo": "Earth One",
"first wave": "First Wave",
"focus": "DC Focus",
"helix": "Helix",
"homage comics": "Homage Comics",
"impact comics": "Impact Comics",
"impact! comics": "Impact Comics",
"johnny dc": "Johnny DC",
"mad": "Mad",
"minx": "Minx",
"paradox press": "Paradox Press",
"piranha press": "Piranha Press",
"sandman universe": "Sandman Universe",
"tangent comics": "Tangent Comics",
"tsr": "TSR",
"vertigo": "Vertigo",
"wildstorm productions": "WildStorm Productions",
"wildstorm signature": "WildStorm Productions",
"wildstorm": "WildStorm Productions",
"wonder comics": "Wonder Comics",
"young animal": "Young Animal",
"zuda comics": "Zuda Comics",
"zuda": "Zuda Comics"
},
"Dark Horse Comics":{
"berger books": "Berger Books",
"comics' greatest world": "Dark Horse Heroes",
"dark horse digital": "Dark Horse Digital",
"dark horse heroes": "Dark Horse Heroes",
"dark horse manga": "Dark Horse Manga",
"dh deluxe": "DH Deluxe",
"dh press": "DH Press",
"kitchen sink books": "Kitchen Sink Books",
"legend": "Legend",
"m press": "M Press",
"maverick": "Maverick"
},
"Archie Comics":{
"archie action": "Archie Action",
"archie adventure Series": "Archie Adventure Series",
"archie horror": "Archie Horror",
"dark circle Comics": "Dark Circle Comics",
"dark circle": "Dark Circle Comics",
"mighty comics Group": "Mighty Comics Group",
"radio comics": "Mighty Comics Group",
"red circle Comics": "Dark Circle Comics",
"red circle": "Dark Circle Comics"
}
}

View File

@ -1,352 +0,0 @@
# Extracted and mutilated from https://github.com/lordwelch/wsfmt
# Which was extracted and mutilated from https://github.com/golang/go/tree/master/src/text/template/parse
from __future__ import annotations
import calendar
import os
import unicodedata
from enum import Enum, auto
from typing import Any, Callable
class ItemType(Enum):
Error = auto() # Error occurred; value is text of error
EOF = auto()
Text = auto() # Text
LeftParen = auto()
Number = auto() # Simple number
IssueNumber = auto() # Preceded by a # Symbol
RightParen = auto()
Space = auto() # Run of spaces separating arguments
Dot = auto()
LeftBrace = auto()
RightBrace = auto()
LeftSBrace = auto()
RightSBrace = auto()
Symbol = auto()
Skip = auto() # __ or -- no title, issue or series information beyond
Operator = auto()
Calendar = auto()
InfoSpecifier = auto() # Specifies type of info e.g. v1 for 'volume': 1
ArchiveType = auto()
Honorific = auto()
Keywords = auto()
FCBD = auto()
ComicType = auto()
Publisher = auto()
C2C = auto()
braces = [
ItemType.LeftBrace,
ItemType.LeftParen,
ItemType.LeftSBrace,
ItemType.RightBrace,
ItemType.RightParen,
ItemType.RightSBrace,
]
eof = chr(0)
key = {
"fcbd": ItemType.FCBD,
"freecomicbookday": ItemType.FCBD,
"cbr": ItemType.ArchiveType,
"cbz": ItemType.ArchiveType,
"cbt": ItemType.ArchiveType,
"cb7": ItemType.ArchiveType,
"rar": ItemType.ArchiveType,
"zip": ItemType.ArchiveType,
"tar": ItemType.ArchiveType,
"7z": ItemType.ArchiveType,
"annual": ItemType.ComicType,
"book": ItemType.ComicType,
"volume": ItemType.InfoSpecifier,
"vol.": ItemType.InfoSpecifier,
"vol": ItemType.InfoSpecifier,
"v": ItemType.InfoSpecifier,
"of": ItemType.InfoSpecifier,
"dc": ItemType.Publisher,
"marvel": ItemType.Publisher,
"covers": ItemType.InfoSpecifier,
"c2c": ItemType.C2C,
"mr": ItemType.Honorific,
"ms": ItemType.Honorific,
"mrs": ItemType.Honorific,
"dr": ItemType.Honorific,
}
class Item:
def __init__(self, typ: ItemType, pos: int, val: str) -> None:
self.typ: ItemType = typ
self.pos: int = pos
self.val: str = val
def __repr__(self) -> str:
return f"{self.val}: index: {self.pos}: {self.typ}"
class Lexer:
def __init__(self, string: str) -> None:
self.input: str = string # The string being scanned
self.state: Callable[[Lexer], Callable | None] | None = None # The next lexing function to enter
self.pos: int = -1 # Current position in the input
self.start: int = 0 # Start position of this item
self.lastPos: int = 0 # Position of most recent item returned by nextItem
self.paren_depth: int = 0 # Nesting depth of ( ) exprs
self.brace_depth: int = 0 # Nesting depth of { }
self.sbrace_depth: int = 0 # Nesting depth of [ ]
self.items: list[Item] = []
# Next returns the next rune in the input.
def get(self) -> str:
if int(self.pos) >= len(self.input) - 1:
self.pos += 1
return eof
self.pos += 1
return self.input[self.pos]
# Peek returns but does not consume the next rune in the input.
def peek(self) -> str:
if int(self.pos) >= len(self.input) - 1:
return eof
return self.input[self.pos + 1]
def backup(self) -> None:
self.pos -= 1
# Emit passes an item back to the client.
def emit(self, t: ItemType) -> None:
self.items.append(Item(t, self.start, self.input[self.start : self.pos + 1]))
self.start = self.pos + 1
# Ignore skips over the pending input before this point.
def ignore(self) -> None:
self.start = self.pos
# Accept consumes the next rune if it's from the valid se:
def accept(self, valid: str) -> bool:
if self.get() in valid:
return True
self.backup()
return False
# AcceptRun consumes a run of runes from the valid set.
def accept_run(self, valid: str) -> None:
while self.get() in valid:
continue
self.backup()
def scan_number(self) -> bool:
digits = "0123456789"
self.accept_run(digits)
if self.accept("."):
if self.accept(digits):
self.accept_run(digits)
else:
self.backup()
if self.accept("s"):
if not self.accept("t"):
self.backup()
elif self.accept("nr"):
if not self.accept("d"):
self.backup()
elif self.accept("t"):
if not self.accept("h"):
self.backup()
return True
# Runs the state machine for the lexer.
def run(self) -> None:
self.state = lex_filename
while self.state is not None:
self.state = self.state(self)
# Errorf returns an error token and terminates the scan by passing
# Back a nil pointer that will be the next state, terminating self.nextItem.
def errorf(lex: Lexer, message: str) -> Callable[[Lexer], Callable | None] | None:
lex.items.append(Item(ItemType.Error, lex.start, message))
return None
# Scans the elements inside action delimiters.
def lex_filename(lex: Lexer) -> Callable[[Lexer], Callable | None] | None:
r = lex.get()
if r == eof:
if lex.paren_depth != 0:
return errorf(lex, "unclosed left paren")
if lex.brace_depth != 0:
return errorf(lex, "unclosed left paren")
lex.emit(ItemType.EOF)
return None
elif is_space(r):
if r == "_" and lex.peek() == "_":
lex.get()
lex.emit(ItemType.Skip)
else:
return lex_space
elif r == ".":
r = lex.peek()
if r < "0" or "9" < r:
lex.emit(ItemType.Dot)
return lex_filename
lex.backup()
return lex_number
elif r == "'":
r = lex.peek()
if r in "0123456789":
return lex_number
lex.emit(ItemType.Text) # TODO: Change to Text
elif "0" <= r <= "9":
lex.backup()
return lex_number
elif r == "#":
if "0" <= lex.peek() <= "9":
return lex_number
lex.emit(ItemType.Symbol)
elif is_operator(r):
if r == "-" and lex.peek() == "-":
lex.get()
lex.emit(ItemType.Skip)
else:
return lex_operator
elif is_alpha_numeric(r):
lex.backup()
return lex_text
elif r == "(":
lex.emit(ItemType.LeftParen)
lex.paren_depth += 1
elif r == ")":
lex.emit(ItemType.RightParen)
lex.paren_depth -= 1
if lex.paren_depth < 0:
return errorf(lex, "unexpected right paren " + r)
elif r == "{":
lex.emit(ItemType.LeftBrace)
lex.brace_depth += 1
elif r == "}":
lex.emit(ItemType.RightBrace)
lex.brace_depth -= 1
if lex.brace_depth < 0:
return errorf(lex, "unexpected right brace " + r)
elif r == "[":
lex.emit(ItemType.LeftSBrace)
lex.sbrace_depth += 1
elif r == "]":
lex.emit(ItemType.RightSBrace)
lex.sbrace_depth -= 1
if lex.sbrace_depth < 0:
return errorf(lex, "unexpected right brace " + r)
elif is_symbol(r):
lex.emit(ItemType.Symbol)
else:
return errorf(lex, "unrecognized character in action: " + r)
return lex_filename
def lex_operator(lex: Lexer) -> Callable:
lex.accept_run("-|:;")
lex.emit(ItemType.Operator)
return lex_filename
# LexSpace scans a run of space characters.
# One space has already been seen.
def lex_space(lex: Lexer) -> Callable:
while is_space(lex.peek()):
lex.get()
lex.emit(ItemType.Space)
return lex_filename
# Lex_text scans an alphanumeric.
def lex_text(lex: Lexer) -> Callable:
while True:
r = lex.get()
if is_alpha_numeric(r):
if r.isnumeric(): # E.g. v1
word = lex.input[lex.start : lex.pos]
if word.casefold() in key and key[word.casefold()] == ItemType.InfoSpecifier:
lex.backup()
lex.emit(key[word.casefold()])
return lex_filename
else:
if r == "'" and lex.peek() == "s":
lex.get()
else:
lex.backup()
word = lex.input[lex.start : lex.pos + 1]
if word.casefold() == "vol" and lex.peek() == ".":
lex.get()
word = lex.input[lex.start : lex.pos + 1]
if word.casefold() in key:
lex.emit(key[word.casefold()])
elif cal(word):
lex.emit(ItemType.Calendar)
else:
lex.emit(ItemType.Text)
break
return lex_filename
def cal(value: str) -> set[Any]:
month_abbr = [i for i, x in enumerate(calendar.month_abbr) if x == value.title()]
month_name = [i for i, x in enumerate(calendar.month_name) if x == value.title()]
day_abbr = [i for i, x in enumerate(calendar.day_abbr) if x == value.title()]
day_name = [i for i, x in enumerate(calendar.day_name) if x == value.title()]
return set(month_abbr + month_name + day_abbr + day_name)
def lex_number(lex: Lexer) -> Callable[[Lexer], Callable | None] | None:
if not lex.scan_number():
return errorf(lex, "bad number syntax: " + lex.input[lex.start : lex.pos])
# Complex number logic removed. Messes with math operations without space
if lex.input[lex.start] == "#":
lex.emit(ItemType.IssueNumber)
elif not lex.input[lex.pos].isdigit():
# Assume that 80th is just text and not a number
lex.emit(ItemType.Text)
else:
lex.emit(ItemType.Number)
return lex_filename
def is_space(character: str) -> bool:
return character in "_ \t"
# IsAlphaNumeric reports whether r is an alphabetic, digit, or underscore.
def is_alpha_numeric(character: str) -> bool:
return character.isalpha() or character.isnumeric()
def is_operator(character: str) -> bool:
return character in "-|:;/\\"
def is_symbol(character: str) -> bool:
return unicodedata.category(character)[0] in "PS"
def Lex(filename: str) -> Lexer:
lex = Lexer(string=os.path.basename(filename))
lex.run()
return lex

File diff suppressed because it is too large Load Diff

View File

@ -1,461 +0,0 @@
"""A class for internal metadata storage
The goal of this class is to handle ALL the data that might come from various
tagging schemes and databases, such as ComicVine or GCD. This makes conversion
possible, however lossy it might be
"""
# Copyright 2012-2014 Anthony Beville
#
# 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 copy
import dataclasses
import logging
from typing import Any, TypedDict
from comicapi import utils
logger = logging.getLogger(__name__)
class PageType:
"""
These page info classes are exactly the same as the CIX scheme, since
it's unique
"""
FrontCover = "FrontCover"
InnerCover = "InnerCover"
Roundup = "Roundup"
Story = "Story"
Advertisement = "Advertisement"
Editorial = "Editorial"
Letters = "Letters"
Preview = "Preview"
BackCover = "BackCover"
Other = "Other"
Deleted = "Deleted"
class ImageMetadata(TypedDict, total=False):
Type: str
Bookmark: str
DoublePage: bool
Image: int
ImageSize: str
ImageHeight: str
ImageWidth: str
class CreditMetadata(TypedDict):
person: str
role: str
primary: bool
@dataclasses.dataclass
class GenericMetadata:
writer_synonyms = ["writer", "plotter", "scripter"]
penciller_synonyms = ["artist", "penciller", "penciler", "breakdowns"]
inker_synonyms = ["inker", "artist", "finishes"]
colorist_synonyms = ["colorist", "colourist", "colorer", "colourer"]
letterer_synonyms = ["letterer"]
cover_synonyms = ["cover", "covers", "coverartist", "cover artist"]
editor_synonyms = ["editor"]
is_empty: bool = True
tag_origin: str | None = None
series: str | None = None
issue: str | None = None
title: str | None = None
publisher: str | None = None
month: int | None = None
year: int | None = None
day: int | None = None
issue_count: int | None = None
volume: int | None = None
genre: str | None = None
language: str | None = None # 2 letter iso code
comments: str | None = None # use same way as Summary in CIX
volume_count: int | None = None
critical_rating: float | None = None # rating in CBL; CommunityRating in CIX
country: str | None = None
alternate_series: str | None = None
alternate_number: str | None = None
alternate_count: int | None = None
imprint: str | None = None
notes: str | None = None
web_link: str | None = None
format: str | None = None
manga: str | None = None
black_and_white: bool | None = None
page_count: int | None = None
maturity_rating: str | None = None
story_arc: str | None = None
series_group: str | None = None
scan_info: str | None = None
characters: str | None = None
teams: str | None = None
locations: str | None = None
credits: list[CreditMetadata] = dataclasses.field(default_factory=list)
tags: set[str] = dataclasses.field(default_factory=set)
pages: list[ImageMetadata] = dataclasses.field(default_factory=list)
# Some CoMet-only items
price: str | None = None
is_version_of: str | None = None
rights: str | None = None
identifier: str | None = None
last_mark: str | None = None
cover_image: str | None = None
def __post_init__(self):
for key, value in self.__dict__.items():
if value and key != "is_empty":
self.is_empty = False
break
def copy(self) -> GenericMetadata:
return copy.deepcopy(self)
def replace(self, /, **kwargs: Any) -> GenericMetadata:
tmp = self.copy()
tmp.__dict__.update(kwargs)
return tmp
def overlay(self, new_md: GenericMetadata) -> None:
"""Overlay a metadata object on this one
That is, when the new object has non-None values, over-write them
to this one.
"""
def assign(cur: str, new: Any) -> None:
if new is not None:
if isinstance(new, str) and len(new) == 0:
setattr(self, cur, None)
else:
setattr(self, cur, new)
if not new_md.is_empty:
self.is_empty = False
assign("series", new_md.series)
assign("issue", new_md.issue)
assign("issue_count", new_md.issue_count)
assign("title", new_md.title)
assign("publisher", new_md.publisher)
assign("day", new_md.day)
assign("month", new_md.month)
assign("year", new_md.year)
assign("volume", new_md.volume)
assign("volume_count", new_md.volume_count)
assign("genre", new_md.genre)
assign("language", new_md.language)
assign("country", new_md.country)
assign("critical_rating", new_md.critical_rating)
assign("alternate_series", new_md.alternate_series)
assign("alternate_number", new_md.alternate_number)
assign("alternate_count", new_md.alternate_count)
assign("imprint", new_md.imprint)
assign("web_link", new_md.web_link)
assign("format", new_md.format)
assign("manga", new_md.manga)
assign("black_and_white", new_md.black_and_white)
assign("maturity_rating", new_md.maturity_rating)
assign("story_arc", new_md.story_arc)
assign("series_group", new_md.series_group)
assign("scan_info", new_md.scan_info)
assign("characters", new_md.characters)
assign("teams", new_md.teams)
assign("locations", new_md.locations)
assign("comments", new_md.comments)
assign("notes", new_md.notes)
assign("price", new_md.price)
assign("is_version_of", new_md.is_version_of)
assign("rights", new_md.rights)
assign("identifier", new_md.identifier)
assign("last_mark", new_md.last_mark)
self.overlay_credits(new_md.credits)
# TODO
# not sure if the tags and pages should broken down, or treated
# as whole lists....
# For now, go the easy route, where any overlay
# value wipes out the whole list
if len(new_md.tags) > 0:
assign("tags", new_md.tags)
if len(new_md.pages) > 0:
assign("pages", new_md.pages)
def overlay_credits(self, new_credits: list[CreditMetadata]) -> None:
for c in new_credits:
primary = bool("primary" in c and c["primary"])
# Remove credit role if person is blank
if c["person"] == "":
for r in reversed(self.credits):
if r["role"].casefold() == c["role"].casefold():
self.credits.remove(r)
# otherwise, add it!
else:
self.add_credit(c["person"], c["role"], primary)
def set_default_page_list(self, count: int) -> None:
# generate a default page list, with the first page marked as the cover
for i in range(count):
page_dict = ImageMetadata(Image=i)
if i == 0:
page_dict["Type"] = PageType.FrontCover
self.pages.append(page_dict)
def get_archive_page_index(self, pagenum: int) -> int:
# convert the displayed page number to the page index of the file in the archive
if pagenum < len(self.pages):
return int(self.pages[pagenum]["Image"])
return 0
def get_cover_page_index_list(self) -> list[int]:
# return a list of archive page indices of cover pages
coverlist = []
for p in self.pages:
if "Type" in p and p["Type"] == PageType.FrontCover:
coverlist.append(int(p["Image"]))
if len(coverlist) == 0:
coverlist.append(0)
return coverlist
def add_credit(self, person: str, role: str, primary: bool = False) -> None:
credit = CreditMetadata(person=person, role=role, primary=primary)
# look to see if it's not already there...
found = False
for c in self.credits:
if c["person"].casefold() == person.casefold() and c["role"].casefold() == role.casefold():
# no need to add it. just adjust the "primary" flag as needed
c["primary"] = primary
found = True
break
if not found:
self.credits.append(credit)
def get_primary_credit(self, role: str) -> str:
primary = ""
for credit in self.credits:
if "role" not in credit or "person" not in credit:
continue
if (primary == "" and credit["role"].casefold() == role.casefold()) or (
credit["role"].casefold() == role.casefold() and "primary" in credit and credit["primary"]
):
primary = credit["person"]
return primary
def __str__(self) -> str:
vals: list[tuple[str, Any]] = []
if self.is_empty:
return "No metadata"
def add_string(tag: str, val: Any) -> None:
if val is not None and str(val) != "":
vals.append((tag, val))
def add_attr_string(tag: str) -> None:
add_string(tag, getattr(self, tag))
add_attr_string("series")
add_attr_string("issue")
add_attr_string("issue_count")
add_attr_string("title")
add_attr_string("publisher")
add_attr_string("year")
add_attr_string("month")
add_attr_string("day")
add_attr_string("volume")
add_attr_string("volume_count")
add_attr_string("genre")
add_attr_string("language")
add_attr_string("country")
add_attr_string("critical_rating")
add_attr_string("alternate_series")
add_attr_string("alternate_number")
add_attr_string("alternate_count")
add_attr_string("imprint")
add_attr_string("web_link")
add_attr_string("format")
add_attr_string("manga")
add_attr_string("price")
add_attr_string("is_version_of")
add_attr_string("rights")
add_attr_string("identifier")
add_attr_string("last_mark")
if self.black_and_white:
add_attr_string("black_and_white")
add_attr_string("maturity_rating")
add_attr_string("story_arc")
add_attr_string("series_group")
add_attr_string("scan_info")
add_attr_string("characters")
add_attr_string("teams")
add_attr_string("locations")
add_attr_string("comments")
add_attr_string("notes")
add_string("tags", ", ".join(self.tags))
for c in self.credits:
primary = ""
if "primary" in c and c["primary"]:
primary = " [P]"
add_string("credit", c["role"] + ": " + c["person"] + primary)
# find the longest field name
flen = 0
for i in vals:
flen = max(flen, len(i[0]))
flen += 1
# format the data nicely
outstr = ""
fmt_str = "{0: <" + str(flen) + "} {1}\n"
for i in vals:
outstr += fmt_str.format(i[0] + ":", i[1])
return outstr
def fix_publisher(self) -> None:
if self.publisher is None:
return
if self.imprint is None:
self.imprint = ""
imprint, publisher = utils.get_publisher(self.publisher)
self.publisher = publisher
if self.imprint.casefold() in publisher.casefold():
self.imprint = None
if self.imprint is None or self.imprint == "":
self.imprint = imprint
elif self.imprint.casefold() in imprint.casefold():
self.imprint = imprint
md_test: GenericMetadata = GenericMetadata(
is_empty=False,
tag_origin=None,
series="Cory Doctorow's Futuristic Tales of the Here and Now",
issue="1",
title="Anda's Game",
publisher="IDW Publishing",
month=10,
year=2007,
day=1,
issue_count=6,
volume=1,
genre="Sci-Fi",
language="en",
comments=(
"For 12-year-old Anda, getting paid real money to kill the characters of players who were cheating"
" in her favorite online computer game was a win-win situation. Until she found out who was paying her,"
" and what those characters meant to the livelihood of children around the world."
),
volume_count=None,
critical_rating=3.0,
country=None,
alternate_series="Tales",
alternate_number="2",
alternate_count=7,
imprint="craphound.com",
notes="Tagged with ComicTagger 1.3.2a5 using info from Comic Vine on 2022-04-16 15:52:26. [Issue ID 140529]",
web_link="https://comicvine.gamespot.com/cory-doctorows-futuristic-tales-of-the-here-and-no/4000-140529/",
format="Series",
manga="No",
black_and_white=None,
page_count=24,
maturity_rating="Everyone 10+",
story_arc="Here and Now",
series_group="Futuristic Tales",
scan_info="(CC BY-NC-SA 3.0)",
characters="Anda",
teams="Fahrenheit",
locations="lonely cottage ",
credits=[
CreditMetadata(primary=False, person="Dara Naraghi", role="Writer"),
CreditMetadata(primary=False, person="Esteve Polls", role="Penciller"),
CreditMetadata(primary=False, person="Esteve Polls", role="Inker"),
CreditMetadata(primary=False, person="Neil Uyetake", role="Letterer"),
CreditMetadata(primary=False, person="Sam Kieth", role="Cover"),
CreditMetadata(primary=False, person="Ted Adams", role="Editor"),
],
tags=set(),
pages=[
ImageMetadata(Image=0, ImageHeight="1280", ImageSize="195977", ImageWidth="800", Type=PageType.FrontCover),
ImageMetadata(Image=1, ImageHeight="2039", ImageSize="611993", ImageWidth="1327"),
ImageMetadata(Image=2, ImageHeight="2039", ImageSize="783726", ImageWidth="1327"),
ImageMetadata(Image=3, ImageHeight="2039", ImageSize="679584", ImageWidth="1327"),
ImageMetadata(Image=4, ImageHeight="2039", ImageSize="788179", ImageWidth="1327"),
ImageMetadata(Image=5, ImageHeight="2039", ImageSize="864433", ImageWidth="1327"),
ImageMetadata(Image=6, ImageHeight="2039", ImageSize="765606", ImageWidth="1327"),
ImageMetadata(Image=7, ImageHeight="2039", ImageSize="876427", ImageWidth="1327"),
ImageMetadata(Image=8, ImageHeight="2039", ImageSize="852622", ImageWidth="1327"),
ImageMetadata(Image=9, ImageHeight="2039", ImageSize="800205", ImageWidth="1327"),
ImageMetadata(Image=10, ImageHeight="2039", ImageSize="746243", ImageWidth="1326"),
ImageMetadata(Image=11, ImageHeight="2039", ImageSize="718062", ImageWidth="1327"),
ImageMetadata(Image=12, ImageHeight="2039", ImageSize="532179", ImageWidth="1326"),
ImageMetadata(Image=13, ImageHeight="2039", ImageSize="686708", ImageWidth="1327"),
ImageMetadata(Image=14, ImageHeight="2039", ImageSize="641907", ImageWidth="1327"),
ImageMetadata(Image=15, ImageHeight="2039", ImageSize="805388", ImageWidth="1327"),
ImageMetadata(Image=16, ImageHeight="2039", ImageSize="668927", ImageWidth="1326"),
ImageMetadata(Image=17, ImageHeight="2039", ImageSize="710605", ImageWidth="1327"),
ImageMetadata(Image=18, ImageHeight="2039", ImageSize="761398", ImageWidth="1326"),
ImageMetadata(Image=19, ImageHeight="2039", ImageSize="743807", ImageWidth="1327"),
ImageMetadata(Image=20, ImageHeight="2039", ImageSize="552911", ImageWidth="1326"),
ImageMetadata(Image=21, ImageHeight="2039", ImageSize="556827", ImageWidth="1327"),
ImageMetadata(Image=22, ImageHeight="2039", ImageSize="675078", ImageWidth="1326"),
ImageMetadata(
Bookmark="Interview",
Image=23,
ImageHeight="2032",
ImageSize="800965",
ImageWidth="1338",
Type=PageType.Letters,
),
],
price=None,
is_version_of=None,
rights=None,
identifier=None,
last_mark=None,
cover_image=None,
)

View File

@ -1,116 +0,0 @@
"""Support for mixed digit/string type Issue field
Class for handling the odd permutations of an 'issue number' that the
comics industry throws at us.
e.g.: "12", "12.1", "0", "-1", "5AU", "100-2"
"""
# Copyright 2012-2014 Anthony Beville
#
# 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 unicodedata
logger = logging.getLogger(__name__)
class IssueString:
def __init__(self, text: str | None) -> None:
# break up the issue number string into 2 parts: the numeric and suffix string.
# (assumes that the numeric portion is always first)
self.num = None
self.suffix = ""
if text is None:
return
text = str(text)
if len(text) == 0:
return
# skip the minus sign if it's first
if text[0] == "-":
start = 1
else:
start = 0
# if it's still not numeric at start skip it
if text[start].isdigit() or text[start] == ".":
# walk through the string, look for split point (the first non-numeric)
decimal_count = 0
for idx in range(start, len(text)):
if text[idx] not in "0123456789.":
break
# special case: also split on second "."
if text[idx] == ".":
decimal_count += 1
if decimal_count > 1:
break
else:
idx = len(text)
# move trailing numeric decimal to suffix
# (only if there is other junk after )
if text[idx - 1] == "." and len(text) != idx:
idx = idx - 1
# if there is no numeric after the minus, make the minus part of the suffix
if idx == 1 and start == 1:
idx = 0
part1 = text[0:idx]
part2 = text[idx : len(text)]
if part1 != "":
self.num = float(part1)
self.suffix = part2
else:
self.suffix = text
def as_string(self, pad: int = 0) -> str:
# return the float, left side zero-padded, with suffix attached
if self.num is None:
return self.suffix
negative = self.num < 0
num_f = abs(self.num)
num_int = int(num_f)
num_s = str(num_int)
if float(num_int) != num_f:
num_s = str(num_f)
num_s += self.suffix
# create padding
padding = ""
length = len(str(num_int))
if length < pad:
padding = "0" * (pad - length)
num_s = padding + num_s
if negative:
num_s = "-" + num_s
return num_s
def as_float(self) -> float | None:
# return the float, with no suffix
if len(self.suffix) == 1 and self.suffix.isnumeric():
return (self.num or 0) + unicodedata.numeric(self.suffix)
return self.num

View File

@ -1,265 +0,0 @@
"""Some generic utilities"""
# Copyright 2012-2014 Anthony Beville
#
# 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 glob
import json
import logging
import os
import pathlib
import unicodedata
from collections import defaultdict
from collections.abc import Mapping
from shutil import which # noqa: F401
from typing import Any
import pycountry
import thefuzz.fuzz
logger = logging.getLogger(__name__)
class UtilsVars:
already_fixed_encoding = False
def parse_date_str(date_str: str) -> tuple[int | None, int | None, int | None]:
day = None
month = None
year = None
if date_str:
parts = date_str.split("-")
year = xlate(parts[0], True)
if len(parts) > 1:
month = xlate(parts[1], True)
if len(parts) > 2:
day = xlate(parts[2], True)
return day, month, year
def get_recursive_filelist(pathlist: list[str]) -> list[str]:
"""Get a recursive list of of all files under all path items in the list"""
filelist: list[str] = []
for p in pathlist:
if os.path.isdir(p):
filelist.extend(x for x in glob.glob(f"{p}{os.sep}/**", recursive=True) if not os.path.isdir(x))
elif str(p) not in filelist:
filelist.append(str(p))
return filelist
def add_to_path(dirname: str) -> None:
if dirname:
dirname = os.path.abspath(dirname)
paths = [os.path.normpath(x) for x in os.environ["PATH"].split(os.pathsep)]
if dirname not in paths:
paths.insert(0, dirname)
os.environ["PATH"] = os.pathsep.join(paths)
def xlate(data: Any, is_int: bool = False, is_float: bool = False) -> Any:
if data is None or data == "":
return None
if is_int or is_float:
i: str | int | float
if isinstance(data, (int, float)):
i = data
else:
i = str(data).translate(defaultdict(lambda: None, zip((ord(c) for c in "1234567890."), "1234567890.")))
if i == "":
return None
try:
if is_float:
return float(i)
return int(float(i))
except ValueError:
return None
return str(data)
def remove_articles(text: str) -> str:
text = text.casefold()
articles = [
"&",
"a",
"am",
"an",
"and",
"as",
"at",
"be",
"but",
"by",
"for",
"if",
"is",
"issue",
"it",
"it's",
"its",
"itself",
"of",
"or",
"so",
"the",
"the",
"with",
]
new_text = ""
for word in text.split():
if word not in articles:
new_text += word + " "
new_text = new_text[:-1]
return new_text
def sanitize_title(text: str, basic: bool = False) -> str:
# normalize unicode and convert to ascii. Does not work for everything eg ½ to 12 not 1/2
text = unicodedata.normalize("NFKD", text).casefold()
# comicvine keeps apostrophes a part of the word
text = text.replace("'", "")
text = text.replace('"', "")
if not basic:
# comicvine ignores punctuation and accents
# remove all characters that are not a letter, separator (space) or number
# replace any "dash punctuation" with a space
# makes sure that batman-superman and self-proclaimed stay separate words
text = "".join(
c if unicodedata.category(c)[0] not in "P" else " " for c in text if unicodedata.category(c)[0] in "LZNP"
)
# remove extra space and articles and all lower case
text = remove_articles(text).strip()
return text
def titles_match(search_title: str, record_title: str, threshold: int = 90) -> bool:
sanitized_search = sanitize_title(search_title)
sanitized_record = sanitize_title(record_title)
ratio: int = thefuzz.fuzz.ratio(sanitized_search, sanitized_record)
logger.debug(
"search title: %s ; record title: %s ; ratio: %d ; match threshold: %d",
search_title,
record_title,
ratio,
threshold,
)
return ratio >= threshold
def unique_file(file_name: pathlib.Path) -> pathlib.Path:
name = file_name.name
counter = 1
while True:
if not file_name.exists():
return file_name
file_name = file_name.with_name(name + " (" + str(counter) + ")")
counter += 1
languages: dict[str | None, str | None] = defaultdict(lambda: None)
countries: dict[str | None, str | None] = defaultdict(lambda: None)
for c in pycountry.countries:
if "alpha_2" in c._fields:
countries[c.alpha_2] = c.name
for lng in pycountry.languages:
if "alpha_2" in lng._fields:
languages[lng.alpha_2] = lng.name
def get_language_from_iso(iso: str | None) -> str | None:
return languages[iso]
def get_language(string: str | None) -> str | None:
if string is None:
return None
string = string.casefold()
lang = get_language_from_iso(string)
if lang is None:
try:
return str(pycountry.languages.lookup(string).name)
except LookupError:
return None
return lang
def get_publisher(publisher: str) -> tuple[str, str]:
imprint = ""
for pub in publishers.values():
imprint, publisher, ok = pub[publisher]
if ok:
break
return (imprint, publisher)
def update_publishers(new_publishers: Mapping[str, Mapping[str, str]]) -> None:
for publisher in new_publishers:
if publisher in publishers:
publishers[publisher].update(new_publishers[publisher])
else:
publishers[publisher] = ImprintDict(publisher, new_publishers[publisher])
class ImprintDict(dict):
"""
ImprintDict takes a publisher and a dict or mapping of lowercased
imprint names to the proper imprint name. Retrieving a value from an
ImprintDict returns a tuple of (imprint, publisher, keyExists).
if the key does not exist the key is returned as the publisher unchanged
"""
def __init__(self, publisher: str, mapping: tuple | Mapping = (), **kwargs: dict) -> None:
super().__init__(mapping, **kwargs)
self.publisher = publisher
def __missing__(self, key: str) -> None:
return None
def __getitem__(self, k: str) -> tuple[str, str, bool]:
item = super().__getitem__(k.casefold())
if k.casefold() == self.publisher.casefold():
return ("", self.publisher, True)
if item is None:
return ("", k, False)
else:
return (item, self.publisher, True)
def copy(self) -> ImprintDict:
return ImprintDict(self.publisher, super().copy())
publishers: dict[str, ImprintDict] = {}
def load_publishers() -> None:
try:
update_publishers(json.loads((pathlib.Path(__file__).parent / "data" / "publishers.json").read_text("utf-8")))
except Exception:
logger.exception("Failed to load publishers.json; The are no publishers or imprints loaded")

View File

@ -1,9 +1,5 @@
#!/usr/bin/env python3
from __future__ import annotations
import localefix
from comictaggerlib.main import ctmain
if __name__ == "__main__":
localefix.configure_locale()
ctmain()

View File

@ -1,51 +0,0 @@
# -*- mode: python -*-
import platform
from os.path import join
from comictaggerlib import ctversion
from PyInstaller.utils.hooks import get_module_file_attribute
enable_console = False
binaries = []
block_cipher = None
if platform.system() == "Windows":
enable_console = True
a = Analysis(['comictagger.py'],
binaries=binaries,
datas=[('comictaggerlib/ui/*.ui', 'ui'), ('comictaggerlib/graphics', 'graphics'), ('comicapi/data', 'comicapi/data'),(os.path.join(os.path.dirname(get_module_file_attribute('wordninja')),"wordninja"), "wordninja")],
hiddenimports=['PIL'],
hookspath=[],
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher)
pyz = PYZ(a.pure, a.zipped_data,
cipher=block_cipher)
exe = EXE(pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
# single file setup
exclude_binaries=False,
name='comictagger',
debug=False,
strip=False,
upx=True,
console=enable_console,
icon="windows/app.ico" )
app = BUNDLE(exe,
name='ComicTagger.app',
icon='mac/app.icns',
info_plist={
'NSHighResolutionCapable': 'True',
'NSRequiresAquaSystemAppearance': 'False',
'CFBundleDisplayName': 'ComicTagger',
'CFBundleShortVersionString': ctversion.version,
'CFBundleVersion': ctversion.version
},
bundle_identifier=None)

View File

@ -0,0 +1,17 @@
{
"build_systems":
[
{
"file_regex": "^[ ]*File \"(...*?)\", line ([0-9]*)",
"name": "Anaconda Python Builder",
"selector": "source.python",
"shell_cmd": "\"python3\" -u \"$file\""
}
],
"folders":
[
{
"path": "."
}
]
}

File diff suppressed because it is too large Load Diff

View File

@ -1 +1 @@
from __future__ import annotations
from ._version import version as __version__

View File

@ -1,50 +0,0 @@
from __future__ import annotations
import logging
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from comictaggerlib.settings import ComicTaggerSettings
logger = logging.getLogger(__name__)
class QTextEditLogger(QtCore.QObject, logging.Handler):
qlog = QtCore.pyqtSignal(str)
def __init__(self, formatter: logging.Formatter, level: int) -> None:
super().__init__()
self.setFormatter(formatter)
self.setLevel(level)
def emit(self, record: logging.LogRecord) -> None:
msg = self.format(record)
self.qlog.emit(msg.strip())
class ApplicationLogWindow(QtWidgets.QDialog):
def __init__(self, log_handler: QTextEditLogger, parent: QtCore.QObject = None) -> None:
super().__init__(parent)
uic.loadUi(ComicTaggerSettings.get_ui_file("logwindow.ui"), self)
self.log_handler = log_handler
self.log_handler.qlog.connect(self.textEdit.append)
f = QtGui.QFont("menlo")
f.setStyleHint(QtGui.QFont.Monospace)
self.setFont(f)
self._button = QtWidgets.QPushButton(self)
self._button.setText("Test Me")
layout = self.layout()
layout.addWidget(self._button)
# Connect signal to slot
self._button.clicked.connect(self.test)
self.textEdit.setTabStopDistance(self.textEdit.tabStopDistance() * 2)
def test(self) -> None:
logger.debug("damn, a bug")
logger.info("something to remember")
logger.warning("that's not right")
logger.error("foobar")

View File

@ -1,54 +1,38 @@
"""A PyQT4 dialog to select from automated issue matches"""
#
# Copyright 2012-2014 Anthony Beville
#
# 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
from typing import Callable
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from comicapi.comicarchive import MetaDataStyle
from comicapi.genericmetadata import GenericMetadata
from comictaggerlib.coverimagewidget import CoverImageWidget
from comictaggerlib.resulttypes import IssueResult, MultipleMatch
from comictaggerlib.settings import ComicTaggerSettings
from comictaggerlib.ui.qtutils import reduce_widget_font_size
from comictaggerlib.ui.qtutils import reduceWidgetFontSize
logger = logging.getLogger(__name__)
from .comicarchive import MetaDataStyle
from .coverimagewidget import CoverImageWidget
from .settings import ComicTaggerSettings
class AutoTagMatchWindow(QtWidgets.QDialog):
volume_id = 0
def __init__(
self,
parent: QtWidgets.QWidget,
match_set_list: list[MultipleMatch],
style: int,
fetch_func: Callable[[IssueResult], GenericMetadata],
settings: ComicTaggerSettings,
) -> None:
super().__init__(parent)
def __init__(self, parent, match_set_list, style, fetch_func):
super(AutoTagMatchWindow, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.get_ui_file("matchselectionwindow.ui"), self)
self.settings = settings
self.current_match_set: MultipleMatch = match_set_list[0]
uic.loadUi(ComicTaggerSettings.getUIFile("matchselectionwindow.ui"), self)
self.altCoverWidget = CoverImageWidget(self.altCoverContainer, CoverImageWidget.AltCoverMode)
gridlayout = QtWidgets.QGridLayout(self.altCoverContainer)
@ -60,60 +44,50 @@ class AutoTagMatchWindow(QtWidgets.QDialog):
gridlayout.addWidget(self.archiveCoverWidget)
gridlayout.setContentsMargins(0, 0, 0, 0)
reduce_widget_font_size(self.twList)
reduce_widget_font_size(self.teDescription, 1)
reduceWidgetFontSize(self.twList)
reduceWidgetFontSize(self.teDescription, 1)
self.setWindowFlags(
QtCore.Qt.WindowType(
self.windowFlags()
| QtCore.Qt.WindowType.WindowSystemMenuHint
| QtCore.Qt.WindowType.WindowMaximizeButtonHint
)
)
self.setWindowFlags(self.windowFlags() | QtCore.Qt.WindowSystemMenuHint | QtCore.Qt.WindowMaximizeButtonHint)
self.skipButton = QtWidgets.QPushButton("Skip to Next")
self.buttonBox.addButton(self.skipButton, QtWidgets.QDialogButtonBox.ButtonRole.ActionRole)
self.buttonBox.button(QtWidgets.QDialogButtonBox.StandardButton.Ok).setText("Accept and Write Tags")
self.skipButton = QtWidgets.QPushButton(self.tr("Skip to Next"))
self.buttonBox.addButton(self.skipButton, QtWidgets.QDialogButtonBox.ActionRole)
self.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).setText("Accept and Write Tags")
self.match_set_list = match_set_list
self._style = style
self.style = style
self.fetch_func = fetch_func
self.current_match_set_idx = 0
self.twList.currentItemChanged.connect(self.current_item_changed)
self.twList.cellDoubleClicked.connect(self.cell_double_clicked)
self.skipButton.clicked.connect(self.skip_to_next)
self.twList.currentItemChanged.connect(self.currentItemChanged)
self.twList.cellDoubleClicked.connect(self.cellDoubleClicked)
self.skipButton.clicked.connect(self.skipToNext)
self.update_data()
self.updateData()
def update_data(self) -> None:
def updateData(self):
self.current_match_set = self.match_set_list[self.current_match_set_idx]
if self.current_match_set_idx + 1 == len(self.match_set_list):
self.buttonBox.button(QtWidgets.QDialogButtonBox.StandardButton.Cancel).setDisabled(True)
self.skipButton.setText("Skip")
self.buttonBox.button(QtWidgets.QDialogButtonBox.Cancel).setDisabled(True)
# self.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).setText("Accept")
self.skipButton.setText(self.tr("Skip"))
self.set_cover_image()
self.populate_table()
self.setCoverImage()
self.populateTable()
self.twList.resizeColumnsToContents()
self.twList.selectRow(0)
path = self.current_match_set.ca.path
self.setWindowTitle(
"Select correct match or skip ({} of {}): {}".format(
self.current_match_set_idx + 1,
len(self.match_set_list),
os.path.split(path)[1],
)
"Select correct match or skip ({0} of {1}): {2}".format(self.current_match_set_idx + 1, len(self.match_set_list), os.path.split(path)[1])
)
def populate_table(self) -> None:
if not self.current_match_set:
return
def populateTable(self):
self.twList.setRowCount(0)
while self.twList.rowCount() > 0:
self.twList.removeRow(0)
self.twList.setSortingEnabled(False)
@ -123,137 +97,130 @@ class AutoTagMatchWindow(QtWidgets.QDialog):
item_text = match["series"]
item = QtWidgets.QTableWidgetItem(item_text)
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
item.setData(QtCore.Qt.ItemDataRole.UserRole, (match,))
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
item.setData(QtCore.Qt.ToolTipRole, item_text)
item.setData(QtCore.Qt.UserRole, (match,))
item.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 0, item)
if match["publisher"] is not None:
item_text = str(match["publisher"])
item_text = "{0}".format(match["publisher"])
else:
item_text = "Unknown"
item = QtWidgets.QTableWidgetItem(item_text)
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
item.setData(QtCore.Qt.ToolTipRole, item_text)
item.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 1, item)
month_str = ""
year_str = "????"
if match["month"] is not None:
month_str = f"-{int(match['month']):02d}"
month_str = "-{0:02d}".format(int(match["month"]))
if match["year"] is not None:
year_str = str(match["year"])
year_str = "{0}".format(match["year"])
item_text = year_str + month_str
item = QtWidgets.QTableWidgetItem(item_text)
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
item.setData(QtCore.Qt.ToolTipRole, item_text)
item.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 2, item)
item_text = match["issue_title"]
if item_text is None:
item_text = ""
item = QtWidgets.QTableWidgetItem(item_text)
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
item.setData(QtCore.Qt.ToolTipRole, item_text)
item.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 3, item)
row += 1
self.twList.resizeColumnsToContents()
self.twList.setSortingEnabled(True)
self.twList.sortItems(2, QtCore.Qt.SortOrder.AscendingOrder)
self.twList.sortItems(2, QtCore.Qt.AscendingOrder)
self.twList.selectRow(0)
self.twList.resizeColumnsToContents()
self.twList.horizontalHeader().setStretchLastSection(True)
def cell_double_clicked(self, r: int, c: int) -> None:
def cellDoubleClicked(self, r, c):
self.accept()
def current_item_changed(self, curr: QtCore.QModelIndex, prev: QtCore.QModelIndex) -> None:
def currentItemChanged(self, curr, prev):
if curr is None:
return None
return
if prev is not None and prev.row() == curr.row():
return None
return
self.altCoverWidget.set_issue_id(self.current_match()["issue_id"])
if self.current_match()["description"] is None:
self.altCoverWidget.setIssueID(self.currentMatch()["issue_id"])
if self.currentMatch()["description"] is None:
self.teDescription.setText("")
else:
self.teDescription.setText(self.current_match()["description"])
self.teDescription.setText(self.currentMatch()["description"])
def set_cover_image(self) -> None:
def setCoverImage(self):
ca = self.current_match_set.ca
self.archiveCoverWidget.set_archive(ca)
self.archiveCoverWidget.setArchive(ca)
def current_match(self) -> IssueResult:
def currentMatch(self):
row = self.twList.currentRow()
match: IssueResult = self.twList.item(row, 0).data(QtCore.Qt.ItemDataRole.UserRole)[0]
match = self.twList.item(row, 0).data(QtCore.Qt.UserRole)[0]
return match
def accept(self) -> None:
def accept(self):
self.save_match()
self.saveMatch()
self.current_match_set_idx += 1
if self.current_match_set_idx == len(self.match_set_list):
# no more items
QtWidgets.QDialog.accept(self)
else:
self.update_data()
self.updateData()
def skip_to_next(self) -> None:
def skipToNext(self):
self.current_match_set_idx += 1
if self.current_match_set_idx == len(self.match_set_list):
# no more items
QtWidgets.QDialog.reject(self)
else:
self.update_data()
self.updateData()
def reject(self) -> None:
def reject(self):
reply = QtWidgets.QMessageBox.question(
self,
"Cancel Matching",
"Are you sure you wish to cancel the matching process?",
QtWidgets.QMessageBox.StandardButton.Yes,
QtWidgets.QMessageBox.StandardButton.No,
self.tr("Cancel Matching"),
self.tr("Are you sure you wish to cancel the matching process?"),
QtWidgets.QMessageBox.Yes,
QtWidgets.QMessageBox.No,
)
if reply == QtWidgets.QMessageBox.StandardButton.No:
if reply == QtWidgets.QMessageBox.No:
return
QtWidgets.QDialog.reject(self)
def save_match(self) -> None:
def saveMatch(self):
match = self.current_match()
match = self.currentMatch()
ca = self.current_match_set.ca
md = ca.read_metadata(self._style)
if md.is_empty:
md = ca.metadata_from_filename(
self.settings.complicated_parser,
self.settings.remove_c2c,
self.settings.remove_fcbd,
self.settings.remove_publisher,
)
md = ca.readMetadata(self.style)
if md.isEmpty:
md = ca.metadataFromFilename()
# now get the particular issue data
cv_md = self.fetch_func(match)
if cv_md is None:
QtWidgets.QMessageBox.critical(
self, "Network Issue", "Could not connect to Comic Vine to get issue details!"
)
QtWidgets.QMessageBox.critical(self, self.tr("Network Issue"), self.tr("Could not connect to Comic Vine to get issue details!"))
return
QtWidgets.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.CursorShape.WaitCursor))
QtWidgets.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.WaitCursor))
md.overlay(cv_md)
success = ca.write_metadata(md, self._style)
ca.load_cache([MetaDataStyle.CBI, MetaDataStyle.CIX])
success = ca.writeMetadata(md, self.style)
ca.loadCache([MetaDataStyle.CBI, MetaDataStyle.CIX])
QtWidgets.QApplication.restoreOverrideCursor()
if not success:
QtWidgets.QMessageBox.warning(self, "Write Error", "Saving the tags to the archive seemed to fail!")
QtWidgets.QMessageBox.warning(self, self.tr("Write Error"), self.tr("Saving the tags to the archive seemed to fail!"))

View File

@ -1,36 +1,32 @@
"""A PyQT4 dialog to show ID log and progress"""
#
# Copyright 2012-2014 Anthony Beville
#
# 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
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from PyQt5 import QtCore, QtWidgets, uic
from comictaggerlib.ui.qtutils import reduceWidgetFontSize
from comictaggerlib.coverimagewidget import CoverImageWidget
from comictaggerlib.settings import ComicTaggerSettings
from comictaggerlib.ui.qtutils import reduce_widget_font_size
logger = logging.getLogger(__name__)
from .coverimagewidget import CoverImageWidget
from .settings import ComicTaggerSettings
class AutoTagProgressWindow(QtWidgets.QDialog):
def __init__(self, parent: QtWidgets.QWidget) -> None:
super().__init__(parent)
def __init__(self, parent):
super(AutoTagProgressWindow, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.get_ui_file("autotagprogresswindow.ui"), self)
uic.loadUi(ComicTaggerSettings.getUIFile("autotagprogresswindow.ui"), self)
self.archiveCoverWidget = CoverImageWidget(self.archiveCoverContainer, CoverImageWidget.DataMode, False)
gridlayout = QtWidgets.QGridLayout(self.archiveCoverContainer)
@ -44,27 +40,21 @@ class AutoTagProgressWindow(QtWidgets.QDialog):
self.isdone = False
self.setWindowFlags(
QtCore.Qt.WindowType(
self.windowFlags()
| QtCore.Qt.WindowType.WindowSystemMenuHint
| QtCore.Qt.WindowType.WindowMaximizeButtonHint
)
)
self.setWindowFlags(self.windowFlags() | QtCore.Qt.WindowSystemMenuHint | QtCore.Qt.WindowMaximizeButtonHint)
reduce_widget_font_size(self.textEdit)
reduceWidgetFontSize(self.textEdit)
def set_archive_image(self, img_data: bytes) -> None:
self.set_cover_image(img_data, self.archiveCoverWidget)
def setArchiveImage(self, img_data):
self.setCoverImage(img_data, self.archiveCoverWidget)
def set_test_image(self, img_data: bytes) -> None:
self.set_cover_image(img_data, self.testCoverWidget)
def setTestImage(self, img_data):
self.setCoverImage(img_data, self.testCoverWidget)
def set_cover_image(self, img_data: bytes, widget: CoverImageWidget) -> None:
widget.set_image_data(img_data)
def setCoverImage(self, img_data, widget):
widget.setImageData(img_data)
QtCore.QCoreApplication.processEvents()
QtCore.QCoreApplication.processEvents()
def reject(self) -> None:
def reject(self):
QtWidgets.QDialog.reject(self)
self.isdone = True

View File

@ -1,105 +1,114 @@
"""A PyQT4 dialog to confirm and set options for auto-tag"""
#
# Copyright 2012-2014 Anthony Beville
#
# 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
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from PyQt5 import QtCore, QtWidgets, uic
from comictaggerlib.settings import ComicTaggerSettings
logger = logging.getLogger(__name__)
from .settings import ComicTaggerSettings
class AutoTagStartWindow(QtWidgets.QDialog):
def __init__(self, parent: QtWidgets.QWidget, settings: ComicTaggerSettings, msg: str) -> None:
super().__init__(parent)
def __init__(self, parent, settings, msg):
super(AutoTagStartWindow, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.get_ui_file("autotagstartwindow.ui"), self)
uic.loadUi(ComicTaggerSettings.getUIFile("autotagstartwindow.ui"), self)
self.label.setText(msg)
self.setWindowFlags(
QtCore.Qt.WindowType(self.windowFlags() & ~QtCore.Qt.WindowType.WindowContextHelpButtonHint)
)
self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint)
self.settings = settings
self.cbxSpecifySearchString.setChecked(False)
self.cbxSplitWords.setChecked(False)
self.sbNameMatchSearchThresh.setValue(self.settings.id_series_match_identify_thresh)
self.cbxSaveOnLowConfidence.setCheckState(QtCore.Qt.Unchecked)
self.cbxDontUseYear.setCheckState(QtCore.Qt.Unchecked)
self.cbxAssumeIssueOne.setCheckState(QtCore.Qt.Unchecked)
self.cbxIgnoreLeadingDigitsInFilename.setCheckState(QtCore.Qt.Unchecked)
self.cbxRemoveAfterSuccess.setCheckState(QtCore.Qt.Unchecked)
self.cbxSpecifySearchString.setCheckState(QtCore.Qt.Unchecked)
self.cbxAutoImprint.setCheckState(QtCore.Qt.Unchecked)
self.leNameLengthMatchTolerance.setText(str(self.settings.id_length_delta_thresh))
self.leSearchString.setEnabled(False)
self.cbxSaveOnLowConfidence.setChecked(self.settings.save_on_low_confidence)
self.cbxDontUseYear.setChecked(self.settings.dont_use_year_when_identifying)
self.cbxAssumeIssueOne.setChecked(self.settings.assume_1_if_no_issue_num)
self.cbxIgnoreLeadingDigitsInFilename.setChecked(self.settings.ignore_leading_numbers_in_filename)
self.cbxRemoveAfterSuccess.setChecked(self.settings.remove_archive_after_successful_match)
self.cbxWaitForRateLimit.setChecked(self.settings.wait_and_retry_on_rate_limit)
self.cbxAutoImprint.setChecked(self.settings.auto_imprint)
if self.settings.save_on_low_confidence:
self.cbxSaveOnLowConfidence.setCheckState(QtCore.Qt.Checked)
if self.settings.dont_use_year_when_identifying:
self.cbxDontUseYear.setCheckState(QtCore.Qt.Checked)
if self.settings.assume_1_if_no_issue_num:
self.cbxAssumeIssueOne.setCheckState(QtCore.Qt.Checked)
if self.settings.ignore_leading_numbers_in_filename:
self.cbxIgnoreLeadingDigitsInFilename.setCheckState(QtCore.Qt.Checked)
if self.settings.remove_archive_after_successful_match:
self.cbxRemoveAfterSuccess.setCheckState(QtCore.Qt.Checked)
if self.settings.wait_and_retry_on_rate_limit:
self.cbxWaitForRateLimit.setCheckState(QtCore.Qt.Checked)
if self.settings.auto_imprint:
self.cbxAutoImprint.setCheckState(QtCore.Qt.Checked)
nlmt_tip = """<html>The <b>Name Match Ratio Threshold: Auto-Identify</b> is for eliminating automatic
search matches that are too long compared to your series name search. The lower
nlmtTip = """ <html>The <b>Name Length Match Tolerance</b> is for eliminating automatic
search matches that are too long compared to your series name search. The higher
it is, the more likely to have a good match, but each search will take longer and
use more bandwidth. Too high, and only the very closest matches will be explored.</html>"""
use more bandwidth. Too low, and only the very closest lexical matches will be
explored.</html>"""
self.sbNameMatchSearchThresh.setToolTip(nlmt_tip)
self.leNameLengthMatchTolerance.setToolTip(nlmtTip)
ss_tip = """<html>
ssTip = """<html>
The <b>series search string</b> specifies the search string to be used for all selected archives.
Use this when trying to match archives with hard-to-parse or incorrect filenames. All archives selected
should be from the same series.
</html>"""
self.leSearchString.setToolTip(ss_tip)
self.cbxSpecifySearchString.setToolTip(ss_tip)
self.leSearchString.setToolTip(ssTip)
self.cbxSpecifySearchString.setToolTip(ssTip)
self.cbxSpecifySearchString.stateChanged.connect(self.search_string_toggle)
validator = QtGui.QIntValidator(0, 99, self)
self.leNameLengthMatchTolerance.setValidator(validator)
self.auto_save_on_low = False
self.dont_use_year = False
self.assume_issue_one = False
self.ignore_leading_digits_in_filename = False
self.remove_after_success = False
self.wait_and_retry_on_rate_limit = False
self.search_string = ""
self.name_length_match_tolerance = self.settings.id_series_match_search_thresh
self.split_words = self.cbxSplitWords.isChecked()
self.cbxSpecifySearchString.stateChanged.connect(self.searchStringToggle)
def search_string_toggle(self) -> None:
self.autoSaveOnLow = False
self.dontUseYear = False
self.assumeIssueOne = False
self.ignoreLeadingDigitsInFilename = False
self.removeAfterSuccess = False
self.waitAndRetryOnRateLimit = False
self.searchString = None
self.nameLengthMatchTolerance = self.settings.id_length_delta_thresh
def searchStringToggle(self):
enable = self.cbxSpecifySearchString.isChecked()
self.leSearchString.setEnabled(enable)
def accept(self) -> None:
def accept(self):
QtWidgets.QDialog.accept(self)
self.auto_save_on_low = self.cbxSaveOnLowConfidence.isChecked()
self.dont_use_year = self.cbxDontUseYear.isChecked()
self.assume_issue_one = self.cbxAssumeIssueOne.isChecked()
self.ignore_leading_digits_in_filename = self.cbxIgnoreLeadingDigitsInFilename.isChecked()
self.remove_after_success = self.cbxRemoveAfterSuccess.isChecked()
self.name_length_match_tolerance = self.sbNameMatchSearchThresh.value()
self.wait_and_retry_on_rate_limit = self.cbxWaitForRateLimit.isChecked()
self.split_words = self.cbxSplitWords.isChecked()
self.autoSaveOnLow = self.cbxSaveOnLowConfidence.isChecked()
self.dontUseYear = self.cbxDontUseYear.isChecked()
self.assumeIssueOne = self.cbxAssumeIssueOne.isChecked()
self.ignoreLeadingDigitsInFilename = self.cbxIgnoreLeadingDigitsInFilename.isChecked()
self.removeAfterSuccess = self.cbxRemoveAfterSuccess.isChecked()
self.nameLengthMatchTolerance = int(self.leNameLengthMatchTolerance.text())
self.waitAndRetryOnRateLimit = self.cbxWaitForRateLimit.isChecked()
# persist some settings
self.settings.save_on_low_confidence = self.auto_save_on_low
self.settings.dont_use_year_when_identifying = self.dont_use_year
self.settings.assume_1_if_no_issue_num = self.assume_issue_one
self.settings.ignore_leading_numbers_in_filename = self.ignore_leading_digits_in_filename
self.settings.remove_archive_after_successful_match = self.remove_after_success
self.settings.wait_and_retry_on_rate_limit = self.wait_and_retry_on_rate_limit
self.settings.save_on_low_confidence = self.autoSaveOnLow
self.settings.dont_use_year_when_identifying = self.dontUseYear
self.settings.assume_1_if_no_issue_num = self.assumeIssueOne
self.settings.ignore_leading_numbers_in_filename = self.ignoreLeadingDigitsInFilename
self.settings.remove_archive_after_successful_match = self.removeAfterSuccess
self.settings.wait_and_retry_on_rate_limit = self.waitAndRetryOnRateLimit
if self.cbxSpecifySearchString.isChecked():
self.search_string = self.leSearchString.text()
self.searchString = str(self.leSearchString.text())
if len(self.searchString) == 0:
self.searchString = None

View File

@ -1,41 +1,34 @@
"""A class to manage modifying metadata specifically for CBL/CBI"""
#
# Copyright 2012-2014 Anthony Beville
#
# 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
from comicapi.genericmetadata import CreditMetadata, GenericMetadata
from comictaggerlib.settings import ComicTaggerSettings
logger = logging.getLogger(__name__)
class CBLTransformer:
def __init__(self, metadata: GenericMetadata, settings: ComicTaggerSettings) -> None:
def __init__(self, metadata, settings):
self.metadata = metadata
self.settings = settings
def apply(self) -> GenericMetadata:
def apply(self):
# helper funcs
def append_to_tags_if_unique(item: str) -> None:
if item.casefold() not in (tag.casefold() for tag in self.metadata.tags):
self.metadata.tags.add(item)
def append_to_tags_if_unique(item):
if item.lower() not in (tag.lower() for tag in self.metadata.tags):
self.metadata.tags.append(item)
def add_string_list_to_tags(str_list: str | None) -> None:
if str_list:
def add_string_list_to_tags(str_list):
if str_list is not None and str_list != "":
items = [s.strip() for s in str_list.split(",")]
for item in items:
append_to_tags_if_unique(item)
@ -43,11 +36,11 @@ class CBLTransformer:
if self.settings.assume_lone_credit_is_primary:
# helper
def set_lone_primary(role_list: list[str]) -> tuple[CreditMetadata | None, int]:
lone_credit: CreditMetadata | None = None
def setLonePrimary(role_list):
lone_credit = None
count = 0
for c in self.metadata.credits:
if c["role"].casefold() in role_list:
if c["role"].lower() in role_list:
count += 1
lone_credit = c
if count > 1:
@ -59,13 +52,13 @@ class CBLTransformer:
# need to loop three times, once for 'writer', 'artist', and then
# 'penciler' if no artist
set_lone_primary(["writer"])
c, count = set_lone_primary(["artist"])
setLonePrimary(["writer"])
c, count = setLonePrimary(["artist"])
if c is None and count == 0:
c, count = set_lone_primary(["penciler", "penciller"])
c, count = setLonePrimary(["penciler", "penciller"])
if c is not None:
c["primary"] = False
self.metadata.add_credit(c["person"], "Artist", True)
self.metadata.addCredit(c["person"], "Artist", True)
if self.settings.copy_characters_to_tags:
add_string_list_to_tags(self.metadata.characters)
@ -77,7 +70,7 @@ class CBLTransformer:
add_string_list_to_tags(self.metadata.locations)
if self.settings.copy_storyarcs_to_tags:
add_string_list_to_tags(self.metadata.story_arc)
add_string_list_to_tags(self.metadata.storyArc)
if self.settings.copy_notes_to_comments:
if self.metadata.notes is not None:
@ -89,12 +82,12 @@ class CBLTransformer:
self.metadata.comments += self.metadata.notes
if self.settings.copy_weblink_to_comments:
if self.metadata.web_link is not None:
if self.metadata.webLink is not None:
if self.metadata.comments is None:
self.metadata.comments = ""
else:
self.metadata.comments += "\n\n"
if self.metadata.web_link not in self.metadata.comments:
self.metadata.comments += self.metadata.web_link
if self.metadata.webLink not in self.metadata.comments:
self.metadata.comments += self.metadata.webLink
return self.metadata

View File

@ -1,53 +1,65 @@
#!/usr/bin/python
"""ComicTagger CLI functions"""
#
# Copyright 2013 Anthony Beville
#
# 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 argparse
import json
import logging
import os
import pathlib
import sys
from pprint import pprint
from comicapi import utils
from comicapi.comicarchive import ComicArchive, MetaDataStyle
from comicapi.genericmetadata import GenericMetadata
from comictaggerlib.cbltransformer import CBLTransformer
from comictaggerlib.comicvinetalker import ComicVineTalker, ComicVineTalkerException
from comictaggerlib.filerenamer import FileRenamer, get_rename_dir
from comictaggerlib.issueidentifier import IssueIdentifier
from comictaggerlib.resulttypes import IssueResult, MultipleMatch, OnlineMatchResults
from comictaggerlib.settings import ComicTaggerSettings
from . import utils
from .cbltransformer import CBLTransformer
from .comicarchive import ComicArchive, MetaDataStyle
from .comicvinetalker import ComicVineTalker, ComicVineTalkerException
from .filerenamer import FileRenamer
from .genericmetadata import GenericMetadata
from .issueidentifier import IssueIdentifier
from .options import Options
from .settings import ComicTaggerSettings
logger = logging.getLogger(__name__)
filename_encoding = sys.getfilesystemencoding()
def actual_issue_data_fetch(
match: IssueResult, settings: ComicTaggerSettings, opts: argparse.Namespace
) -> GenericMetadata:
class MultipleMatch:
def __init__(self, filename, match_list):
self.filename = filename
self.matches = match_list
class OnlineMatchResults:
def __init__(self):
self.goodMatches = []
self.noMatches = []
self.multipleMatches = []
self.lowConfidenceMatches = []
self.writeFailures = []
self.fetchDataFailures = []
def actual_issue_data_fetch(match, settings, opts):
# now get the particular issue data
try:
comic_vine = ComicVineTalker(settings.id_series_match_search_thresh)
comic_vine.wait_for_rate_limit = opts.wait_on_cv_rate_limit
cv_md = comic_vine.fetch_issue_data(match["volume_id"], match["issue_number"], settings)
comicVine = ComicVineTalker()
comicVine.wait_for_rate_limit = opts.wait_and_retry_on_rate_limit
cv_md = comicVine.fetchIssueData(match["volume_id"], match["issue_number"], settings)
except ComicVineTalkerException:
logger.exception("Network error while getting issue details. Save aborted")
return GenericMetadata()
print("Network error while getting issue details. Save aborted", file=sys.stderr)
return None
if settings.apply_cbl_transform_on_cv_import:
cv_md = CBLTransformer(cv_md, settings).apply()
@ -55,46 +67,35 @@ def actual_issue_data_fetch(
return cv_md
def actual_metadata_save(ca: ComicArchive, opts: argparse.Namespace, md: GenericMetadata) -> bool:
if not opts.dryrun:
for metadata_style in opts.type:
# write out the new data
if not ca.write_metadata(md, metadata_style):
logger.error("The tag save seemed to fail for style: %s!", MetaDataStyle.name[metadata_style])
return False
def actual_metadata_save(ca, opts, md):
print("Save complete.")
logger.info("Save complete.")
if not opts.dryrun:
# write out the new data
if not ca.writeMetadata(md, opts.data_style):
print("The tag save seemed to fail!", file=sys.stderr)
return False
else:
print("Save complete.", file=sys.stderr)
else:
if opts.terse:
logger.info("dry-run option was set, so nothing was written")
print("dry-run option was set, so nothing was written")
print("dry-run option was set, so nothing was written", file=sys.stderr)
else:
logger.info("dry-run option was set, so nothing was written, but here is the final set of tags:")
print("dry-run option was set, so nothing was written, but here is the final set of tags:")
print(f"{md}")
print("dry-run option was set, so nothing was written, but here is the final set of tags:", file=sys.stderr)
print("{0}".format(md))
return True
def display_match_set_for_choice(
label: str, match_set: MultipleMatch, opts: argparse.Namespace, settings: ComicTaggerSettings
) -> None:
print(f"{match_set.ca.path} -- {label}:")
def display_match_set_for_choice(label, match_set, opts, settings):
print("{0} -- {1}:".format(match_set.filename, label))
# sort match list by year
match_set.matches.sort(key=lambda k: k["year"] or 0)
match_set.matches.sort(key=lambda k: k["year"])
for (counter, m) in enumerate(match_set.matches):
counter += 1
print(
" {}. {} #{} [{}] ({}/{}) - {}".format(
counter,
m["series"],
m["issue_number"],
m["publisher"],
m["month"],
m["year"],
m["issue_title"],
" {0}. {1} #{2} [{3}] ({4}/{5}) - {6}".format(
counter, m["series"], m["issue_number"], m["publisher"], m["month"], m["year"], m["issue_title"]
)
)
if opts.interactive:
@ -103,59 +104,55 @@ def display_match_set_for_choice(
if (i.isdigit() and int(i) in range(1, len(match_set.matches) + 1)) or i == "s":
break
if i != "s":
i = int(i) - 1
# save the data!
# we know at this point, that the file is all good to go
ca = match_set.ca
md = create_local_metadata(opts, ca, settings)
cv_md = actual_issue_data_fetch(match_set.matches[int(i) - 1], settings, opts)
if opts.overwrite:
md = cv_md
else:
md.overlay(cv_md)
ca = ComicArchive(match_set.filename, settings.rar_exe_path, ComicTaggerSettings.getGraphic("nocover.png"))
md = create_local_metadata(opts, ca, ca.hasMetadata(opts.data_style))
cv_md = actual_issue_data_fetch(match_set.matches[int(i)], settings, opts)
md.overlay(cv_md)
if opts.auto_imprint:
md.fix_publisher()
if settings.auto_imprint:
md.fixPublisher()
actual_metadata_save(ca, opts, md)
def post_process_matches(
match_results: OnlineMatchResults, opts: argparse.Namespace, settings: ComicTaggerSettings
) -> None:
def post_process_matches(match_results, opts, settings):
# now go through the match results
if opts.show_save_summary:
if len(match_results.good_matches) > 0:
if len(match_results.goodMatches) > 0:
print("\nSuccessful matches:\n------------------")
for f in match_results.good_matches:
for f in match_results.goodMatches:
print(f)
if len(match_results.no_matches) > 0:
if len(match_results.noMatches) > 0:
print("\nNo matches:\n------------------")
for f in match_results.no_matches:
for f in match_results.noMatches:
print(f)
if len(match_results.write_failures) > 0:
if len(match_results.writeFailures) > 0:
print("\nFile Write Failures:\n------------------")
for f in match_results.write_failures:
for f in match_results.writeFailures:
print(f)
if len(match_results.fetch_data_failures) > 0:
if len(match_results.fetchDataFailures) > 0:
print("\nNetwork Data Fetch Failures:\n------------------")
for f in match_results.fetch_data_failures:
for f in match_results.fetchDataFailures:
print(f)
if not opts.show_save_summary and not opts.interactive:
# just quit if we're not interactive or showing the summary
return
if len(match_results.multiple_matches) > 0:
if len(match_results.multipleMatches) > 0:
print("\nArchives with multiple high-confidence matches:\n------------------")
for match_set in match_results.multiple_matches:
for match_set in match_results.multipleMatches:
display_match_set_for_choice("Multiple high-confidence matches", match_set, opts, settings)
if len(match_results.low_confidence_matches) > 0:
if len(match_results.lowConfidenceMatches) > 0:
print("\nArchives with low-confidence matches:\n------------------")
for match_set in match_results.low_confidence_matches:
for match_set in match_results.lowConfidenceMatches:
if len(match_set.matches) == 1:
label = "Single low-confidence match"
else:
@ -164,98 +161,89 @@ def post_process_matches(
display_match_set_for_choice(label, match_set, opts, settings)
def cli_mode(opts: argparse.Namespace, settings: ComicTaggerSettings) -> None:
def cli_mode(opts, settings):
if len(opts.file_list) < 1:
logger.error("You must specify at least one filename. Use the -h option for more info")
print("You must specify at least one filename. Use the -h option for more info", file=sys.stderr)
return
match_results = OnlineMatchResults()
for f in opts.file_list:
if isinstance(f, str):
pass
process_file_cli(f, opts, settings, match_results)
sys.stdout.flush()
post_process_matches(match_results, opts, settings)
def create_local_metadata(opts: argparse.Namespace, ca: ComicArchive, settings: ComicTaggerSettings) -> GenericMetadata:
def create_local_metadata(opts, ca, has_desired_tags):
md = GenericMetadata()
md.set_default_page_list(ca.get_number_of_pages())
md.setDefaultPageList(ca.getNumberOfPages())
if has_desired_tags:
md = ca.readMetadata(opts.data_style)
# now, overlay the parsed filename info
if opts.parse_filename:
f_md = ca.metadata_from_filename(
settings.complicated_parser,
settings.remove_c2c,
settings.remove_fcbd,
settings.remove_publisher,
opts.split_words,
)
md.overlay(f_md)
for metadata_style in opts.type:
if ca.has_metadata(metadata_style):
try:
t_md = ca.read_metadata(metadata_style)
md.overlay(t_md)
break
except Exception as e:
logger.error("Failed to load metadata for %s: %s", ca.path, e)
md.overlay(ca.metadataFromFilename())
# finally, use explicit stuff
md.overlay(opts.metadata)
if opts.metadata is not None:
md.overlay(opts.metadata)
return md
def process_file_cli(
filename: str, opts: argparse.Namespace, settings: ComicTaggerSettings, match_results: OnlineMatchResults
) -> None:
def process_file_cli(filename, opts, settings, match_results):
batch_mode = len(opts.file_list) > 1
ca = ComicArchive(filename, settings.rar_exe_path, ComicTaggerSettings.get_graphic("nocover.png"))
settings.auto_imprint = opts.auto_imprint
ca = ComicArchive(filename, settings.rar_exe_path, ComicTaggerSettings.getGraphic("nocover.png"))
if not os.path.lexists(filename):
logger.error("Cannot find %s", filename)
print("Cannot find " + filename, file=sys.stderr)
return
if not ca.seems_to_be_a_comic_archive():
logger.error("Sorry, but %s is not a comic archive!", filename)
if not ca.seemsToBeAComicArchive():
print("Sorry, but " + filename + " is not a comic archive!", file=sys.stderr)
return
if not ca.is_writable() and (opts.delete or opts.copy or opts.save or opts.rename):
logger.error("This archive is not writable")
# if not ca.isWritableForStyle(opts.data_style) and (opts.delete_tags or
# opts.save_tags or opts.rename_file):
if not ca.isWritable() and (opts.delete_tags or opts.copy_tags or opts.save_tags or opts.rename_file):
print("This archive is not writable for that tag type", file=sys.stderr)
return
has = [False, False, False]
if ca.has_cix():
if ca.hasCIX():
has[MetaDataStyle.CIX] = True
if ca.has_cbi():
if ca.hasCBI():
has[MetaDataStyle.CBI] = True
if ca.has_comet():
if ca.hasCoMet():
has[MetaDataStyle.COMET] = True
if opts.print:
if opts.print_tags:
if not opts.type:
page_count = ca.get_number_of_pages()
if opts.data_style is None:
page_count = ca.getNumberOfPages()
brief = ""
if batch_mode:
brief = f"{ca.path}: "
brief = "{0}: ".format(filename)
if ca.is_sevenzip():
brief += "7Z archive "
elif ca.is_zip():
if ca.isZip():
brief += "ZIP archive "
elif ca.is_rar():
elif ca.isRar():
brief += "RAR archive "
elif ca.is_folder():
elif ca.isFolder():
brief += "Folder archive "
brief += f"({page_count: >3} pages)"
brief += "({0: >3} pages)".format(page_count)
brief += " tags:[ "
if not (has[MetaDataStyle.CBI] or has[MetaDataStyle.CIX] or has[MetaDataStyle.COMET]):
@ -276,116 +264,99 @@ def process_file_cli(
print()
if not opts.type or MetaDataStyle.CIX in opts.type:
if opts.data_style is None or opts.data_style == MetaDataStyle.CIX:
if has[MetaDataStyle.CIX]:
print("--------- ComicRack tags ---------")
try:
if opts.raw:
print(ca.read_raw_cix())
else:
print(ca.read_cix())
except Exception as e:
logger.error("Failed to load metadata for %s: %s", ca.path, e)
if opts.raw:
print("{0}".format(str(ca.readRawCIX(), errors="ignore")))
else:
print("{0}".format(ca.readCIX()))
if not opts.type or MetaDataStyle.CBI in opts.type:
if opts.data_style is None or opts.data_style == MetaDataStyle.CBI:
if has[MetaDataStyle.CBI]:
print("------- ComicBookLover tags -------")
try:
if opts.raw:
pprint(json.loads(ca.read_raw_cbi()))
else:
print(ca.read_cbi())
except Exception as e:
logger.error("Failed to load metadata for %s: %s", ca.path, e)
if opts.raw:
pprint(json.loads(ca.readRawCBI()))
else:
print("{0}".format(ca.readCBI()))
if not opts.type or MetaDataStyle.COMET in opts.type:
if opts.data_style is None or opts.data_style == MetaDataStyle.COMET:
if has[MetaDataStyle.COMET]:
print("----------- CoMet tags -----------")
try:
if opts.raw:
print(ca.read_raw_comet())
else:
print(ca.read_comet())
except Exception as e:
logger.error("Failed to load metadata for %s: %s", ca.path, e)
elif opts.delete:
for metadata_style in opts.type:
style_name = MetaDataStyle.name[metadata_style]
if has[metadata_style]:
if not opts.dryrun:
if not ca.remove_metadata(metadata_style):
print(f"{filename}: Tag removal seemed to fail!")
else:
print(f"{filename}: Removed {style_name} tags.")
if opts.raw:
print("{0}".format(ca.readRawCoMet()))
else:
print(f"{filename}: dry-run. {style_name} tags not removed")
else:
print(f"{filename}: This archive doesn't have {style_name} tags to remove.")
print("{0}".format(ca.readCoMet()))
elif opts.copy is not None:
for metadata_style in opts.type:
dst_style_name = MetaDataStyle.name[metadata_style]
if opts.no_overwrite and has[metadata_style]:
print(f"{filename}: Already has {dst_style_name} tags. Not overwriting.")
return
if opts.copy == metadata_style:
print(f"{filename}: Destination and source are same: {dst_style_name}. Nothing to do.")
return
src_style_name = MetaDataStyle.name[opts.copy]
if has[opts.copy]:
if not opts.dryrun:
try:
md = ca.read_metadata(opts.copy)
except Exception as e:
md = GenericMetadata()
logger.error("Failed to load metadata for %s: %s", ca.path, e)
if settings.apply_cbl_transform_on_bulk_operation and metadata_style == MetaDataStyle.CBI:
md = CBLTransformer(md, settings).apply()
if not ca.write_metadata(md, metadata_style):
print(f"{filename}: Tag copy seemed to fail!")
else:
print(f"{filename}: Copied {src_style_name} tags to {dst_style_name}.")
elif opts.delete_tags:
style_name = MetaDataStyle.name[opts.data_style]
if has[opts.data_style]:
if not opts.dryrun:
if not ca.removeMetadata(opts.data_style):
print("{0}: Tag removal seemed to fail!".format(filename))
else:
print(f"{filename}: dry-run. {src_style_name} tags not copied")
print("{0}: Removed {1} tags.".format(filename, style_name))
else:
print(f"{filename}: This archive doesn't have {src_style_name} tags to copy.")
print("{0}: dry-run. {1} tags not removed".format(filename, style_name))
else:
print("{0}: This archive doesn't have {1} tags to remove.".format(filename, style_name))
elif opts.save:
elif opts.copy_tags:
dst_style_name = MetaDataStyle.name[opts.data_style]
if opts.no_overwrite and has[opts.data_style]:
print("{0}: Already has {1} tags. Not overwriting.".format(filename, dst_style_name))
return
if opts.copy_source == opts.data_style:
print("{0}: Destination and source are same: {1}. Nothing to do.".format(filename, dst_style_name))
return
if opts.no_overwrite:
for metadata_style in opts.type:
if has[metadata_style]:
print(f"{filename}: Already has {MetaDataStyle.name[metadata_style]} tags. Not overwriting.")
return
src_style_name = MetaDataStyle.name[opts.copy_source]
if has[opts.copy_source]:
if not opts.dryrun:
md = ca.readMetadata(opts.copy_source)
if settings.apply_cbl_transform_on_bulk_operation and opts.data_style == MetaDataStyle.CBI:
md = CBLTransformer(md, settings).apply()
if not ca.writeMetadata(md, opts.data_style):
print("{0}: Tag copy seemed to fail!".format(filename))
else:
print("{0}: Copied {1} tags to {2} .".format(filename, src_style_name, dst_style_name))
else:
print("{0}: dry-run. {1} tags not copied".format(filename, src_style_name))
else:
print("{0}: This archive doesn't have {1} tags to copy.".format(filename, src_style_name))
elif opts.save_tags:
if opts.no_overwrite and has[opts.data_style]:
print("{0}: Already has {1} tags. Not overwriting.".format(filename, MetaDataStyle.name[opts.data_style]))
return
if batch_mode:
print(f"Processing {ca.path}...")
print("Processing {0}...".format(filename))
md = create_local_metadata(opts, ca, settings)
md = create_local_metadata(opts, ca, has[opts.data_style])
if md.issue is None or md.issue == "":
if opts.assume_issue_one:
if opts.assume_issue_is_one_if_not_set:
md.issue = "1"
# now, search online
if opts.online:
if opts.search_online:
if opts.issue_id is not None:
# we were given the actual ID to search with
try:
comic_vine = ComicVineTalker(settings.id_series_match_search_thresh)
comic_vine.wait_for_rate_limit = opts.wait_on_cv_rate_limit
cv_md = comic_vine.fetch_issue_data_by_issue_id(opts.issue_id, settings)
comicVine = ComicVineTalker()
comicVine.wait_for_rate_limit = opts.wait_and_retry_on_rate_limit
cv_md = comicVine.fetchIssueDataByIssueID(opts.issue_id, settings)
except ComicVineTalkerException:
logger.exception("Network error while getting issue details. Save aborted")
match_results.fetch_data_failures.append(str(ca.path.absolute()))
print("Network error while getting issue details. Save aborted", file=sys.stderr)
match_results.fetchDataFailures.append(filename)
return
if cv_md is None:
logger.error("No match for ID %s was found.", opts.issue_id)
match_results.no_matches.append(str(ca.path.absolute()))
print("No match for ID {0} was found.".format(opts.issue_id), file=sys.stderr)
match_results.noMatches.append(filename)
return
if settings.apply_cbl_transform_on_cv_import:
@ -393,21 +364,21 @@ def process_file_cli(
else:
ii = IssueIdentifier(ca, settings)
if md is None or md.is_empty:
logger.error("No metadata given to search online with!")
match_results.no_matches.append(str(ca.path.absolute()))
if md is None or md.isEmpty:
print("No metadata given to search online with!", file=sys.stderr)
match_results.noMatches.append(filename)
return
def myoutput(text: str) -> None:
def myoutput(text):
if opts.verbose:
IssueIdentifier.default_write_output(text)
IssueIdentifier.defaultWriteOutput(text)
# use our overlaid MD struct to search
ii.set_additional_metadata(md)
ii.only_use_additional_meta_data = True
ii.wait_and_retry_on_rate_limit = opts.wait_on_cv_rate_limit
ii.set_output_function(myoutput)
ii.cover_page_index = md.get_cover_page_index_list()[0]
# use our overlayed MD struct to search
ii.setAdditionalMetadata(md)
ii.onlyUseAdditionalMetaData = True
ii.waitAndRetryOnRateLimit = opts.wait_and_retry_on_rate_limit
ii.setOutputFunction(myoutput)
ii.cover_page_index = md.getCoverPageIndexList()[0]
matches = ii.search()
result = ii.search_result
@ -416,165 +387,166 @@ def process_file_cli(
choices = False
low_confidence = False
if result == ii.result_no_matches:
if result == ii.ResultNoMatches:
pass
elif result == ii.result_found_match_but_bad_cover_score:
elif result == ii.ResultFoundMatchButBadCoverScore:
low_confidence = True
found_match = True
elif result == ii.result_found_match_but_not_first_page:
elif result == ii.ResultFoundMatchButNotFirstPage:
found_match = True
elif result == ii.result_multiple_matches_with_bad_image_scores:
elif result == ii.ResultMultipleMatchesWithBadImageScores:
low_confidence = True
choices = True
elif result == ii.result_one_good_match:
elif result == ii.ResultOneGoodMatch:
found_match = True
elif result == ii.result_multiple_good_matches:
elif result == ii.ResultMultipleGoodMatches:
choices = True
if choices:
if low_confidence:
logger.error("Online search: Multiple low confidence matches. Save aborted")
match_results.low_confidence_matches.append(MultipleMatch(ca, matches))
print("Online search: Multiple low confidence matches. Save aborted", file=sys.stderr)
match_results.lowConfidenceMatches.append(MultipleMatch(filename, matches))
return
logger.error("Online search: Multiple good matches. Save aborted")
match_results.multiple_matches.append(MultipleMatch(ca, matches))
return
if low_confidence and opts.abort_on_low_confidence:
logger.error("Online search: Low confidence match. Save aborted")
match_results.low_confidence_matches.append(MultipleMatch(ca, matches))
else:
print("Online search: Multiple good matches. Save aborted", file=sys.stderr)
match_results.multipleMatches.append(MultipleMatch(filename, matches))
return
if low_confidence and opts.abortOnLowConfidence:
print("Online search: Low confidence match. Save aborted", file=sys.stderr)
match_results.lowConfidenceMatches.append(MultipleMatch(filename, matches))
return
if not found_match:
logger.error("Online search: No match found. Save aborted")
match_results.no_matches.append(str(ca.path.absolute()))
print("Online search: No match found. Save aborted", file=sys.stderr)
match_results.noMatches.append(filename)
return
# we got here, so we have a single match
# now get the particular issue data
cv_md = actual_issue_data_fetch(matches[0], settings, opts)
if cv_md.is_empty:
match_results.fetch_data_failures.append(str(ca.path.absolute()))
if cv_md is None:
match_results.fetchDataFailures.append(filename)
return
if opts.overwrite:
md = cv_md
else:
md.overlay(cv_md)
md.overlay(cv_md)
if opts.auto_imprint:
md.fix_publisher()
if settings.auto_imprint:
md.fixPublisher()
# ok, done building our metadata. time to save
if not actual_metadata_save(ca, opts, md):
match_results.write_failures.append(str(ca.path.absolute()))
match_results.writeFailures.append(filename)
else:
match_results.good_matches.append(str(ca.path.absolute()))
match_results.goodMatches.append(filename)
elif opts.rename:
elif opts.rename_file:
msg_hdr = ""
if batch_mode:
msg_hdr = f"{ca.path}: "
msg_hdr = "{0}: ".format(filename)
md = create_local_metadata(opts, ca, settings)
if opts.data_style is not None:
use_tags = has[opts.data_style]
else:
use_tags = False
md = create_local_metadata(opts, ca, use_tags)
if md.series is None:
logger.error(msg_hdr + "Can't rename without series name")
print(msg_hdr + "Can't rename without series name", file=sys.stderr)
return
new_ext = "" # default
new_ext = None # default
if settings.rename_extension_based_on_archive:
if ca.is_sevenzip():
new_ext = ".cb7"
elif ca.is_zip():
if ca.isZip():
new_ext = ".cbz"
elif ca.is_rar():
elif ca.isRar():
new_ext = ".cbr"
renamer = FileRenamer(md, platform="universal" if settings.rename_strict else "auto")
renamer.set_template(settings.rename_template)
renamer.set_issue_zero_padding(settings.rename_issue_number_padding)
renamer.set_smart_cleanup(settings.rename_use_smart_string_cleanup)
renamer = FileRenamer(md)
renamer.setTemplate(settings.rename_template)
renamer.setIssueZeroPadding(settings.rename_issue_number_padding)
renamer.setSmartCleanup(settings.rename_use_smart_string_cleanup)
renamer.move = settings.rename_move_dir
try:
new_name = renamer.determine_name(ext=new_ext)
except ValueError:
logger.exception(
msg_hdr + "Invalid format string!\n"
"Your rename template is invalid!\n\n"
"%s\n\n"
"Please consult the template help in the settings "
new_name = renamer.determineName(filename, ext=new_ext)
except Exception as e:
print(
msg_hdr + "Invalid format string!\nYour rename template is invalid!\n\n"
"{}\n\nPlease consult the template help in the settings "
"and the documentation on the format at "
"https://docs.python.org/3/library/string.html#format-string-syntax",
settings.rename_template,
file=sys.stderr,
)
return
except Exception:
logger.exception("Formatter failure: %s metadata: %s", settings.rename_template, renamer.metadata)
folder = get_rename_dir(ca, settings.rename_dir if settings.rename_move_dir else None)
folder = os.path.dirname(os.path.abspath(filename))
if settings.rename_move_dir and len(settings.rename_dir.strip()) > 3:
folder = settings.rename_dir.strip()
full_path = folder / new_name
new_abs_path = utils.unique_file(os.path.join(folder, new_name))
if full_path == ca.path:
if os.path.join(folder, new_name) == os.path.abspath(filename):
print(msg_hdr + "Filename is already good!", file=sys.stderr)
return
suffix = ""
if not opts.dryrun:
# rename the file
ca.rename(utils.unique_file(full_path))
os.makedirs(os.path.dirname(new_abs_path), 0o777, True)
os.rename(filename, new_abs_path)
else:
suffix = " (dry-run, no change)"
print(f"renamed '{os.path.basename(ca.path)}' -> '{new_name}' {suffix}")
print("renamed '{0}' -> '{1}' {2}".format(os.path.basename(filename), new_name, suffix))
elif opts.export_to_zip:
msg_hdr = ""
if batch_mode:
msg_hdr = f"{ca.path}: "
msg_hdr = "{0}: ".format(filename)
if ca.is_zip():
logger.error(msg_hdr + "Archive is already a zip file.")
if not ca.isRar():
print(msg_hdr + "Archive is not a RAR.", file=sys.stderr)
return
filename_path = pathlib.Path(filename).absolute()
new_file = filename_path.with_suffix(".cbz")
rar_file = os.path.abspath(os.path.abspath(filename))
new_file = os.path.splitext(rar_file)[0] + ".cbz"
if opts.abort_on_conflict and new_file.exists():
print(msg_hdr + f"{new_file.name} already exists in the that folder.")
if opts.abort_export_on_conflict and os.path.lexists(new_file):
print(msg_hdr + "{0} already exists in the that folder.".format(os.path.split(new_file)[1]))
return
new_file = utils.unique_file(new_file)
new_file = utils.unique_file(os.path.join(new_file))
delete_success = False
export_success = False
if not opts.dryrun:
if ca.export_as_zip(new_file):
if ca.exportAsZip(new_file):
export_success = True
if opts.delete_after_zip_export:
if opts.delete_rar_after_export:
try:
filename_path.unlink(missing_ok=True)
delete_success = True
except OSError:
logger.exception(msg_hdr + "Error deleting original archive after export")
os.unlink(rar_file)
except:
print(msg_hdr + "Error deleting original RAR after export", file=sys.stderr)
delete_success = False
else:
delete_success = True
else:
# last export failed, so remove the zip, if it exists
new_file.unlink(missing_ok=True)
if os.path.lexists(new_file):
os.remove(new_file)
else:
msg = msg_hdr + f"Dry-run: Would try to create {os.path.split(new_file)[1]}"
if opts.delete_after_zip_export:
msg += " and delete original."
msg = msg_hdr + "Dry-run: Would try to create {0}".format(os.path.split(new_file)[1])
if opts.delete_rar_after_export:
msg += " and delete orginal."
print(msg)
return
msg = msg_hdr
if export_success:
msg += f"Archive exported successfully to: {os.path.split(new_file)[1]}"
if opts.delete_after_zip_export and delete_success:
msg += "Archive exported successfully to: {0}".format(os.path.split(new_file)[1])
if opts.delete_rar_after_export and delete_success:
msg += " (Original deleted) "
else:
msg += "Archive failed to export!"

1
comictaggerlib/comet.py Normal file
View File

@ -0,0 +1 @@
from comicapi.comet import *

View File

@ -0,0 +1 @@
from comicapi.comicarchive import *

View File

@ -0,0 +1 @@
from comicapi.comicbookinfo import *

View File

@ -0,0 +1 @@
from comicapi.comicinfoxml import *

View File

@ -1,37 +1,31 @@
"""A python class to manage caching of data from Comic Vine"""
#
# Copyright 2012-2014 Anthony Beville
#
# 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 datetime
import logging
import os
import sqlite3 as lite
from typing import Any
from comictaggerlib import ctversion
from comictaggerlib.resulttypes import CVImage, CVIssuesResults, CVPublisher, CVVolumeResults, SelectDetails
from comictaggerlib.settings import ComicTaggerSettings
logger = logging.getLogger(__name__)
from . import _version, utils
from .settings import ComicTaggerSettings
class ComicCacher:
def __init__(self) -> None:
self.settings_folder = ComicTaggerSettings.get_settings_folder()
self.db_file = os.path.join(self.settings_folder, "comic_cache.db")
class ComicVineCacher:
def __init__(self):
self.settings_folder = ComicTaggerSettings.getSettingsFolder()
self.db_file = os.path.join(self.settings_folder, "cv_cache.db")
self.version_file = os.path.join(self.settings_folder, "cache_version.txt")
# verify that cache is from same version as this one
@ -40,80 +34,75 @@ class ComicCacher:
with open(self.version_file, "rb") as f:
data = f.read().decode("utf-8")
f.close()
except Exception:
except:
pass
if data != ctversion.version:
self.clear_cache()
if data != _version.version:
self.clearCache()
if not os.path.exists(self.db_file):
self.create_cache_db()
def clear_cache(self) -> None:
def clearCache(self):
try:
os.unlink(self.db_file)
except Exception:
except:
pass
try:
os.unlink(self.version_file)
except Exception:
except:
pass
def create_cache_db(self) -> None:
def create_cache_db(self):
# create the version file
with open(self.version_file, "w", encoding="utf-8") as f:
f.write(ctversion.version)
with open(self.version_file, "w") as f:
f.write(_version.version)
# this will wipe out any existing version
open(self.db_file, "wb").close()
open(self.db_file, "w").close()
con = lite.connect(self.db_file)
# create tables
with con:
cur = con.cursor()
# source_name,name,id,start_year,publisher,image,description,count_of_issues
# name,id,start_year,publisher,image,description,count_of_issues
cur.execute(
"CREATE TABLE VolumeSearchCache("
+ "search_term TEXT,"
+ "id INT NOT NULL,"
+ "id INT,"
+ "name TEXT,"
+ "start_year INT,"
+ "publisher TEXT,"
+ "count_of_issues INT,"
+ "image_url TEXT,"
+ "description TEXT,"
+ "timestamp DATE DEFAULT (datetime('now','localtime')),"
+ "source_name TEXT NOT NULL,"
+ "aliases TEXT)" # Newline separated
+ "timestamp DATE DEFAULT (datetime('now','localtime'))) "
)
cur.execute(
"CREATE TABLE Volumes("
+ "id INT NOT NULL,"
+ "id INT,"
+ "name TEXT,"
+ "publisher TEXT,"
+ "count_of_issues INT,"
+ "start_year INT,"
+ "timestamp DATE DEFAULT (datetime('now','localtime')), "
+ "source_name TEXT NOT NULL,"
+ "aliases TEXT," # Newline separated
+ "PRIMARY KEY (id, source_name))"
+ "PRIMARY KEY (id))"
)
cur.execute(
"CREATE TABLE AltCovers("
+ "issue_id INT NOT NULL,"
+ "issue_id INT,"
+ "url_list TEXT,"
+ "timestamp DATE DEFAULT (datetime('now','localtime')), "
+ "source_name TEXT NOT NULL,"
+ "aliases TEXT," # Newline separated
+ "PRIMARY KEY (issue_id, source_name))"
+ "PRIMARY KEY (issue_id))"
)
cur.execute(
"CREATE TABLE Issues("
+ "id INT NOT NULL,"
+ "id INT,"
+ "volume_id INT,"
+ "name TEXT,"
+ "issue_number TEXT,"
@ -123,12 +112,10 @@ class ComicCacher:
+ "site_detail_url TEXT,"
+ "description TEXT,"
+ "timestamp DATE DEFAULT (datetime('now','localtime')), "
+ "source_name TEXT NOT NULL,"
+ "aliases TEXT," # Newline separated
+ "PRIMARY KEY (id, source_name))"
+ "PRIMARY KEY (id))"
)
def add_search_results(self, source_name: str, search_term: str, cv_search_results: list[CVVolumeResults]) -> None:
def add_search_results(self, search_term, cv_search_results):
con = lite.connect(self.db_file)
@ -137,13 +124,11 @@ class ComicCacher:
cur = con.cursor()
# remove all previous entries with this search term
cur.execute(
"DELETE FROM VolumeSearchCache WHERE search_term = ? AND source_name = ?",
[search_term.casefold(), source_name],
)
cur.execute("DELETE FROM VolumeSearchCache WHERE search_term = ?", [search_term.lower()])
# now add in new results
for record in cv_search_results:
timestamp = datetime.datetime.now()
if record["publisher"] is None:
pub_name = ""
@ -157,11 +142,10 @@ class ComicCacher:
cur.execute(
"INSERT INTO VolumeSearchCache "
+ "(source_name, search_term, id, name, start_year, publisher, count_of_issues, image_url, description, aliases) "
+ "VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ "(search_term, id, name, start_year, publisher, count_of_issues, image_url, description) "
+ "VALUES(?, ?, ?, ?, ?, ?, ?, ?)",
(
source_name,
search_term.casefold(),
search_term.lower(),
record["id"],
record["name"],
record["start_year"],
@ -169,13 +153,12 @@ class ComicCacher:
record["count_of_issues"],
url,
record["description"],
record["aliases"],
),
)
def get_search_results(self, source_name: str, search_term: str) -> list[CVVolumeResults]:
def get_search_results(self, search_term):
results = []
results = list()
con = lite.connect(self.db_file)
with con:
con.text_factory = str
@ -186,29 +169,27 @@ class ComicCacher:
cur.execute("DELETE FROM VolumeSearchCache WHERE timestamp < ?", [str(a_day_ago)])
# fetch
cur.execute(
"SELECT * FROM VolumeSearchCache WHERE search_term=? AND source_name=?",
[search_term.casefold(), source_name],
)
cur.execute("SELECT * FROM VolumeSearchCache WHERE search_term=?", [search_term.lower()])
rows = cur.fetchall()
# now process the results
for record in rows:
result = CVVolumeResults(
id=record[1],
name=record[2],
start_year=record[3],
count_of_issues=record[5],
description=record[7],
publisher=CVPublisher(name=record[4]),
image=CVImage(super_url=record[6]),
aliases=record[10],
)
result = dict()
result["id"] = record[1]
result["name"] = record[2]
result["start_year"] = record[3]
result["publisher"] = dict()
result["publisher"]["name"] = record[4]
result["count_of_issues"] = record[5]
result["image"] = dict()
result["image"]["super_url"] = record[6]
result["description"] = record[7]
results.append(result)
return results
def add_alt_covers(self, source_name: str, issue_id: int, url_list: list[str]) -> None:
def add_alt_covers(self, issue_id, url_list):
con = lite.connect(self.db_file)
@ -217,16 +198,13 @@ class ComicCacher:
cur = con.cursor()
# remove all previous entries with this search term
cur.execute("DELETE FROM AltCovers WHERE issue_id=? AND source_name=?", [issue_id, source_name])
cur.execute("DELETE FROM AltCovers WHERE issue_id = ?", [issue_id])
url_list_str = ",".join(url_list)
url_list_str = utils.listToString(url_list)
# now add in new record
cur.execute(
"INSERT INTO AltCovers (source_name, issue_id, url_list) VALUES(?, ?, ?)",
(source_name, issue_id, url_list_str),
)
cur.execute("INSERT INTO AltCovers " + "(issue_id, url_list) " + "VALUES(?, ?)", (issue_id, url_list_str))
def get_alt_covers(self, source_name: str, issue_id: int) -> list[str]:
def get_alt_covers(self, issue_id):
con = lite.connect(self.db_file)
with con:
@ -238,18 +216,21 @@ class ComicCacher:
a_month_ago = datetime.datetime.today() - datetime.timedelta(days=30)
cur.execute("DELETE FROM AltCovers WHERE timestamp < ?", [str(a_month_ago)])
cur.execute("SELECT url_list FROM AltCovers WHERE issue_id=? AND source_name=?", [issue_id, source_name])
cur.execute("SELECT url_list FROM AltCovers WHERE issue_id=?", [issue_id])
row = cur.fetchone()
if row is None:
return []
return None
else:
url_list_str = row[0]
if len(url_list_str) == 0:
return []
raw_list = url_list_str.split(",")
url_list = []
for item in raw_list:
url_list.append(str(item).strip())
return url_list
url_list_str = row[0]
if not url_list_str:
return []
url_list = str(url_list_str).split(",")
return url_list
def add_volume_info(self, source_name: str, cv_volume_record: CVVolumeResults) -> None:
def add_volume_info(self, cv_volume_record):
con = lite.connect(self.db_file)
@ -265,22 +246,20 @@ class ComicCacher:
pub_name = cv_volume_record["publisher"]["name"]
data = {
"id": cv_volume_record["id"],
"source_name": source_name,
"name": cv_volume_record["name"],
"publisher": pub_name,
"count_of_issues": cv_volume_record["count_of_issues"],
"start_year": cv_volume_record["start_year"],
"timestamp": timestamp,
"aliases": cv_volume_record["aliases"],
}
self.upsert(cur, "volumes", data)
self.upsert(cur, "volumes", "id", cv_volume_record["id"], data)
def add_volume_issues_info(self, source_name: str, volume_id: int, cv_volume_issues: list[CVIssuesResults]) -> None:
def add_volume_issues_info(self, volume_id, cv_volume_issues):
con = lite.connect(self.db_file)
with con:
cur = con.cursor()
timestamp = datetime.datetime.now()
@ -288,10 +267,9 @@ class ComicCacher:
# add in issues
for issue in cv_volume_issues:
data = {
"id": issue["id"],
"volume_id": volume_id,
"source_name": source_name,
"name": issue["name"],
"issue_number": issue["issue_number"],
"site_detail_url": issue["site_detail_url"],
@ -300,13 +278,12 @@ class ComicCacher:
"thumb_url": issue["image"]["thumb_url"],
"description": issue["description"],
"timestamp": timestamp,
"aliases": issue["aliases"],
}
self.upsert(cur, "issues", data)
self.upsert(cur, "issues", "id", issue["id"], data)
def get_volume_info(self, volume_id: int, source_name: str) -> CVVolumeResults | None:
def get_volume_info(self, volume_id):
result: CVVolumeResults | None = None
result = None
con = lite.connect(self.db_file)
with con:
@ -318,30 +295,29 @@ class ComicCacher:
cur.execute("DELETE FROM Volumes WHERE timestamp < ?", [str(a_week_ago)])
# fetch
cur.execute(
"SELECT source_name,id,name,publisher,count_of_issues,start_year,aliases FROM Volumes"
" WHERE id=? AND source_name=?",
[volume_id, source_name],
)
cur.execute("SELECT id,name,publisher,count_of_issues,start_year FROM Volumes WHERE id = ?", [volume_id])
row = cur.fetchone()
if row is None:
return result
result = dict()
# since ID is primary key, there is only one row
result = CVVolumeResults(
id=row[1],
name=row[2],
count_of_issues=row[4],
start_year=row[5],
publisher=CVPublisher(name=row[3]),
aliases=row[6],
)
result["id"] = row[0]
result["name"] = row[1]
result["publisher"] = dict()
result["publisher"]["name"] = row[2]
result["count_of_issues"] = row[3]
result["start_year"] = row[4]
result["issues"] = list()
return result
def get_volume_issues_info(self, volume_id: int, source_name: str) -> list[CVIssuesResults]:
def get_volume_issues_info(self, volume_id):
result = None
con = lite.connect(self.db_file)
with con:
@ -354,43 +330,35 @@ class ComicCacher:
cur.execute("DELETE FROM Issues WHERE timestamp < ?", [str(a_week_ago)])
# fetch
results: list[CVIssuesResults] = []
results = list()
cur.execute(
(
"SELECT source_name,id,name,issue_number,site_detail_url,cover_date,super_url,thumb_url,description,aliases"
" FROM Issues WHERE volume_id=? AND source_name=?"
),
[volume_id, source_name],
"SELECT id,name,issue_number,site_detail_url,cover_date,super_url,thumb_url,description FROM Issues WHERE volume_id = ?", [volume_id]
)
rows = cur.fetchall()
# now process the results
for row in rows:
record = CVIssuesResults(
id=row[1],
name=row[2],
issue_number=row[3],
site_detail_url=row[4],
cover_date=row[5],
image=CVImage(super_url=row[6], thumb_url=row[7]),
description=row[8],
aliases=row[9],
)
record = dict()
record["id"] = row[0]
record["name"] = row[1]
record["issue_number"] = row[2]
record["site_detail_url"] = row[3]
record["cover_date"] = row[4]
record["image"] = dict()
record["image"]["super_url"] = row[5]
record["image"]["thumb_url"] = row[6]
record["description"] = row[7]
results.append(record)
if len(results) == 0:
return None
return results
def add_issue_select_details(
self,
source_name: str,
issue_id: int,
image_url: str,
thumb_image_url: str,
cover_date: str,
site_detail_url: str,
) -> None:
def add_issue_select_details(self, issue_id, image_url, thumb_image_url, cover_date, site_detail_url):
con = lite.connect(self.db_file)
@ -400,36 +368,32 @@ class ComicCacher:
timestamp = datetime.datetime.now()
data = {
"id": issue_id,
"source_name": source_name,
"super_url": image_url,
"thumb_url": thumb_image_url,
"cover_date": cover_date,
"site_detail_url": site_detail_url,
"timestamp": timestamp,
}
self.upsert(cur, "issues", data)
self.upsert(cur, "issues", "id", issue_id, data)
def get_issue_select_details(self, issue_id: int, source_name: str) -> SelectDetails:
def get_issue_select_details(self, issue_id):
con = lite.connect(self.db_file)
with con:
cur = con.cursor()
con.text_factory = str
cur.execute(
"SELECT super_url,thumb_url,cover_date,site_detail_url FROM Issues WHERE id=? AND source_name=?",
[issue_id, source_name],
)
cur.execute("SELECT super_url,thumb_url,cover_date,site_detail_url FROM Issues WHERE id=?", [issue_id])
row = cur.fetchone()
details = SelectDetails(
image_url=None,
thumb_image_url=None,
cover_date=None,
site_detail_url=None,
)
if row is not None and row[0] is not None:
details = dict()
if row is None or row[0] is None:
details["image_url"] = None
details["thumb_image_url"] = None
details["cover_date"] = None
details["site_detail_url"] = None
else:
details["image_url"] = row[0]
details["thumb_image_url"] = row[1]
details["cover_date"] = row[2]
@ -437,15 +401,18 @@ class ComicCacher:
return details
def upsert(self, cur: lite.Cursor, tablename: str, data: dict[str, Any]) -> None:
def upsert(self, cur, tablename, pkname, pkval, data):
"""This does an insert if the given PK doesn't exist, and an
update it if does
TODO: look into checking if UPDATE is needed
TODO: should the cursor be created here, and not up the stack?
"""
ins_count = len(data) + 1
keys = ""
vals = []
vals = list()
ins_slots = ""
set_slots = ""
@ -463,5 +430,13 @@ class ComicCacher:
ins_slots += "?"
set_slots += key + " = ?"
sql_ins = f"INSERT OR REPLACE INTO {tablename} ({keys}) VALUES ({ins_slots})"
keys += ", " + pkname
vals.append(pkval)
ins_slots += ", ?"
condition = pkname + " = ?"
sql_ins = "INSERT OR IGNORE INTO " + tablename + " (" + keys + ") " + " VALUES (" + ins_slots + ")"
cur.execute(sql_ins, vals)
sql_upd = "UPDATE " + tablename + " SET " + set_slots + " WHERE " + condition
cur.execute(sql_upd, vals)

File diff suppressed because it is too large Load Diff

View File

@ -3,244 +3,204 @@
Display cover images from either a local archive, or from Comic Vine.
TODO: This should be re-factored using subclasses!
"""
#
# Copyright 2012-2014 Anthony Beville
#
# 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
from typing import Callable, cast
from PyQt5 import uic
from PyQt5.QtCore import *
from PyQt5.QtGui import *
from PyQt5.QtWidgets import *
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from comictaggerlib.ui.qtutils import getQImageFromData, reduceWidgetFontSize
from comicapi import utils
from comicapi.comicarchive import ComicArchive
from comictaggerlib.comicvinetalker import ComicVineTalker
from comictaggerlib.imagefetcher import ImageFetcher
from comictaggerlib.imagepopup import ImagePopup
from comictaggerlib.pageloader import PageLoader
from comictaggerlib.settings import ComicTaggerSettings
from comictaggerlib.ui.qtutils import get_qimage_from_data, reduce_widget_font_size
logger = logging.getLogger(__name__)
from .comicvinetalker import ComicVineTalker, ComicVineTalkerException
from .imagefetcher import ImageFetcher
from .imagepopup import ImagePopup
from .pageloader import PageLoader
from .settings import ComicTaggerSettings
def clickable(widget: QtWidgets.QWidget) -> QtCore.pyqtBoundSignal:
"""Allow a label to be clickable"""
def clickable(widget):
"""# Allow a label to be clickable"""
class Filter(QtCore.QObject):
class Filter(QObject):
dblclicked = QtCore.pyqtSignal()
dblclicked = pyqtSignal()
def eventFilter(self, obj, event):
def eventFilter(self, obj: QtCore.QObject, event: QtCore.QEvent) -> bool:
if obj == widget:
if event.type() == QtCore.QEvent.Type.MouseButtonDblClick:
if event.type() == QEvent.MouseButtonDblClick:
self.dblclicked.emit()
return True
return False
flt = Filter(widget)
widget.installEventFilter(flt)
return flt.dblclicked
filter = Filter(widget)
widget.installEventFilter(filter)
return filter.dblclicked
class Signal(QtCore.QObject):
alt_url_list_fetch_complete = QtCore.pyqtSignal(list)
url_fetch_complete = QtCore.pyqtSignal(str, str)
image_fetch_complete = QtCore.pyqtSignal(QtCore.QByteArray)
class CoverImageWidget(QWidget):
def __init__(
self,
list_fetch: Callable[[list[str]], None],
url_fetch: Callable[[str, str], None],
image_fetch: Callable[[bytes], None],
) -> None:
super().__init__()
self.alt_url_list_fetch_complete.connect(list_fetch)
self.url_fetch_complete.connect(url_fetch)
self.image_fetch_complete.connect(image_fetch)
def emit_list(self, url_list: list[str]) -> None:
self.alt_url_list_fetch_complete.emit(url_list)
def emit_url(self, image_url: str, thumb_url: str | None) -> None:
self.url_fetch_complete.emit(image_url, thumb_url)
def emit_image(self, image_data: bytes | QtCore.QByteArray) -> None:
self.image_fetch_complete.emit(image_data)
class CoverImageWidget(QtWidgets.QWidget):
ArchiveMode = 0
AltCoverMode = 1
URLMode = 1
DataMode = 3
def __init__(self, parent: QtWidgets.QWidget, mode: int, expand_on_click: bool = True) -> None:
super().__init__(parent)
def __init__(self, parent, mode, expand_on_click=True):
super(CoverImageWidget, self).__init__(parent)
self.cover_fetcher = ImageFetcher()
uic.loadUi(ComicTaggerSettings.get_ui_file("coverimagewidget.ui"), self)
uic.loadUi(ComicTaggerSettings.getUIFile("coverimagewidget.ui"), self)
reduce_widget_font_size(self.label)
reduceWidgetFontSize(self.label)
self.sig = Signal(
self.alt_cover_url_list_fetch_complete, self.primary_url_fetch_complete, self.cover_remote_fetch_complete
)
self.mode: int = mode
self.page_loader: PageLoader | None = None
self.mode = mode
self.comicVine = ComicVineTalker()
self.page_loader = None
self.showControls = True
self.current_pixmap = QtGui.QPixmap()
self.btnLeft.setIcon(QIcon(ComicTaggerSettings.getGraphic("left.png")))
self.btnRight.setIcon(QIcon(ComicTaggerSettings.getGraphic("right.png")))
self.comic_archive: ComicArchive | None = None
self.issue_id: int | None = None
self.url_list: list[str] = []
if self.page_loader is not None:
self.page_loader.abandoned = True
self.page_loader = None
self.imageIndex = -1
self.imageCount = 1
self.imageData = b""
self.btnLeft.setIcon(QtGui.QIcon(ComicTaggerSettings.get_graphic("left.png")))
self.btnRight.setIcon(QtGui.QIcon(ComicTaggerSettings.get_graphic("right.png")))
self.btnLeft.clicked.connect(self.decrement_image)
self.btnRight.clicked.connect(self.increment_image)
self.btnLeft.clicked.connect(self.decrementImage)
self.btnRight.clicked.connect(self.incrementImage)
self.resetWidget()
if expand_on_click:
clickable(self.lblImage).connect(self.show_popup)
clickable(self.lblImage).connect(self.showPopup)
else:
self.lblImage.setToolTip("")
self.update_content()
self.updateContent()
def reset_widget(self) -> None:
def resetWidget(self):
self.comic_archive = None
self.issue_id = None
self.comicVine = None
self.cover_fetcher = None
self.url_list = []
if self.page_loader is not None:
self.page_loader.abandoned = True
self.page_loader = None
self.imageIndex = -1
self.imageCount = 1
self.imageData = b""
self.imageData = None
def clear(self) -> None:
self.reset_widget()
self.update_content()
def clear(self):
self.resetWidget()
self.updateContent()
def increment_image(self) -> None:
def incrementImage(self):
self.imageIndex += 1
if self.imageIndex == self.imageCount:
self.imageIndex = 0
self.update_content()
self.updateContent()
def decrement_image(self) -> None:
def decrementImage(self):
self.imageIndex -= 1
if self.imageIndex == -1:
self.imageIndex = self.imageCount - 1
self.update_content()
self.updateContent()
def set_archive(self, ca: ComicArchive, page: int = 0) -> None:
def setArchive(self, ca, page=0):
if self.mode == CoverImageWidget.ArchiveMode:
self.reset_widget()
self.resetWidget()
self.comic_archive = ca
self.imageIndex = page
self.imageCount = ca.get_number_of_pages()
self.update_content()
self.imageCount = ca.getNumberOfPages()
self.updateContent()
def set_url(self, url: str) -> None:
def setURL(self, url):
if self.mode == CoverImageWidget.URLMode:
self.reset_widget()
self.update_content()
self.resetWidget()
self.updateContent()
self.url_list = [url]
self.imageIndex = 0
self.imageCount = 1
self.update_content()
self.updateContent()
def set_issue_id(self, issue_id: int) -> None:
def setIssueID(self, issue_id):
if self.mode == CoverImageWidget.AltCoverMode:
self.reset_widget()
self.update_content()
self.resetWidget()
self.updateContent()
self.issue_id = issue_id
comic_vine = ComicVineTalker()
ComicVineTalker.url_fetch_complete = self.sig.emit_url
comic_vine.async_fetch_issue_cover_urls(self.issue_id)
self.comicVine = ComicVineTalker()
self.comicVine.urlFetchComplete.connect(self.primaryUrlFetchComplete)
self.comicVine.asyncFetchIssueCoverURLs(int(self.issue_id))
def set_image_data(self, image_data: bytes) -> None:
def setImageData(self, image_data):
if self.mode == CoverImageWidget.DataMode:
self.reset_widget()
self.resetWidget()
if image_data:
if image_data is None:
self.imageIndex = -1
else:
self.imageIndex = 0
self.imageData = image_data
else:
self.imageIndex = -1
self.update_content()
self.updateContent()
def primary_url_fetch_complete(self, primary_url: str, thumb_url: str | None = None) -> None:
def primaryUrlFetchComplete(self, primary_url, thumb_url, issue_id):
self.url_list.append(str(primary_url))
self.imageIndex = 0
self.imageCount = len(self.url_list)
self.update_content()
self.updateContent()
# defer the alt cover search
QtCore.QTimer.singleShot(1, self.start_alt_cover_search)
QTimer.singleShot(1, self.startAltCoverSearch)
def start_alt_cover_search(self) -> None:
def startAltCoverSearch(self):
if self.issue_id is not None:
# now we need to get the list of alt cover URLs
self.label.setText("Searching for alt. covers...")
# now we need to get the list of alt cover URLs
self.label.setText("Searching for alt. covers...")
# page URL should already be cached, so no need to defer
comic_vine = ComicVineTalker()
issue_page_url = comic_vine.fetch_issue_page_url(self.issue_id)
ComicVineTalker.alt_url_list_fetch_complete = self.sig.emit_list
comic_vine.async_fetch_alternate_cover_urls(utils.xlate(self.issue_id), cast(str, issue_page_url))
# page URL should already be cached, so no need to defer
self.comicVine = ComicVineTalker()
issue_page_url = self.comicVine.fetchIssuePageURL(self.issue_id)
self.comicVine.altUrlListFetchComplete.connect(self.altCoverUrlListFetchComplete)
self.comicVine.asyncFetchAlternateCoverURLs(int(self.issue_id), issue_page_url)
def alt_cover_url_list_fetch_complete(self, url_list: list[str]) -> None:
if url_list:
def altCoverUrlListFetchComplete(self, url_list, issue_id):
if len(url_list) > 0:
self.url_list.extend(url_list)
self.imageCount = len(self.url_list)
self.update_controls()
self.updateControls()
def set_page(self, pagenum: int) -> None:
def setPage(self, pagenum):
if self.mode == CoverImageWidget.ArchiveMode:
self.imageIndex = pagenum
self.update_content()
self.updateContent()
def update_content(self) -> None:
self.update_image()
self.update_controls()
def updateContent(self):
self.updateImage()
self.updateControls()
def update_image(self) -> None:
def updateImage(self):
if self.imageIndex == -1:
self.load_default()
self.loadDefault()
elif self.mode in [CoverImageWidget.AltCoverMode, CoverImageWidget.URLMode]:
self.load_url()
self.loadURL()
elif self.mode == CoverImageWidget.DataMode:
self.cover_remote_fetch_complete(self.imageData)
self.coverRemoteFetchComplete(self.imageData, 0)
else:
self.load_page()
self.loadPage()
def update_controls(self) -> None:
def updateControls(self):
if not self.showControls or self.mode == CoverImageWidget.DataMode:
self.btnLeft.hide()
self.btnRight.hide()
@ -261,47 +221,62 @@ class CoverImageWidget(QtWidgets.QWidget):
if self.imageIndex == -1 or self.imageCount == 1:
self.label.setText("")
elif self.mode == CoverImageWidget.AltCoverMode:
self.label.setText(f"Cover {self.imageIndex + 1} (of {self.imageCount})")
self.label.setText("Cover {0} (of {1})".format(self.imageIndex + 1, self.imageCount))
else:
self.label.setText(f"Page {self.imageIndex + 1} (of {self.imageCount})")
self.label.setText("Page {0} (of {1})".format(self.imageIndex + 1, self.imageCount))
def load_url(self) -> None:
self.load_default()
def loadURL(self):
self.loadDefault()
self.cover_fetcher = ImageFetcher()
ImageFetcher.image_fetch_complete = self.sig.emit_image
self.cover_fetcher.fetchComplete.connect(self.coverRemoteFetchComplete)
self.cover_fetcher.fetch(self.url_list[self.imageIndex])
# print("ATB cover fetch started...")
# called when the image is done loading from internet
def cover_remote_fetch_complete(self, image_data: bytes) -> None:
img = get_qimage_from_data(image_data)
self.current_pixmap = QtGui.QPixmap.fromImage(img)
self.set_display_pixmap()
def coverRemoteFetchComplete(self, image_data, issue_id):
img = getQImageFromData(image_data)
self.current_pixmap = QPixmap(img)
self.setDisplayPixmap(0, 0)
# print("ATB cover fetch complete!")
def load_page(self) -> None:
def loadPage(self):
if self.comic_archive is not None:
if self.page_loader is not None:
self.page_loader.abandoned = True
self.page_loader = PageLoader(self.comic_archive, self.imageIndex)
self.page_loader.loadComplete.connect(self.page_load_complete)
self.page_loader.loadComplete.connect(self.pageLoadComplete)
self.page_loader.start()
def page_load_complete(self, image_data: bytes) -> None:
img = get_qimage_from_data(image_data)
self.current_pixmap = QtGui.QPixmap.fromImage(img)
self.set_display_pixmap()
def pageLoadComplete(self, img):
self.current_pixmap = QPixmap(img)
self.setDisplayPixmap(0, 0)
self.page_loader = None
def load_default(self) -> None:
self.current_pixmap = QtGui.QPixmap(ComicTaggerSettings.get_graphic("nocover.png"))
self.set_display_pixmap()
def loadDefault(self):
self.current_pixmap = QPixmap(ComicTaggerSettings.getGraphic("nocover.png"))
# print("loadDefault called")
self.setDisplayPixmap(0, 0)
def resizeEvent(self, resize_event: QtGui.QResizeEvent) -> None:
def resizeEvent(self, resize_event):
if self.current_pixmap is not None:
self.set_display_pixmap()
delta_w = resize_event.size().width() - resize_event.oldSize().width()
delta_h = resize_event.size().height() - resize_event.oldSize().height()
# print "ATB resizeEvent deltas", resize_event.size().width(),
# resize_event.size().height()
self.setDisplayPixmap(delta_w, delta_h)
def set_display_pixmap(self) -> None:
def setDisplayPixmap(self, delta_w, delta_h):
"""The deltas let us know what the new width and height of the label will be"""
# new_h = self.frame.height() + delta_h
# new_w = self.frame.width() + delta_w
# print "ATB setDisplayPixmap deltas", delta_w , delta_h
# print "ATB self.frame", self.frame.width(), self.frame.height()
# print "ATB self.", self.width(), self.height()
# frame_w = new_w
# frame_h = new_h
new_h = self.frame.height()
new_w = self.frame.width()
frame_w = self.frame.width()
@ -310,20 +285,24 @@ class CoverImageWidget(QtWidgets.QWidget):
new_h -= 4
new_w -= 4
new_h = max(new_h, 0)
new_w = max(new_w, 0)
if new_h < 0:
new_h = 0
if new_w < 0:
new_w = 0
# print "ATB setDisplayPixmap deltas", delta_w , delta_h
# print "ATB self.frame", frame_w, frame_h
# print "ATB new size", new_w, new_h
# scale the pixmap to fit in the frame
scaled_pixmap = self.current_pixmap.scaled(
new_w, new_h, QtCore.Qt.AspectRatioMode.KeepAspectRatio, QtCore.Qt.SmoothTransformation
)
scaled_pixmap = self.current_pixmap.scaled(new_w, new_h, Qt.KeepAspectRatio, Qt.SmoothTransformation)
self.lblImage.setPixmap(scaled_pixmap)
# move and resize the label to be centered in the fame
img_w = scaled_pixmap.width()
img_h = scaled_pixmap.height()
self.lblImage.resize(img_w, img_h)
self.lblImage.move(int((frame_w - img_w) / 2), int((frame_h - img_h) / 2))
self.lblImage.move((frame_w - img_w) / 2, (frame_h - img_h) / 2)
def show_popup(self) -> None:
ImagePopup(self, self.current_pixmap)
def showPopup(self):
self.popup = ImagePopup(self, self.current_pixmap)

View File

@ -1,38 +1,33 @@
"""A PyQT4 dialog to edit credits"""
#
# Copyright 2012-2014 Anthony Beville
#
# 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
from typing import Any
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from PyQt5 import QtWidgets, uic
from comictaggerlib.settings import ComicTaggerSettings
logger = logging.getLogger(__name__)
from .settings import ComicTaggerSettings
class CreditEditorWindow(QtWidgets.QDialog):
ModeEdit = 0
ModeNew = 1
def __init__(self, parent: QtWidgets.QWidget, mode: int, role: str, name: str, primary: bool) -> None:
super().__init__(parent)
def __init__(self, parent, mode, role, name, primary):
super(CreditEditorWindow, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.get_ui_file("crediteditorwindow.ui"), self)
uic.loadUi(ComicTaggerSettings.getUIFile("crediteditorwindow.ui"), self)
self.mode = mode
@ -64,33 +59,34 @@ class CreditEditorWindow(QtWidgets.QDialog):
else:
self.cbRole.setCurrentIndex(i)
self.cbPrimary.setChecked(primary)
if primary:
self.cbPrimary.setCheckState(QtCore.Qt.Checked)
self.cbRole.currentIndexChanged.connect(self.role_changed)
self.cbRole.editTextChanged.connect(self.role_changed)
self.cbRole.currentIndexChanged.connect(self.roleChanged)
self.cbRole.editTextChanged.connect(self.roleChanged)
self.update_primary_button()
self.updatePrimaryButton()
def update_primary_button(self) -> None:
enabled = self.current_role_can_be_primary()
def updatePrimaryButton(self):
enabled = self.currentRoleCanBePrimary()
self.cbPrimary.setEnabled(enabled)
def current_role_can_be_primary(self) -> bool:
def currentRoleCanBePrimary(self):
role = self.cbRole.currentText()
if role.casefold() in ("artist", "writer"):
if str(role).lower() == "writer" or str(role).lower() == "artist":
return True
else:
return False
return False
def roleChanged(self, s):
self.updatePrimaryButton()
def role_changed(self, s: Any) -> None:
self.update_primary_button()
def get_credits(self) -> tuple[str, str, bool]:
primary = self.current_role_can_be_primary() and self.cbPrimary.isChecked()
def getCredits(self):
primary = self.currentRoleCanBePrimary() and self.cbPrimary.isChecked()
return self.cbRole.currentText(), self.leName.text(), primary
def accept(self) -> None:
def accept(self):
if self.cbRole.currentText() == "" or self.leName.text() == "":
QtWidgets.QMessageBox.warning(self, "Whoops", "You need to enter both role and name for a credit.")
QtWidgets.QMessageBox.warning(self, self.tr("Whoops"), self.tr("You need to enter both role and name for a credit."))
else:
QtWidgets.QDialog.accept(self)

View File

@ -1,27 +1,22 @@
"""A PyQT4 dialog to confirm and set options for export to zip"""
#
# Copyright 2012-2014 Anthony Beville
#
# 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
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from PyQt5 import QtCore, QtWidgets, uic
from comictaggerlib.settings import ComicTaggerSettings
logger = logging.getLogger(__name__)
from .settings import ComicTaggerSettings
class ExportConflictOpts:
@ -31,27 +26,25 @@ class ExportConflictOpts:
class ExportWindow(QtWidgets.QDialog):
def __init__(self, parent: QtWidgets.QWidget, settings: ComicTaggerSettings, msg: str) -> None:
super().__init__(parent)
def __init__(self, parent, settings, msg):
super(ExportWindow, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.get_ui_file("exportwindow.ui"), self)
uic.loadUi(ComicTaggerSettings.getUIFile("exportwindow.ui"), self)
self.label.setText(msg)
self.setWindowFlags(
QtCore.Qt.WindowType(self.windowFlags() & ~QtCore.Qt.WindowType.WindowContextHelpButtonHint)
)
self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint)
self.settings = settings
self.cbxDeleteOriginal.setChecked(False)
self.cbxAddToList.setChecked(True)
self.cbxDeleteOriginal.setCheckState(QtCore.Qt.Unchecked)
self.cbxAddToList.setCheckState(QtCore.Qt.Checked)
self.radioDontCreate.setChecked(True)
self.deleteOriginal = False
self.addToList = True
self.fileConflictBehavior = ExportConflictOpts.dontCreate
def accept(self) -> None:
def accept(self):
QtWidgets.QDialog.accept(self)
self.deleteOriginal = self.cbxDeleteOriginal.isChecked()
@ -60,3 +53,5 @@ class ExportWindow(QtWidgets.QDialog):
self.fileConflictBehavior = ExportConflictOpts.dontCreate
elif self.radioCreateNew.isChecked():
self.fileConflictBehavior = ExportConflictOpts.createUnique
# else:
# self.fileConflictBehavior = ExportConflictOpts.overwrite

View File

@ -0,0 +1 @@
from comicapi.filenameparser import *

View File

@ -1,66 +1,41 @@
"""Functions for renaming files based on metadata"""
#
# Copyright 2012-2014 Anthony Beville
#
# 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 calendar
import logging
import os
import pathlib
import re
import string
import sys
from typing import Any, cast
from pathvalidate import sanitize_filename
from pathvalidate import sanitize_filepath
from comicapi.comicarchive import ComicArchive
from comicapi.genericmetadata import GenericMetadata
from comicapi.issuestring import IssueString
logger = logging.getLogger(__name__)
def get_rename_dir(ca: ComicArchive, rename_dir: str | pathlib.Path | None) -> pathlib.Path:
folder = ca.path.parent.absolute()
if rename_dir is not None:
if isinstance(rename_dir, str):
rename_dir = rename_dir.strip()
folder = pathlib.Path(rename_dir).absolute()
return folder
from . import utils
from .issuestring import IssueString
class MetadataFormatter(string.Formatter):
def __init__(self, smart_cleanup: bool = False, platform: str = "auto") -> None:
def __init__(self, smart_cleanup=False):
super().__init__()
self.smart_cleanup = smart_cleanup
self.platform = platform.casefold()
def format_field(self, value: Any, format_spec: str) -> str:
def format_field(self, value, format_spec):
if value is None or value == "":
return ""
return cast(str, super().format_field(value, format_spec))
return super().format_field(value, format_spec)
def _vformat(
self,
format_string: str,
args: list[Any],
kwargs: dict[str, Any],
used_args: set[Any],
recursion_depth: int,
auto_arg_index: int = 0,
) -> tuple[str, int]:
def _vformat(self, format_string, args, kwargs, used_args, recursion_depth, auto_arg_index=0):
if recursion_depth < 0:
raise ValueError("Max string recursion exceeded")
result = []
@ -70,33 +45,27 @@ class MetadataFormatter(string.Formatter):
# output the literal text
if literal_text:
if lstrip:
literal_text = literal_text.lstrip("-_)}]#")
if self.smart_cleanup:
if self.platform in ["universal", "windows"] or sys.platform.casefold() in ["windows"]:
# colons get special treatment
literal_text = literal_text.replace(": ", " - ")
literal_text = literal_text.replace(":", "-")
lspace = literal_text[0].isspace() if literal_text else False
rspace = literal_text[-1].isspace() if literal_text else False
literal_text = " ".join(literal_text.split())
if literal_text == "":
literal_text = " "
else:
if lspace:
literal_text = " " + literal_text
if rspace:
literal_text += " "
result.append(literal_text)
result.append(literal_text.lstrip("-_)}]#"))
else:
result.append(literal_text)
lstrip = False
# if there's a field, output it
if field_name is not None and field_name != "":
field_name = field_name.casefold()
# this is some markup, find the object and do the formatting
if field_name is not None:
# this is some markup, find the object and do
# the formatting
# handle arg indexing when digit field_names are given.
if field_name.isdigit():
raise ValueError("cannot use a number as a field name")
# handle arg indexing when empty field_names are given.
if field_name == "":
if auto_arg_index is False:
raise ValueError("cannot switch from manual field specification to automatic field numbering")
field_name = str(auto_arg_index)
auto_arg_index += 1
elif field_name.isdigit():
if auto_arg_index:
raise ValueError("cannot switch from manual field specification to automatic field numbering")
# disable auto arg incrementing, if it gets
# used later on, then an exception will be raised
auto_arg_index = False
# given the field_name, find the object it references
# and the argument it came from
@ -104,95 +73,72 @@ class MetadataFormatter(string.Formatter):
used_args.add(arg_used)
# do any conversion on the resulting object
obj = self.convert_field(obj, conversion) # type: ignore
obj = self.convert_field(obj, conversion)
# expand the format spec, if needed
format_spec, _ = self._vformat(
cast(str, format_spec), args, kwargs, used_args, recursion_depth - 1, auto_arg_index=False
)
format_spec, auto_arg_index = self._vformat(format_spec, args, kwargs, used_args, recursion_depth - 1, auto_arg_index=auto_arg_index)
# format the object and append to the result
fmt_obj = self.format_field(obj, format_spec)
if fmt_obj == "" and result and self.smart_cleanup and literal_text:
if self.str_contains(result[-1], "({["):
lstrip = True
if result:
if " " in result[-1]:
result[-1], _, _ = result[-1].rstrip().rpartition(" ")
result[-1] = result[-1].rstrip("-_({[#")
if self.smart_cleanup:
fmt_obj = " ".join(fmt_obj.split())
fmt_obj = str(sanitize_filename(fmt_obj, platform=self.platform))
result.append(fmt_obj)
fmtObj = self.format_field(obj, format_spec)
if fmtObj == "" and len(result) > 0 and self.smart_cleanup:
lstrip = True
result.pop()
result.append(fmtObj)
return "".join(result), False
def str_contains(self, chars: str, string: str) -> bool:
for char in chars:
if char in string:
return True
return False
return "".join(result), auto_arg_index
class FileRenamer:
def __init__(self, metadata: GenericMetadata | None, platform: str = "auto") -> None:
self.template = "{publisher}/{series}/{series} v{volume} #{issue} (of {issue_count}) ({year})"
def __init__(self, metadata):
self.setMetadata(metadata)
self.setTemplate("{publisher}/{series}/{series} v{volume} #{issue} (of {issueCount}) ({year})")
self.smart_cleanup = True
self.issue_zero_padding = 3
self.metadata = metadata or GenericMetadata()
self.move = False
self.platform = platform
def set_metadata(self, metadata: GenericMetadata) -> None:
self.metadata = metadata
def setMetadata(self, metadata):
self.metdata = metadata
def set_issue_zero_padding(self, count: int) -> None:
def setIssueZeroPadding(self, count):
self.issue_zero_padding = count
def set_smart_cleanup(self, on: bool) -> None:
def setSmartCleanup(self, on):
self.smart_cleanup = on
def set_template(self, template: str) -> None:
def setTemplate(self, template):
self.template = template
def determine_name(self, ext: str) -> str:
def determineName(self, filename, ext=None):
class Default(dict):
def __missing__(self, key: str) -> str:
def __missing__(self, key):
return "{" + key + "}"
md = self.metadata
md = self.metdata
# padding for issue
md.issue = IssueString(md.issue).as_string(pad=self.issue_zero_padding)
md.issue = IssueString(md.issue).asString(pad=self.issue_zero_padding)
template = self.template
pathComponents = template.split(os.sep)
new_name = ""
fmt = MetadataFormatter(self.smart_cleanup, platform=self.platform)
md_dict = vars(md)
for role in ["writer", "penciller", "inker", "colorist", "letterer", "cover artist", "editor"]:
md_dict[role] = md.get_primary_credit(role)
fmt = MetadataFormatter(self.smart_cleanup)
for Component in pathComponents:
new_name = os.path.join(new_name, fmt.vformat(Component, args=[], kwargs=Default(vars(md))).replace("/", "-"))
if (isinstance(md.month, int) or isinstance(md.month, str) and md.month.isdigit()) and 0 < int(md.month) < 13:
md_dict["month_name"] = calendar.month_name[int(md.month)]
md_dict["month_abbr"] = calendar.month_abbr[int(md.month)]
else:
md_dict["month_name"] = ""
md_dict["month_abbr"] = ""
new_basename = ""
for component in pathlib.PureWindowsPath(template).parts:
new_basename = str(
sanitize_filename(fmt.vformat(component, args=[], kwargs=Default(md_dict)), platform=self.platform)
).strip()
new_name = os.path.join(new_name, new_basename)
if ext is None or ext == "":
ext = os.path.splitext(filename)[1]
new_name += ext
new_basename += ext
# some tweaks to keep various filesystems happy
new_name = new_name.replace(": ", " - ")
new_name = new_name.replace(":", "-")
# remove padding
md.issue = IssueString(md.issue).as_string()
md.issue = IssueString(md.issue).asString()
if self.move:
return new_name.strip()
return new_basename.strip()
return sanitize_filepath(new_name.strip())
else:
return os.path.basename(sanitize_filepath(new_name.strip()))

View File

@ -1,50 +1,54 @@
# coding=utf-8
"""A PyQt5 widget for managing list of comic archive files"""
#
# Copyright 2012-2014 Anthony Beville
#
# 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 platform
from typing import Callable, cast
import sys
from PyQt5 import QtCore, QtWidgets, uic
from PyQt5 import uic
from PyQt5.QtCore import *
from PyQt5.QtCore import pyqtSignal
from PyQt5.QtGui import *
from PyQt5.QtWidgets import *
from comicapi import utils
from comicapi.comicarchive import ComicArchive
from comictaggerlib.optionalmsgdialog import OptionalMessageDialog
from comictaggerlib.settings import ComicTaggerSettings
from comictaggerlib.settingswindow import linuxRarHelp, macRarHelp, windowsRarHelp
from comictaggerlib.ui.qtutils import center_window_on_parent, reduce_widget_font_size
from comictaggerlib.ui.qtutils import centerWindowOnParent, reduceWidgetFontSize
logger = logging.getLogger(__name__)
from . import utils
from .comicarchive import ComicArchive
from .optionalmsgdialog import OptionalMessageDialog
from .settings import ComicTaggerSettings
class FileTableWidgetItem(QtWidgets.QTableWidgetItem):
def __lt__(self, other: object) -> bool:
return self.data(QtCore.Qt.ItemDataRole.UserRole) < other.data(QtCore.Qt.ItemDataRole.UserRole) # type: ignore
class FileTableWidgetItem(QTableWidgetItem):
def __lt__(self, other):
# return (self.data(Qt.UserRole).toBool() <
# other.data(Qt.UserRole).toBool())
return self.data(Qt.UserRole) < other.data(Qt.UserRole)
class FileInfo:
def __init__(self, ca: ComicArchive) -> None:
self.ca: ComicArchive = ca
def __init__(self, ca):
self.ca = ca
class FileSelectionList(QtWidgets.QWidget):
selectionChanged = QtCore.pyqtSignal(QtCore.QVariant)
listCleared = QtCore.pyqtSignal()
class FileSelectionList(QWidget):
selectionChanged = pyqtSignal(QVariant)
listCleared = pyqtSignal()
fileColNum = 0
CRFlagColNum = 1
@ -54,97 +58,77 @@ class FileSelectionList(QtWidgets.QWidget):
folderColNum = 5
dataColNum = fileColNum
def __init__(
self,
parent: QtWidgets.QWidget,
settings: ComicTaggerSettings,
dirty_flag_verification: Callable[[str, str], bool],
) -> None:
super().__init__(parent)
def __init__(self, parent, settings):
super(FileSelectionList, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.get_ui_file("fileselectionlist.ui"), self)
uic.loadUi(ComicTaggerSettings.getUIFile("fileselectionlist.ui"), self)
self.settings = settings
reduce_widget_font_size(self.twList)
reduceWidgetFontSize(self.twList)
self.twList.setColumnCount(6)
self.twList.currentItemChanged.connect(self.current_item_changed_cb)
# self.twlist.setHorizontalHeaderLabels (["File", "Folder", "CR", "CBL", ""])
# self.twList.horizontalHeader().setStretchLastSection(True)
self.twList.currentItemChanged.connect(self.currentItemChangedCB)
self.currentItem = None
self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.ActionsContextMenu)
self.dirty_flag = False
self.setContextMenuPolicy(Qt.ActionsContextMenu)
self.modifiedFlag = False
select_all_action = QtWidgets.QAction("Select All", self)
remove_action = QtWidgets.QAction("Remove Selected Items", self)
self.separator = QtWidgets.QAction("", self)
selectAllAction = QAction("Select All", self)
removeAction = QAction("Remove Selected Items", self)
self.separator = QAction("", self)
self.separator.setSeparator(True)
select_all_action.setShortcut("Ctrl+A")
remove_action.setShortcut("Ctrl+X")
selectAllAction.setShortcut("Ctrl+A")
removeAction.setShortcut("Ctrl+X")
select_all_action.triggered.connect(self.select_all)
remove_action.triggered.connect(self.remove_selection)
selectAllAction.triggered.connect(self.selectAll)
removeAction.triggered.connect(self.removeSelection)
self.addAction(select_all_action)
self.addAction(remove_action)
self.addAction(selectAllAction)
self.addAction(removeAction)
self.addAction(self.separator)
self.dirty_flag_verification = dirty_flag_verification
self.rar_ro_shown = False
def get_sorting(self) -> tuple[int, int]:
def getSorting(self):
col = self.twList.horizontalHeader().sortIndicatorSection()
order = self.twList.horizontalHeader().sortIndicatorOrder()
return int(col), int(order)
return col, order
def set_sorting(self, col: int, order: QtCore.Qt.SortOrder) -> None:
self.twList.horizontalHeader().setSortIndicator(col, order)
def setSorting(self, col, order):
col = self.twList.horizontalHeader().setSortIndicator(col, order)
def add_app_action(self, action: QtWidgets.QAction) -> None:
self.insertAction(QtWidgets.QAction(), action)
def addAppAction(self, action):
self.insertAction(None, action)
def set_modified_flag(self, modified: bool) -> None:
self.dirty_flag = modified
def setModifiedFlag(self, modified):
self.modifiedFlag = modified
def select_all(self) -> None:
self.twList.setRangeSelected(QtWidgets.QTableWidgetSelectionRange(0, 0, self.twList.rowCount() - 1, 5), True)
def selectAll(self):
self.twList.setRangeSelected(QTableWidgetSelectionRange(0, 0, self.twList.rowCount() - 1, 5), True)
def deselect_all(self) -> None:
self.twList.setRangeSelected(QtWidgets.QTableWidgetSelectionRange(0, 0, self.twList.rowCount() - 1, 5), False)
def deselectAll(self):
self.twList.setRangeSelected(QTableWidgetSelectionRange(0, 0, self.twList.rowCount() - 1, 5), False)
def remove_archive_list(self, ca_list: list[ComicArchive]) -> None:
def removeArchiveList(self, ca_list):
self.twList.setSortingEnabled(False)
current_removed = False
for ca in ca_list:
for row in range(self.twList.rowCount()):
row_ca = self.get_archive_by_row(row)
row_ca = self.getArchiveByRow(row)
if row_ca == ca:
if row == self.twList.currentRow():
current_removed = True
self.twList.removeRow(row)
break
self.twList.setSortingEnabled(True)
if self.twList.rowCount() > 0 and current_removed:
# since on a removal, we select row 0, make sure callback occurs if
# we're already there
if self.twList.currentRow() == 0:
self.current_item_changed_cb(self.twList.currentItem(), None)
self.twList.selectRow(0)
elif self.twList.rowCount() <= 0:
self.listCleared.emit()
def getArchiveByRow(self, row):
fi = self.twList.item(row, FileSelectionList.dataColNum).data(Qt.UserRole)
return fi.ca
def get_archive_by_row(self, row: int) -> ComicArchive | None:
if row >= 0:
fi: FileInfo = self.twList.item(row, FileSelectionList.dataColNum).data(QtCore.Qt.ItemDataRole.UserRole)
return fi.ca
return None
def getCurrentArchive(self):
return self.getArchiveByRow(self.twList.currentRow())
def get_current_archive(self) -> ComicArchive | None:
return self.get_archive_by_row(self.twList.currentRow())
def remove_selection(self) -> None:
def removeSelection(self):
row_list = []
for item in self.twList.selectedItems():
if item.column() == 0:
@ -154,79 +138,95 @@ class FileSelectionList(QtWidgets.QWidget):
return
if self.twList.currentRow() in row_list:
if not self.dirty_flag_verification(
"Remove Archive", "If you close this archive, data in the form will be lost. Are you sure?"
):
if not self.modifiedFlagVerification("Remove Archive", "If you close this archive, data in the form will be lost. Are you sure?"):
return
row_list.sort()
row_list.reverse()
self.twList.currentItemChanged.disconnect(self.current_item_changed_cb)
self.twList.currentItemChanged.disconnect(self.currentItemChangedCB)
self.twList.setSortingEnabled(False)
for i in row_list:
self.twList.removeRow(i)
self.twList.setSortingEnabled(True)
self.twList.currentItemChanged.connect(self.current_item_changed_cb)
self.twList.currentItemChanged.connect(self.currentItemChangedCB)
if self.twList.rowCount() > 0:
# since on a removal, we select row 0, make sure callback occurs if
# we're already there
if self.twList.currentRow() == 0:
self.current_item_changed_cb(self.twList.currentItem(), None)
self.currentItemChangedCB(self.twList.currentItem(), None)
self.twList.selectRow(0)
else:
self.listCleared.emit()
def add_path_list(self, pathlist: list[str]) -> None:
def addPathList(self, pathlist):
filelist = utils.get_recursive_filelist(pathlist)
# we now have a list of files to add
# Prog dialog on Linux flakes out for small range, so scale up
progdialog = QtWidgets.QProgressDialog("", "Cancel", 0, len(filelist), parent=self)
progdialog = QProgressDialog("", "Cancel", 0, len(filelist), parent=self)
progdialog.setWindowTitle("Adding Files")
progdialog.setWindowModality(QtCore.Qt.WindowModality.ApplicationModal)
progdialog.setWindowModality(Qt.ApplicationModal)
progdialog.setMinimumDuration(300)
center_window_on_parent(progdialog)
centerWindowOnParent(progdialog)
# QCoreApplication.processEvents()
# progdialog.show()
QtCore.QCoreApplication.processEvents()
first_added = None
rar_added = False
QCoreApplication.processEvents()
firstAdded = None
self.twList.setSortingEnabled(False)
for idx, f in enumerate(filelist):
QtCore.QCoreApplication.processEvents()
QCoreApplication.processEvents()
if progdialog.wasCanceled():
break
progdialog.setValue(idx + 1)
progdialog.setLabelText(f)
center_window_on_parent(progdialog)
QtCore.QCoreApplication.processEvents()
row = self.add_path_item(f)
if row is not None:
ca = self.get_archive_by_row(row)
if ca and ca.is_rar():
rar_added = True
if first_added is None:
first_added = row
centerWindowOnParent(progdialog)
QCoreApplication.processEvents()
row = self.addPathItem(f)
if firstAdded is None and row is not None:
firstAdded = row
progdialog.hide()
QtCore.QCoreApplication.processEvents()
QCoreApplication.processEvents()
if first_added is not None:
self.twList.selectRow(first_added)
if self.settings.show_no_unrar_warning and self.settings.unrar_lib_path == "" and not ComicTaggerSettings.haveOwnUnrarLib():
for f in filelist:
ext = os.path.splitext(f)[1].lower()
if ext == ".rar" or ext == ".cbr":
checked = OptionalMessageDialog.msg(
self,
"No UnRAR Ability",
"""
It looks like you've tried to open at least one CBR or RAR file.<br><br>
In order for ComicTagger to read this kind of file, you will have to configure
the location of the unrar library in the settings. Until then, ComicTagger
will not be able read these kind of files. See the "RAR Tools" tab in the
settings/preferences for more info.
""",
)
self.settings.show_no_unrar_warning = not checked
break
if firstAdded is not None:
self.twList.selectRow(firstAdded)
else:
if len(pathlist) == 1 and os.path.isfile(pathlist[0]):
QtWidgets.QMessageBox.information(
self, "File Open", "Selected file doesn't seem to be a comic archive."
)
ext = os.path.splitext(pathlist[0])[1].lower()
if ext == ".rar" or ext == ".cbr" and self.settings.unrar_lib_path == "":
QMessageBox.information(
self,
self.tr("File Open"),
self.tr("Selected file seems to be a rar file, " "and can't be read until the unrar library is configured."),
)
else:
QMessageBox.information(self, self.tr("File Open"), self.tr("Selected file doesn't seem to be a comic archive."))
else:
QtWidgets.QMessageBox.information(self, "File/Folder Open", "No readable comic archives were found.")
if rar_added and not utils.which(self.settings.rar_exe_path or "rar"):
self.rar_ro_message()
QMessageBox.information(self, self.tr("File/Folder Open"), self.tr("No readable comic archives were found."))
self.twList.setSortingEnabled(True)
@ -241,189 +241,194 @@ class FileSelectionList(QtWidgets.QWidget):
if self.twList.columnWidth(FileSelectionList.folderColNum) > 200:
self.twList.setColumnWidth(FileSelectionList.folderColNum, 200)
def rar_ro_message(self) -> None:
if not self.rar_ro_shown:
if platform.system() == "Windows":
rar_help = windowsRarHelp
def isListDupe(self, path):
r = 0
while r < self.twList.rowCount():
ca = self.getArchiveByRow(r)
if ca.path == path:
return True
r = r + 1
elif platform.system() == "Darwin":
rar_help = macRarHelp
return False
else:
rar_help = linuxRarHelp
OptionalMessageDialog.msg_no_checkbox(
self,
"RAR Files are Read-Only",
"It looks like you have opened a RAR/CBR archive,\n"
"however ComicTagger cannot currently write to them without the rar program and are marked read only!\n\n"
f"{rar_help}",
)
self.rar_ro_shown = True
def is_list_dupe(self, path: str) -> bool:
return self.get_current_list_row(path) >= 0
def get_current_list_row(self, path: str) -> int:
for r in range(self.twList.rowCount()):
ca = cast(ComicArchive, self.get_archive_by_row(r))
if str(ca.path) == path:
def getCurrentListRow(self, path):
r = 0
while r < self.twList.rowCount():
ca = self.getArchiveByRow(r)
if ca.path == path:
return r
r = r + 1
return -1
def add_path_item(self, path: str) -> int:
def addPathItem(self, path):
path = str(path)
path = os.path.abspath(path)
# print "processing", path
if self.is_list_dupe(path):
return self.get_current_list_row(path)
if self.isListDupe(path):
return self.getCurrentListRow(path)
ca = ComicArchive(path, self.settings.rar_exe_path, ComicTaggerSettings.get_graphic("nocover.png"))
ca = ComicArchive(path, self.settings.rar_exe_path, ComicTaggerSettings.getGraphic("nocover.png"))
if ca.seems_to_be_a_comic_archive():
row: int = self.twList.rowCount()
if ca.seemsToBeAComicArchive():
row = self.twList.rowCount()
self.twList.insertRow(row)
fi = FileInfo(ca)
filename_item = QtWidgets.QTableWidgetItem()
folder_item = QtWidgets.QTableWidgetItem()
filename_item = QTableWidgetItem()
folder_item = QTableWidgetItem()
cix_item = FileTableWidgetItem()
cbi_item = FileTableWidgetItem()
readonly_item = FileTableWidgetItem()
type_item = QtWidgets.QTableWidgetItem()
type_item = QTableWidgetItem()
filename_item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
filename_item.setData(QtCore.Qt.ItemDataRole.UserRole, fi)
filename_item.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled)
filename_item.setData(Qt.UserRole, fi)
self.twList.setItem(row, FileSelectionList.fileColNum, filename_item)
folder_item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
folder_item.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled)
self.twList.setItem(row, FileSelectionList.folderColNum, folder_item)
type_item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
type_item.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled)
self.twList.setItem(row, FileSelectionList.typeColNum, type_item)
cix_item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
cix_item.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignHCenter)
cix_item.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled)
cix_item.setTextAlignment(Qt.AlignHCenter)
self.twList.setItem(row, FileSelectionList.CRFlagColNum, cix_item)
cbi_item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
cbi_item.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignHCenter)
cbi_item.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled)
cbi_item.setTextAlignment(Qt.AlignHCenter)
self.twList.setItem(row, FileSelectionList.CBLFlagColNum, cbi_item)
readonly_item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
readonly_item.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignHCenter)
readonly_item.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled)
readonly_item.setTextAlignment(Qt.AlignHCenter)
self.twList.setItem(row, FileSelectionList.readonlyColNum, readonly_item)
self.update_row(row)
self.updateRow(row)
return row
return -1
def update_row(self, row: int) -> None:
if row >= 0:
fi: FileInfo = self.twList.item(row, FileSelectionList.dataColNum).data(QtCore.Qt.ItemDataRole.UserRole)
def updateRow(self, row):
fi = self.twList.item(row, FileSelectionList.dataColNum).data(Qt.UserRole) # .toPyObject()
filename_item = self.twList.item(row, FileSelectionList.fileColNum)
folder_item = self.twList.item(row, FileSelectionList.folderColNum)
cix_item = self.twList.item(row, FileSelectionList.CRFlagColNum)
cbi_item = self.twList.item(row, FileSelectionList.CBLFlagColNum)
type_item = self.twList.item(row, FileSelectionList.typeColNum)
readonly_item = self.twList.item(row, FileSelectionList.readonlyColNum)
filename_item = self.twList.item(row, FileSelectionList.fileColNum)
folder_item = self.twList.item(row, FileSelectionList.folderColNum)
cix_item = self.twList.item(row, FileSelectionList.CRFlagColNum)
cbi_item = self.twList.item(row, FileSelectionList.CBLFlagColNum)
type_item = self.twList.item(row, FileSelectionList.typeColNum)
readonly_item = self.twList.item(row, FileSelectionList.readonlyColNum)
item_text = os.path.split(fi.ca.path)[0]
folder_item.setText(item_text)
folder_item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
item_text = os.path.split(fi.ca.path)[0]
folder_item.setText(item_text)
folder_item.setData(Qt.ToolTipRole, item_text)
item_text = os.path.split(fi.ca.path)[1]
filename_item.setText(item_text)
filename_item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
item_text = os.path.split(fi.ca.path)[1]
filename_item.setText(item_text)
filename_item.setData(Qt.ToolTipRole, item_text)
if fi.ca.is_sevenzip():
item_text = "7Z"
elif fi.ca.is_zip():
item_text = "ZIP"
elif fi.ca.is_rar():
item_text = "RAR"
else:
item_text = ""
type_item.setText(item_text)
type_item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
if fi.ca.isZip():
item_text = "ZIP"
elif fi.ca.isRar():
item_text = "RAR"
elif fi.ca.isTar():
item_text = "TAR"
else:
item_text = ""
type_item.setText(item_text)
type_item.setData(Qt.ToolTipRole, item_text)
if fi.ca.has_cix():
cix_item.setCheckState(QtCore.Qt.CheckState.Checked)
cix_item.setData(QtCore.Qt.ItemDataRole.UserRole, True)
else:
cix_item.setData(QtCore.Qt.ItemDataRole.UserRole, False)
cix_item.setCheckState(QtCore.Qt.CheckState.Unchecked)
if fi.ca.hasCIX():
cix_item.setCheckState(Qt.Checked)
cix_item.setData(Qt.UserRole, True)
else:
cix_item.setData(Qt.UserRole, False)
cix_item.setCheckState(Qt.Unchecked)
if fi.ca.has_cbi():
cbi_item.setCheckState(QtCore.Qt.CheckState.Checked)
cbi_item.setData(QtCore.Qt.ItemDataRole.UserRole, True)
else:
cbi_item.setData(QtCore.Qt.ItemDataRole.UserRole, False)
cbi_item.setCheckState(QtCore.Qt.CheckState.Unchecked)
if fi.ca.hasCBI():
cbi_item.setCheckState(Qt.Checked)
cbi_item.setData(Qt.UserRole, True)
else:
cbi_item.setData(Qt.UserRole, False)
cbi_item.setCheckState(Qt.Unchecked)
if not fi.ca.is_writable():
readonly_item.setCheckState(QtCore.Qt.CheckState.Checked)
readonly_item.setData(QtCore.Qt.ItemDataRole.UserRole, True)
else:
readonly_item.setData(QtCore.Qt.ItemDataRole.UserRole, False)
readonly_item.setCheckState(QtCore.Qt.CheckState.Unchecked)
if not fi.ca.isWritable():
readonly_item.setCheckState(Qt.Checked)
readonly_item.setData(Qt.UserRole, True)
else:
readonly_item.setData(Qt.UserRole, False)
readonly_item.setCheckState(Qt.Unchecked)
# Reading these will force them into the ComicArchive's cache
try:
fi.ca.read_cix()
except Exception:
pass
fi.ca.has_cbi()
# Reading these will force them into the ComicArchive's cache
fi.ca.readCIX()
fi.ca.hasCBI()
def get_selected_archive_list(self) -> list[ComicArchive]:
ca_list: list[ComicArchive] = []
def getSelectedArchiveList(self):
ca_list = []
for r in range(self.twList.rowCount()):
item = self.twList.item(r, FileSelectionList.dataColNum)
if item.isSelected():
fi: FileInfo = item.data(QtCore.Qt.ItemDataRole.UserRole)
fi = item.data(Qt.UserRole)
ca_list.append(fi.ca)
return ca_list
def update_current_row(self) -> None:
self.update_row(self.twList.currentRow())
def updateCurrentRow(self):
self.updateRow(self.twList.currentRow())
def update_selected_rows(self) -> None:
def updateSelectedRows(self):
self.twList.setSortingEnabled(False)
for r in range(self.twList.rowCount()):
item = self.twList.item(r, FileSelectionList.dataColNum)
if item.isSelected():
self.update_row(r)
self.updateRow(r)
self.twList.setSortingEnabled(True)
def current_item_changed_cb(self, curr: QtCore.QModelIndex | None, prev: QtCore.QModelIndex | None) -> None:
if curr is not None:
new_idx = curr.row()
old_idx = -1
if prev is not None:
old_idx = prev.row()
def currentItemChangedCB(self, curr, prev):
if old_idx == new_idx:
new_idx = curr.row()
old_idx = -1
if prev is not None:
old_idx = prev.row()
# print("old {0} new {1}".format(old_idx, new_idx))
if old_idx == new_idx:
return
# don't allow change if modified
if prev is not None and new_idx != old_idx:
if not self.modifiedFlagVerification("Change Archive", "If you change archives now, data in the form will be lost. Are you sure?"):
self.twList.currentItemChanged.disconnect(self.currentItemChangedCB)
self.twList.setCurrentItem(prev)
self.twList.currentItemChanged.connect(self.currentItemChangedCB)
# Need to defer this revert selection, for some reason
QTimer.singleShot(1, self.revertSelection)
return
# don't allow change if modified
if prev is not None and new_idx != old_idx:
if not self.dirty_flag_verification(
"Change Archive", "If you change archives now, data in the form will be lost. Are you sure?"
):
self.twList.currentItemChanged.disconnect(self.current_item_changed_cb)
self.twList.setCurrentItem(prev)
self.twList.currentItemChanged.connect(self.current_item_changed_cb)
# Need to defer this revert selection, for some reason
QtCore.QTimer.singleShot(1, self.revert_selection)
return
fi = self.twList.item(new_idx, FileSelectionList.dataColNum).data(Qt.UserRole) # .toPyObject()
self.selectionChanged.emit(QVariant(fi))
fi = self.twList.item(new_idx, FileSelectionList.dataColNum).data(QtCore.Qt.ItemDataRole.UserRole)
self.selectionChanged.emit(QtCore.QVariant(fi))
def revert_selection(self) -> None:
def revertSelection(self):
self.twList.selectRow(self.twList.currentRow())
def modifiedFlagVerification(self, title, desc):
if self.modifiedFlag:
reply = QMessageBox.question(self, self.tr(title), self.tr(desc), QMessageBox.Yes, QMessageBox.No)
if reply != QMessageBox.Yes:
return False
return True
# Attempt to use a special checkbox widget in the cell.
# Couldn't figure out how to disable it with "enabled" colors
# w = QWidget()
# cb = QCheckBox(w)
# cb.setCheckState(Qt.Checked)
# layout = QHBoxLayout()
# layout.addWidget(cb)
# layout.setAlignment(Qt.AlignHCenter)
# layout.setMargin(2)
# w.setLayout(layout)
# self.twList.setCellWidget(row, 2, w)

View File

@ -0,0 +1 @@
from comicapi.genericmetadata import *

View File

@ -1,22 +1,20 @@
"""A class to manage fetching and caching of images by URL"""
#
# Copyright 2012-2014 Anthony Beville
#
# 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 datetime
import logging
import os
import shutil
import sqlite3 as lite
@ -24,52 +22,54 @@ import tempfile
import requests
from comictaggerlib import ctversion
from comictaggerlib.settings import ComicTaggerSettings
from . import _version
from .settings import ComicTaggerSettings
try:
from PyQt5 import QtCore, QtNetwork
qt_available = True
from PyQt5 import QtGui
from PyQt5.QtCore import QByteArray, QObject, QUrl, pyqtSignal
from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest
except ImportError:
qt_available = False
# No Qt, so define a few dummy QObjects to help us compile
class QObject:
def __init__(self, *args):
pass
logger = logging.getLogger(__name__)
class QByteArray:
pass
class pyqtSignal:
def __init__(self, *args):
pass
def emit(a, b, c):
pass
class ImageFetcherException(Exception):
...
pass
def fetch_complete(image_data: bytes | QtCore.QByteArray) -> None:
...
class ImageFetcher(QObject):
fetchComplete = pyqtSignal(QByteArray, int)
class ImageFetcher:
def __init__(self):
QObject.__init__(self)
image_fetch_complete = fetch_complete
def __init__(self) -> None:
self.settings_folder = ComicTaggerSettings.get_settings_folder()
self.settings_folder = ComicTaggerSettings.getSettingsFolder()
self.db_file = os.path.join(self.settings_folder, "image_url_cache.db")
self.cache_folder = os.path.join(self.settings_folder, "image_cache")
self.user_data = None
self.fetched_url = ""
if not os.path.exists(self.db_file):
self.create_image_db()
if qt_available:
self.nam = QtNetwork.QNetworkAccessManager()
def clear_cache(self) -> None:
def clearCache(self):
os.unlink(self.db_file)
if os.path.isdir(self.cache_folder):
shutil.rmtree(self.cache_folder)
def fetch(self, url: str, blocking: bool = False) -> bytes:
def fetch(self, url, user_data=None, blocking=False):
"""
If called with blocking=True, this will block until the image is
fetched.
@ -77,49 +77,52 @@ class ImageFetcher:
background, and emit a signal when done
"""
self.user_data = user_data
self.fetched_url = url
# first look in the DB
image_data = self.get_image_from_cache(url)
if blocking or not qt_available:
if not image_data:
if blocking:
if image_data is None:
try:
image_data = requests.get(url, headers={"user-agent": "comictagger/" + ctversion.version}).content
print(url)
image_data = requests.get(url, headers={"user-agent": "comictagger/" + _version.version}).content
except Exception as e:
logger.exception("Fetching url failed: %s")
raise ImageFetcherException("Network Error!") from e
print(e)
raise ImageFetcherException("Network Error!")
# save the image to the cache
self.add_image_to_cache(self.fetched_url, image_data)
return image_data
if qt_available:
else:
# if we found it, just emit the signal asap
if image_data:
ImageFetcher.image_fetch_complete(QtCore.QByteArray(image_data))
return b""
if image_data is not None:
self.fetchComplete.emit(QByteArray(image_data), self.user_data)
return
# didn't find it. look online
self.nam.finished.connect(self.finish_request)
self.nam.get(QtNetwork.QNetworkRequest(QtCore.QUrl(url)))
self.nam = QNetworkAccessManager()
self.nam.finished.connect(self.finishRequest)
self.nam.get(QNetworkRequest(QUrl(url)))
# we'll get called back when done...
return b""
def finish_request(self, reply: QtNetwork.QNetworkReply) -> None:
def finishRequest(self, reply):
# read in the image data
logger.debug("request finished")
image_data = reply.readAll()
# save the image to the cache
self.add_image_to_cache(self.fetched_url, image_data)
ImageFetcher.image_fetch_complete(image_data)
self.fetchComplete.emit(QByteArray(image_data), self.user_data)
def create_image_db(self) -> None:
def create_image_db(self):
# this will wipe out any existing version
open(self.db_file, "wb").close()
open(self.db_file, "w").close()
# wipe any existing image cache folder too
if os.path.isdir(self.cache_folder):
@ -130,26 +133,29 @@ class ImageFetcher:
# create tables
with con:
cur = con.cursor()
cur.execute("CREATE TABLE Images(url TEXT,filename TEXT,timestamp TEXT,PRIMARY KEY (url))")
cur.execute("CREATE TABLE Images(" + "url TEXT," + "filename TEXT," + "timestamp TEXT," + "PRIMARY KEY (url))")
def add_image_to_cache(self, url: str, image_data: bytes | QtCore.QByteArray) -> None:
def add_image_to_cache(self, url, image_data):
con = lite.connect(self.db_file)
with con:
cur = con.cursor()
timestamp = datetime.datetime.now()
tmp_fd, filename = tempfile.mkstemp(dir=self.cache_folder, prefix="img")
with os.fdopen(tmp_fd, "w+b") as f:
f.write(bytes(image_data))
f = os.fdopen(tmp_fd, "w+b")
f.write(image_data)
f.close()
cur.execute("INSERT or REPLACE INTO Images VALUES(?, ?, ?)", (url, filename, timestamp))
def get_image_from_cache(self, url: str) -> bytes:
def get_image_from_cache(self, url):
con = lite.connect(self.db_file)
with con:
@ -159,16 +165,16 @@ class ImageFetcher:
row = cur.fetchone()
if row is None:
return b""
return None
else:
filename = row[0]
image_data = None
filename = row[0]
image_data = b""
try:
with open(filename, "rb") as f:
image_data = f.read()
f.close()
except IOError as e:
pass
try:
with open(filename, "rb") as f:
image_data = f.read()
f.close()
except OSError:
pass
return image_data
return image_data

View File

@ -1,79 +1,80 @@
"""A class to manage creating image content hashes, and calculate hamming distances"""
#
# Copyright 2013 Anthony Beville
#
# 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 io
import logging
import sys
from functools import reduce
from typing import TypeVar
try:
from PIL import Image
from PIL import Image, WebPImagePlugin
pil_available = True
except ImportError:
pil_available = False
logger = logging.getLogger(__name__)
class ImageHasher:
def __init__(self, path: str | None = None, data: bytes = b"", width: int = 8, height: int = 8) -> None:
class ImageHasher(object):
def __init__(self, path=None, data=None, width=8, height=8):
# self.hash_size = size
self.width = width
self.height = height
if path is None and not data:
raise OSError
if path is None and data is None:
raise IOError
else:
try:
if path is not None:
self.image = Image.open(path)
else:
self.image = Image.open(io.BytesIO(data))
except Exception as e:
print("Image data seems corrupted! [{}]".format(e))
# just generate a bogus image
self.image = Image.new("L", (1, 1))
def average_hash(self):
try:
if path is not None:
self.image = Image.open(path)
else:
self.image = Image.open(io.BytesIO(data))
except Exception:
logger.exception("Image data seems corrupted!")
# just generate a bogus image
self.image = Image.new("L", (1, 1))
def average_hash(self) -> int:
try:
image = self.image.resize((self.width, self.height), Image.Resampling.LANCZOS).convert("L")
except Exception:
logger.exception("average_hash error")
return 0
image = self.image.resize((self.width, self.height), Image.ANTIALIAS).convert("L")
except Exception as e:
print("average_hash error:", e)
return int(0)
pixels = list(image.getdata())
avg = sum(pixels) / len(pixels)
def compare_value_to_avg(i: int) -> int:
def compare_value_to_avg(i):
return 1 if i > avg else 0
bitlist = list(map(compare_value_to_avg, pixels))
# build up an int value from the bit list, one bit at a time
def set_bit(x: int, idx_val: tuple[int, int]) -> int:
def set_bit(x, idx_val):
(idx, val) = idx_val
return x | (val << idx)
result = reduce(set_bit, enumerate(bitlist), 0)
# print("{0:016x}".format(result))
return result
def average_hash2(self) -> None:
def average_hash2(self):
pass
"""
# Got this one from somewhere on the net. Not a clue how the 'convolve2d' works!
# Got this one from somewhere on the net. Not a clue how the 'convolve2d'
# works!
from numpy import array
from scipy.signal import convolve2d
@ -87,10 +88,12 @@ class ImageHasher:
result = reduce(lambda x, (y, z): x | (z << y),
enumerate(map(lambda i: 0 if i < 0 else 1, filt_data)),
0)
#print("{0:016x}".format(result))
return result
"""
def dct_average_hash(self) -> None:
def dct_average_hash(self):
pass
"""
# Algorithm source: http://syntaxcandy.blogspot.com/2012/08/perceptual-hash.html
@ -128,8 +131,8 @@ class ImageHasher:
7. Construct the hash. Set the 64 bits into a 64-bit integer. The order does not
matter, just as long as you are consistent.
"""
"""
import numpy
import scipy.fftpack
numpy.set_printoptions(threshold=10000, linewidth=200, precision=2, suppress=True)
@ -164,16 +167,15 @@ class ImageHasher:
result = reduce(set_bit, enumerate(bitlist), long(0))
#print("{0:016x}".format(result))
return result
"""
# accepts 2 hashes (longs or hex strings) and returns the hamming distance
T = TypeVar("T", int, str)
@staticmethod
def hamming_distance(h1: T, h2: T) -> int:
if isinstance(h1, int) or isinstance(h2, int):
def hamming_distance(h1, h2):
if isinstance(h1, int) or isinstance(h1, int):
n1 = h1
n2 = h2
else:

View File

@ -1,82 +1,70 @@
"""A PyQT4 widget to display a popup image"""
#
# Copyright 2012-2014 Anthony Beville
#
# 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
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from PyQt5 import QtCore, QtGui, QtWidgets, sip, uic
from comictaggerlib.settings import ComicTaggerSettings
logger = logging.getLogger(__name__)
from .settings import ComicTaggerSettings
class ImagePopup(QtWidgets.QDialog):
def __init__(self, parent: QtWidgets.QWidget, image_pixmap: QtGui.QPixmap) -> None:
super().__init__(parent)
def __init__(self, parent, image_pixmap):
super(ImagePopup, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.get_ui_file("imagepopup.ui"), self)
uic.loadUi(ComicTaggerSettings.getUIFile("imagepopup.ui"), self)
QtWidgets.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.CursorShape.WaitCursor))
QtWidgets.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.WaitCursor))
self.setWindowFlags(QtCore.Qt.WindowType.Popup)
self.setWindowState(QtCore.Qt.WindowState.WindowFullScreen)
# self.setWindowModality(QtCore.Qt.WindowModal)
self.setWindowFlags(QtCore.Qt.Popup)
self.setWindowState(QtCore.Qt.WindowFullScreen)
self.imagePixmap = image_pixmap
screen_size = QtGui.QGuiApplication.primaryScreen().geometry()
QtWidgets.QApplication.primaryScreen()
screen_size = QtWidgets.QDesktopWidget().screenGeometry()
self.resize(screen_size.width(), screen_size.height())
self.move(0, 0)
# This is a total hack. Uses a snapshot of the desktop, and overlays a
# translucent screen over it. Probably can do it better by setting opacity of a widget
# TODO: macOS denies this
# translucent screen over it. Probably can do it better by setting opacity of a
# widget
screen = QtWidgets.QApplication.primaryScreen()
self.desktopBg = screen.grabWindow(sip.voidptr(0), 0, 0, screen_size.width(), screen_size.height())
bg = QtGui.QPixmap(ComicTaggerSettings.get_graphic("popup_bg.png"))
self.clientBgPixmap = bg.scaled(
screen_size.width(),
screen_size.height(),
QtCore.Qt.AspectRatioMode.IgnoreAspectRatio,
QtCore.Qt.SmoothTransformation,
)
self.desktopBg = screen.grabWindow(QtWidgets.QApplication.desktop().winId(), 0, 0, screen_size.width(), screen_size.height())
bg = QtGui.QPixmap(ComicTaggerSettings.getGraphic("popup_bg.png"))
self.clientBgPixmap = bg.scaled(screen_size.width(), screen_size.height(), QtCore.Qt.IgnoreAspectRatio, QtCore.Qt.SmoothTransformation)
self.setMask(self.clientBgPixmap.mask())
self.apply_image_pixmap()
self.applyImagePixmap()
self.showFullScreen()
self.raise_()
QtWidgets.QApplication.restoreOverrideCursor()
def paintEvent(self, event: QtGui.QPaintEvent) -> None:
painter = QtGui.QPainter(self)
painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
painter.drawPixmap(0, 0, self.desktopBg)
painter.drawPixmap(0, 0, self.clientBgPixmap)
painter.end()
def paintEvent(self, event):
self.painter = QtGui.QPainter(self)
self.painter.setRenderHint(QtGui.QPainter.Antialiasing)
self.painter.drawPixmap(0, 0, self.desktopBg)
self.painter.drawPixmap(0, 0, self.clientBgPixmap)
self.painter.end()
def apply_image_pixmap(self) -> None:
def applyImagePixmap(self):
win_h = self.height()
win_w = self.width()
if self.imagePixmap.width() > win_w or self.imagePixmap.height() > win_h:
# scale the pixmap to fit in the frame
display_pixmap = self.imagePixmap.scaled(
win_w, win_h, QtCore.Qt.AspectRatioMode.KeepAspectRatio, QtCore.Qt.SmoothTransformation
)
display_pixmap = self.imagePixmap.scaled(win_w, win_h, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation)
self.lblImage.setPixmap(display_pixmap)
else:
display_pixmap = self.imagePixmap
@ -86,7 +74,7 @@ class ImagePopup(QtWidgets.QDialog):
img_w = display_pixmap.width()
img_h = display_pixmap.height()
self.lblImage.resize(img_w, img_h)
self.lblImage.move(int((win_w - img_w) / 2), int((win_h - img_h) / 2))
self.lblImage.move((win_w - img_w) / 2, (win_h - img_h) / 2)
def mousePressEvent(self, event: QtGui.QMouseEvent) -> None:
def mousePressEvent(self, event):
self.close()

View File

@ -1,86 +1,63 @@
"""A class to automatically identify a comic archive"""
#
# Copyright 2012-2014 Anthony Beville
#
# 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 io
import logging
import sys
from typing import Any, Callable
from typing_extensions import NotRequired, TypedDict
from comicapi import utils
from comicapi.comicarchive import ComicArchive
from comicapi.genericmetadata import GenericMetadata
from comicapi.issuestring import IssueString
from comictaggerlib.comicvinetalker import ComicVineTalker, ComicVineTalkerException
from comictaggerlib.imagefetcher import ImageFetcher, ImageFetcherException
from comictaggerlib.imagehasher import ImageHasher
from comictaggerlib.resulttypes import IssueResult
from comictaggerlib.settings import ComicTaggerSettings
logger = logging.getLogger(__name__)
from . import utils
from .comicvinetalker import ComicVineTalker, ComicVineTalkerException
from .genericmetadata import GenericMetadata
from .imagefetcher import ImageFetcher, ImageFetcherException
from .imagehasher import ImageHasher
from .issuestring import IssueString
try:
from PIL import Image
from PIL import Image, WebPImagePlugin
pil_available = True
except ImportError:
pil_available = False
class SearchKeys(TypedDict):
series: str | None
issue_number: str | None
month: int | None
year: int | None
issue_count: int | None
class Score(TypedDict):
score: NotRequired[int]
url: str
hash: int
class IssueIdentifierNetworkError(Exception):
...
pass
class IssueIdentifierCancelled(Exception):
...
pass
class IssueIdentifier:
result_no_matches = 0
result_found_match_but_bad_cover_score = 1
result_found_match_but_not_first_page = 2
result_multiple_matches_with_bad_image_scores = 3
result_one_good_match = 4
result_multiple_good_matches = 5
def __init__(self, comic_archive: ComicArchive, settings: ComicTaggerSettings) -> None:
self.settings = settings
self.comic_archive: ComicArchive = comic_archive
ResultNoMatches = 0
ResultFoundMatchButBadCoverScore = 1
ResultFoundMatchButNotFirstPage = 2
ResultMultipleMatchesWithBadImageScores = 3
ResultOneGoodMatch = 4
ResultMultipleGoodMatches = 5
def __init__(self, comic_archive, settings):
self.comic_archive = comic_archive
self.image_hasher = 1
self.only_use_additional_meta_data = False
self.onlyUseAdditionalMetaData = False
# a decent hamming score, good enough to call it a match
self.min_score_thresh: int = 16
self.min_score_thresh = 16
# for alternate covers, be more stringent, since we're a bit more
# scattershot in comparisons
@ -95,214 +72,237 @@ class IssueIdentifier:
# used to eliminate series names that are too long based on our search
# string
self.series_match_thresh = settings.id_series_match_identify_thresh
self.length_delta_thresh = settings.id_length_delta_thresh
# used to eliminate unlikely publishers
self.publisher_filter = [s.strip().casefold() for s in settings.id_publisher_filter.split(",")]
self.publisher_blacklist = [s.strip().lower() for s in settings.id_publisher_blacklist.split(",")]
self.additional_metadata = GenericMetadata()
self.output_function: Callable[[str], None] = IssueIdentifier.default_write_output
self.callback: Callable[[int, int], None] | None = None
self.cover_url_callback: Callable[[bytes], None] | None = None
self.search_result = self.result_no_matches
self.output_function = IssueIdentifier.defaultWriteOutput
self.callback = None
self.coverUrlCallback = None
self.search_result = self.ResultNoMatches
self.cover_page_index = 0
self.cancel = False
self.wait_and_retry_on_rate_limit = False
self.waitAndRetryOnRateLimit = False
self.match_list: list[IssueResult] = []
def set_score_min_threshold(self, thresh: int) -> None:
def setScoreMinThreshold(self, thresh):
self.min_score_thresh = thresh
def set_score_min_distance(self, distance: int) -> None:
def setScoreMinDistance(self, distance):
self.min_score_distance = distance
def set_additional_metadata(self, md: GenericMetadata) -> None:
def setAdditionalMetadata(self, md):
self.additional_metadata = md
def set_name_series_match_threshold(self, delta: int) -> None:
self.series_match_thresh = delta
def setNameLengthDeltaThreshold(self, delta):
self.length_delta_thresh = delta
def set_publisher_filter(self, flt: list[str]) -> None:
self.publisher_filter = flt
def setPublisherBlackList(self, blacklist):
self.publisher_blacklist = blacklist
def set_hasher_algorithm(self, algo: int) -> None:
def setHasherAlgorithm(self, algo):
self.image_hasher = algo
pass
def set_output_function(self, func: Callable[[str], None]) -> None:
def setOutputFunction(self, func):
self.output_function = func
pass
def calculate_hash(self, image_data: bytes) -> int:
if self.image_hasher == 3:
return -1 # ImageHasher(data=image_data).dct_average_hash()
if self.image_hasher == 2:
return -1 # ImageHasher(data=image_data).average_hash2()
def calculateHash(self, image_data):
if self.image_hasher == "3":
return ImageHasher(data=image_data).dct_average_hash()
elif self.image_hasher == "2":
return ImageHasher(data=image_data).average_hash2()
else:
return ImageHasher(data=image_data).average_hash()
return ImageHasher(data=image_data).average_hash()
def get_aspect_ratio(self, image_data: bytes) -> float:
def getAspectRatio(self, image_data):
try:
im = Image.open(io.BytesIO(image_data))
im = Image.open(io.StringIO(image_data))
w, h = im.size
return float(h) / float(w)
except Exception:
except:
return 1.5
def crop_cover(self, image_data: bytes) -> bytes:
def cropCover(self, image_data):
im = Image.open(io.BytesIO(image_data))
im = Image.open(io.StringIO(image_data))
w, h = im.size
try:
cropped_im = im.crop((int(w / 2), 0, w, h))
except Exception:
logger.exception("cropCover() error")
return b""
except Exception as e:
print("cropCover() error:", e)
return None
output = io.BytesIO()
output = io.StringIO()
cropped_im.save(output, format="PNG")
cropped_image_data = output.getvalue()
output.close()
return cropped_image_data
def set_progress_callback(self, cb_func: Callable[[int, int], None]) -> None:
def setProgressCallback(self, cb_func):
self.callback = cb_func
def set_cover_url_callback(self, cb_func: Callable[[bytes], None]) -> None:
self.cover_url_callback = cb_func
def setCoverURLCallback(self, cb_func):
self.coverUrlCallback = cb_func
def get_search_keys(self) -> SearchKeys:
def getSearchKeys(self):
ca = self.comic_archive
search_keys = dict()
search_keys["series"] = None
search_keys["issue_number"] = None
search_keys["month"] = None
search_keys["year"] = None
search_keys["issue_count"] = None
if ca is None:
return None
search_keys: SearchKeys
if self.only_use_additional_meta_data:
search_keys = SearchKeys(
series=self.additional_metadata.series,
issue_number=self.additional_metadata.issue,
year=self.additional_metadata.year,
month=self.additional_metadata.month,
issue_count=self.additional_metadata.issue_count,
)
return
if self.onlyUseAdditionalMetaData:
search_keys["series"] = self.additional_metadata.series
search_keys["issue_number"] = self.additional_metadata.issue
search_keys["year"] = self.additional_metadata.year
search_keys["month"] = self.additional_metadata.month
search_keys["issue_count"] = self.additional_metadata.issueCount
return search_keys
# see if the archive has any useful meta data for searching with
try:
if ca.has_cix():
internal_metadata = ca.read_cix()
else:
internal_metadata = ca.read_cbi()
except Exception as e:
internal_metadata = GenericMetadata()
logger.error("Failed to load metadata for %s: %s", ca.path, e)
if ca.hasCIX():
internal_metadata = ca.readCIX()
elif ca.hasCBI():
internal_metadata = ca.readCBI()
else:
internal_metadata = ca.readCBI()
# try to get some metadata from filename
md_from_filename = ca.metadata_from_filename(
self.settings.complicated_parser,
self.settings.remove_c2c,
self.settings.remove_fcbd,
self.settings.remove_publisher,
)
working_md = md_from_filename.copy()
working_md.overlay(internal_metadata)
working_md.overlay(self.additional_metadata)
md_from_filename = ca.metadataFromFilename()
# preference order:
# 1. Additional metadata
# 1. Internal metadata
# 1. Filename metadata
search_keys = SearchKeys(
series=working_md.series,
issue_number=working_md.issue,
year=working_md.year,
month=working_md.month,
issue_count=working_md.issue_count,
)
if self.additional_metadata.series is not None:
search_keys["series"] = self.additional_metadata.series
elif internal_metadata.series is not None:
search_keys["series"] = internal_metadata.series
else:
search_keys["series"] = md_from_filename.series
if self.additional_metadata.issue is not None:
search_keys["issue_number"] = self.additional_metadata.issue
elif internal_metadata.issue is not None:
search_keys["issue_number"] = internal_metadata.issue
else:
search_keys["issue_number"] = md_from_filename.issue
if self.additional_metadata.year is not None:
search_keys["year"] = self.additional_metadata.year
elif internal_metadata.year is not None:
search_keys["year"] = internal_metadata.year
else:
search_keys["year"] = md_from_filename.year
if self.additional_metadata.month is not None:
search_keys["month"] = self.additional_metadata.month
elif internal_metadata.month is not None:
search_keys["month"] = internal_metadata.month
else:
search_keys["month"] = md_from_filename.month
if self.additional_metadata.issueCount is not None:
search_keys["issue_count"] = self.additional_metadata.issueCount
elif internal_metadata.issueCount is not None:
search_keys["issue_count"] = internal_metadata.issueCount
else:
search_keys["issue_count"] = md_from_filename.issueCount
return search_keys
@staticmethod
def default_write_output(text: str) -> None:
def defaultWriteOutput(text):
sys.stdout.write(text)
sys.stdout.flush()
def log_msg(self, msg: Any, newline: bool = True) -> None:
msg = str(msg)
if newline:
msg += "\n"
def log_msg(self, msg, newline=True):
self.output_function(msg)
if newline:
self.output_function("\n")
def get_issue_cover_match_score(
self,
comic_vine: ComicVineTalker,
issue_id: int,
primary_img_url: str,
primary_thumb_url: str,
page_url: str,
local_cover_hash_list: list[int],
use_remote_alternates: bool = False,
use_log: bool = True,
) -> Score:
# local_cover_hash_list is a list of pre-calculated hashes.
# use_remote_alternates - indicates to use alternate covers from CV
def getIssueCoverMatchScore(
self, comicVine, issue_id, primary_img_url, primary_thumb_url, page_url, localCoverHashList, useRemoteAlternates=False, useLog=True
):
# localHashes is a list of pre-calculated hashs.
# useRemoteAlternates - indicates to use alternate covers from CV
try:
url_image_data = ImageFetcher().fetch(primary_thumb_url, blocking=True)
except ImageFetcherException as e:
except ImageFetcherException:
self.log_msg("Network issue while fetching cover image from Comic Vine. Aborting...")
raise IssueIdentifierNetworkError from e
raise IssueIdentifierNetworkError
if self.cancel:
raise IssueIdentifierCancelled
# alert the GUI, if needed
if self.cover_url_callback is not None:
self.cover_url_callback(url_image_data)
if self.coverUrlCallback is not None:
self.coverUrlCallback(url_image_data)
remote_cover_list = [Score(url=primary_img_url, hash=self.calculate_hash(url_image_data))]
remote_cover_list = []
item = dict()
item["url"] = primary_img_url
item["hash"] = self.calculateHash(url_image_data)
remote_cover_list.append(item)
if self.cancel:
raise IssueIdentifierCancelled
if use_remote_alternates:
alt_img_url_list = comic_vine.fetch_alternate_cover_urls(issue_id, page_url)
if useRemoteAlternates:
alt_img_url_list = comicVine.fetchAlternateCoverURLs(issue_id, page_url)
for alt_url in alt_img_url_list:
try:
alt_url_image_data = ImageFetcher().fetch(alt_url, blocking=True)
except ImageFetcherException as e:
except ImageFetcherException:
self.log_msg("Network issue while fetching alt. cover image from Comic Vine. Aborting...")
raise IssueIdentifierNetworkError from e
raise IssueIdentifierNetworkError
if self.cancel:
raise IssueIdentifierCancelled
# alert the GUI, if needed
if self.cover_url_callback is not None:
self.cover_url_callback(alt_url_image_data)
if self.coverUrlCallback is not None:
self.coverUrlCallback(alt_url_image_data)
remote_cover_list.append(Score(url=alt_url, hash=self.calculate_hash(alt_url_image_data)))
item = dict()
item["url"] = alt_url
item["hash"] = self.calculateHash(alt_url_image_data)
remote_cover_list.append(item)
if self.cancel:
raise IssueIdentifierCancelled
if use_log and use_remote_alternates:
self.log_msg(f"[{len(remote_cover_list) - 1} alt. covers]", False)
if use_log:
if useLog and useRemoteAlternates:
self.log_msg("[{0} alt. covers]".format(len(remote_cover_list) - 1), False)
if useLog:
self.log_msg("[ ", False)
score_list = []
done = False
for local_cover_hash in local_cover_hash_list:
for local_cover_hash in localCoverHashList:
for remote_cover_item in remote_cover_list:
score = ImageHasher.hamming_distance(local_cover_hash, remote_cover_item["hash"])
score_list.append(Score(score=score, url=remote_cover_item["url"], hash=remote_cover_item["hash"]))
if use_log:
self.log_msg(score, False)
score_item = dict()
score_item["score"] = score
score_item["url"] = remote_cover_item["url"]
score_item["hash"] = remote_cover_item["hash"]
score_list.append(score_item)
if useLog:
self.log_msg("{0}".format(score), False)
if score <= self.strong_score_thresh:
# such a good score, we can quit now, since for sure we
@ -312,43 +312,54 @@ class IssueIdentifier:
if done:
break
if use_log:
if useLog:
self.log_msg(" ]", False)
best_score_item = min(score_list, key=lambda x: x["score"])
return best_score_item
def search(self) -> list[IssueResult]:
# def validate(self, issue_id):
# create hash list
# score = self.getIssueMatchScore(issue_id, hash_list, useRemoteAlternates = True)
# if score < 20:
# return True
# else:
# return False
def search(self):
ca = self.comic_archive
self.match_list = []
self.cancel = False
self.search_result = self.result_no_matches
self.search_result = self.ResultNoMatches
if not pil_available:
self.log_msg("Python Imaging Library (PIL) is not available and is needed for issue identification.")
return self.match_list
if not ca.seems_to_be_a_comic_archive():
self.log_msg(f"Sorry, but {ca.path} is not a comic archive!")
if not ca.seemsToBeAComicArchive():
self.log_msg("Sorry, but " + opts.filename + " is not a comic archive!")
return self.match_list
cover_image_data = ca.get_page(self.cover_page_index)
cover_hash = self.calculate_hash(cover_image_data)
cover_image_data = ca.getPage(self.cover_page_index)
cover_hash = self.calculateHash(cover_image_data)
# check the aspect ratio
# if it's wider than it is high, it's probably a two page spread
# if so, crop it and calculate a second hash
narrow_cover_hash = None
aspect_ratio = self.get_aspect_ratio(cover_image_data)
aspect_ratio = self.getAspectRatio(cover_image_data)
if aspect_ratio < 1.0:
right_side_image_data = self.crop_cover(cover_image_data)
right_side_image_data = self.cropCover(cover_image_data)
if right_side_image_data is not None:
narrow_cover_hash = self.calculate_hash(right_side_image_data)
narrow_cover_hash = self.calculateHash(right_side_image_data)
keys = self.get_search_keys()
# self.log_msg("Cover hash = {0:016x}".format(cover_hash))
keys = self.getSearchKeys()
# normalize the issue number
keys["issue_number"] = IssueString(keys["issue_number"]).as_string()
keys["issue_number"] = IssueString(keys["issue_number"]).asString()
# we need, at minimum, a series and issue number
if keys["series"] is None or keys["issue_number"] is None:
@ -365,18 +376,21 @@ class IssueIdentifier:
if keys["month"] is not None:
self.log_msg("\tMonth: " + str(keys["month"]))
comic_vine = ComicVineTalker(self.settings.id_series_match_search_thresh)
comic_vine.wait_for_rate_limit = self.wait_and_retry_on_rate_limit
# self.log_msg("Publisher Blacklist: " + str(self.publisher_blacklist))
comicVine = ComicVineTalker()
comicVine.wait_for_rate_limit = self.waitAndRetryOnRateLimit
comic_vine.set_log_func(self.output_function)
comicVine.setLogFunc(self.output_function)
self.log_msg(f"Searching for {keys['series']} #{keys['issue_number']} ...")
# self.log_msg(("Searching for " + keys['series'] + "...")
self.log_msg("Searching for {0} #{1} ...".format(keys["series"], keys["issue_number"]))
try:
cv_search_results = comic_vine.search_for_series(keys["series"])
cv_search_results = comicVine.searchForSeries(keys["series"])
except ComicVineTalkerException:
self.log_msg("Network issue while searching for series. Aborting...")
return []
# self.log_msg("Found " + str(len(cv_search_results)) + " initial results")
if self.cancel:
return []
@ -385,32 +399,28 @@ class IssueIdentifier:
series_second_round_list = []
# self.log_msg("Removing results with too long names, banned publishers, or future start dates")
for item in cv_search_results:
length_approved = False
publisher_approved = True
date_approved = True
# remove any series that starts after the issue year
if (
keys["year"] is not None
and str(keys["year"]).isdigit()
and item["start_year"] is not None
and str(item["start_year"]).isdigit()
):
if keys["year"] is not None and str(keys["year"]).isdigit() and item["start_year"] is not None and str(item["start_year"]).isdigit():
if int(keys["year"]) < int(item["start_year"]):
date_approved = False
aliases = []
if item["aliases"]:
aliases = item["aliases"].split("\n")
for name in [item["name"], *aliases]:
if utils.titles_match(keys["series"], name, self.series_match_thresh):
length_approved = True
break
# remove any series from publishers on the filter
# assume that our search name is close to the actual name, say
# within ,e.g. 5 chars
shortened_key = utils.removearticles(keys["series"])
shortened_item_name = utils.removearticles(item["name"])
if len(shortened_item_name) < (len(shortened_key) + self.length_delta_thresh):
length_approved = True
# remove any series from publishers on the blacklist
if item["publisher"] is not None:
publisher = item["publisher"]["name"]
if publisher is not None and publisher.casefold() in self.publisher_filter:
if publisher is not None and publisher.lower() in self.publisher_blacklist:
publisher_approved = False
if length_approved and publisher_approved and date_approved:
@ -425,16 +435,12 @@ class IssueIdentifier:
series_second_round_list.sort(key=lambda x: len(x["name"]), reverse=False)
# build a list of volume IDs
volume_id_list = []
volume_id_list = list()
for series in series_second_round_list:
volume_id_list.append(series["id"])
issue_list = None
try:
if len(volume_id_list) > 0:
issue_list = comic_vine.fetch_issues_by_volume_issue_num_and_year(
volume_id_list, keys["issue_number"], keys["year"]
)
issue_list = comicVine.fetchIssuesByVolumeIssueNumAndYear(volume_id_list, keys["issue_number"], keys["year"])
except ComicVineTalkerException:
self.log_msg("Network issue while searching for series details. Aborting...")
@ -443,7 +449,7 @@ class IssueIdentifier:
if issue_list is None:
return []
shortlist = []
shortlist = list()
# now re-associate the issues and volumes
for issue in issue_list:
for series in series_second_round_list:
@ -452,11 +458,9 @@ class IssueIdentifier:
break
if keys["year"] is None:
self.log_msg(f"Found {len(shortlist)} series that have an issue #{keys['issue_number']}")
self.log_msg("Found {0} series that have an issue #{1}".format(len(shortlist), keys["issue_number"]))
else:
self.log_msg(
f"Found {len(shortlist)} series that have an issue #{keys['issue_number']} from {keys['year']}"
)
self.log_msg("Found {0} series that have an issue #{1} from {2}".format(len(shortlist), keys["issue_number"], keys["year"]))
# now we have a shortlist of volumes with the desired issue number
# Do first round of cover matching
@ -466,13 +470,10 @@ class IssueIdentifier:
self.callback(counter, len(shortlist) * 3)
counter += 1
self.log_msg(
f"Examining covers for ID: {series['id']} {series['name']} ({series['start_year']}) ...",
newline=False,
)
self.log_msg("Examining covers for ID: {0} {1} ({2}) ...".format(series["id"], series["name"], series["start_year"]), newline=False)
# parse out the cover date
_, month, year = comic_vine.parse_date_str(issue["cover_date"])
day, month, year = comicVine.parseDateStr(issue["cover_date"])
# Now check the cover match against the primary image
hash_list = [cover_hash]
@ -484,84 +485,73 @@ class IssueIdentifier:
thumb_url = issue["image"]["thumb_url"]
page_url = issue["site_detail_url"]
score_item = self.get_issue_cover_match_score(
comic_vine,
issue["id"],
image_url,
thumb_url,
page_url,
hash_list,
use_remote_alternates=False,
score_item = self.getIssueCoverMatchScore(
comicVine, issue["id"], image_url, thumb_url, page_url, hash_list, useRemoteAlternates=False
)
except Exception:
except:
self.match_list = []
return self.match_list
match: IssueResult = {
"series": f"{series['name']} ({series['start_year']})",
"distance": score_item["score"],
"issue_number": keys["issue_number"],
"cv_issue_count": series["count_of_issues"],
"url_image_hash": score_item["hash"],
"issue_title": issue["name"],
"issue_id": issue["id"],
"volume_id": series["id"],
"month": month,
"year": year,
"publisher": None,
"image_url": image_url,
"thumb_url": thumb_url,
"page_url": page_url,
"description": issue["description"],
}
match = dict()
match["series"] = "{0} ({1})".format(series["name"], series["start_year"])
match["distance"] = score_item["score"]
match["issue_number"] = keys["issue_number"]
match["cv_issue_count"] = series["count_of_issues"]
match["url_image_hash"] = score_item["hash"]
match["issue_title"] = issue["name"]
match["issue_id"] = issue["id"]
match["volume_id"] = series["id"]
match["month"] = month
match["year"] = year
match["publisher"] = None
if series["publisher"] is not None:
match["publisher"] = series["publisher"]["name"]
match["image_url"] = image_url
match["thumb_url"] = thumb_url
match["page_url"] = page_url
match["description"] = issue["description"]
self.match_list.append(match)
self.log_msg(f" --> {match['distance']}", newline=False)
self.log_msg(" --> {0}".format(match["distance"]), newline=False)
self.log_msg("")
if len(self.match_list) == 0:
self.log_msg(":-(no matches!")
self.search_result = self.result_no_matches
self.search_result = self.ResultNoMatches
return self.match_list
# sort list by image match scores
self.match_list.sort(key=lambda k: k["distance"])
lst = []
l = []
for i in self.match_list:
lst.append(i["distance"])
l.append(i["distance"])
self.log_msg(f"Compared to covers in {len(self.match_list)} issue(s):", newline=False)
self.log_msg(str(lst))
self.log_msg("Compared to covers in {0} issue(s):".format(len(self.match_list)), newline=False)
self.log_msg(str(l))
def print_match(item: IssueResult) -> None:
def print_match(item):
self.log_msg(
"-----> {} #{} {} ({}/{}) -- score: {}".format(
item["series"],
item["issue_number"],
item["issue_title"],
item["month"],
item["year"],
item["distance"],
"-----> {0} #{1} {2} ({3}/{4}) -- score: {5}".format(
item["series"], item["issue_number"], item["issue_title"], item["month"], item["year"], item["distance"]
)
)
best_score: int = self.match_list[0]["distance"]
best_score = self.match_list[0]["distance"]
if best_score >= self.min_score_thresh:
# we have 1 or more low-confidence matches (all bad cover scores)
# look at a few more pages in the archive, and also alternate covers online
# look at a few more pages in the archive, and also alternate
# covers online
self.log_msg("Very weak scores for the cover. Analyzing alternate pages and covers...")
hash_list = [cover_hash]
if narrow_cover_hash is not None:
hash_list.append(narrow_cover_hash)
for page_index in range(1, min(3, ca.get_number_of_pages())):
image_data = ca.get_page(page_index)
page_hash = self.calculate_hash(image_data)
for i in range(1, min(3, ca.getNumberOfPages())):
image_data = ca.getPage(i)
page_hash = self.calculateHash(image_data)
hash_list.append(page_hash)
second_match_list = []
@ -570,21 +560,15 @@ class IssueIdentifier:
if self.callback is not None:
self.callback(counter, len(self.match_list) * 3)
counter += 1
self.log_msg(f"Examining alternate covers for ID: {m['volume_id']} {m['series']} ...", newline=False)
self.log_msg("Examining alternate covers for ID: {0} {1} ...".format(m["volume_id"], m["series"]), newline=False)
try:
score_item = self.get_issue_cover_match_score(
comic_vine,
m["issue_id"],
m["image_url"],
m["thumb_url"],
m["page_url"],
hash_list,
use_remote_alternates=True,
score_item = self.getIssueCoverMatchScore(
comicVine, m["issue_id"], m["image_url"], m["thumb_url"], m["page_url"], hash_list, useRemoteAlternates=True
)
except Exception:
except:
self.match_list = []
return self.match_list
self.log_msg(f"--->{score_item['score']}")
self.log_msg("--->{0}".format(score_item["score"]))
self.log_msg("")
if score_item["score"] < self.min_alternate_score_thresh:
@ -597,43 +581,43 @@ class IssueIdentifier:
self.log_msg("--------------------------------------------------------------------------")
print_match(self.match_list[0])
self.log_msg("--------------------------------------------------------------------------")
self.search_result = self.result_found_match_but_bad_cover_score
self.search_result = self.ResultFoundMatchButBadCoverScore
else:
self.log_msg("--------------------------------------------------------------------------")
self.log_msg("Multiple bad cover matches! Need to use other info...")
self.log_msg("--------------------------------------------------------------------------")
self.search_result = self.result_multiple_matches_with_bad_image_scores
self.search_result = self.ResultMultipleMatchesWithBadImageScores
return self.match_list
else:
# We did good, found something!
self.log_msg("Success in secondary/alternate cover matching!")
# We did good, found something!
self.log_msg("Success in secondary/alternate cover matching!")
self.match_list = second_match_list
# sort new list by image match scores
self.match_list.sort(key=lambda k: k["distance"])
best_score = self.match_list[0]["distance"]
self.log_msg("[Second round cover matching: best score = {best_score}]")
# now drop down into the rest of the processing
self.match_list = second_match_list
# sort new list by image match scores
self.match_list.sort(key=lambda k: k["distance"])
best_score = self.match_list[0]["distance"]
self.log_msg("[Second round cover matching: best score = {0}]".format(best_score))
# now drop down into the rest of the processing
if self.callback is not None:
self.callback(99, 100)
# now pare down list, remove any item more than specified distant from the top scores
for match_item in reversed(self.match_list):
if match_item["distance"] > best_score + self.min_score_distance:
self.match_list.remove(match_item)
# now pare down list, remove any item more than specified distant from
# the top scores
for item in reversed(self.match_list):
if item["distance"] > best_score + self.min_score_distance:
self.match_list.remove(item)
# One more test for the case choosing limited series first issue vs a trade with the same cover:
# if we have a given issue count > 1 and the volume from CV has count==1, remove it from match list
# if we have a given issue count > 1 and the volume from CV has
# count==1, remove it from match list
if len(self.match_list) >= 2 and keys["issue_count"] is not None and keys["issue_count"] != 1:
new_list = []
new_list = list()
for match in self.match_list:
if match["cv_issue_count"] != 1:
new_list.append(match)
else:
self.log_msg(
f"Removing volume {match['series']} [{match['volume_id']}] from consideration (only 1 issue)"
)
self.log_msg("Removing volume {0} [{1}] from consideration (only 1 issue)".format(match["series"], match["volume_id"]))
if len(new_list) > 0:
self.match_list = new_list
@ -642,20 +626,20 @@ class IssueIdentifier:
self.log_msg("--------------------------------------------------------------------------")
print_match(self.match_list[0])
self.log_msg("--------------------------------------------------------------------------")
self.search_result = self.result_one_good_match
self.search_result = self.ResultOneGoodMatch
elif len(self.match_list) == 0:
self.log_msg("--------------------------------------------------------------------------")
self.log_msg("No matches found :(")
self.log_msg("--------------------------------------------------------------------------")
self.search_result = self.result_no_matches
self.search_result = self.ResultNoMatches
else:
# we've got multiple good matches:
self.log_msg("More than one likely candidate.")
self.search_result = self.result_multiple_good_matches
self.search_result = self.ResultMultipleGoodMatches
self.log_msg("--------------------------------------------------------------------------")
for match_item in self.match_list:
print_match(match_item)
for item in self.match_list:
print_match(item)
self.log_msg("--------------------------------------------------------------------------")
return self.match_list

View File

@ -1,85 +1,71 @@
"""A PyQT4 dialog to select specific issue from list"""
#
# Copyright 2012-2014 Anthony Beville
#
# 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
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from comicapi.issuestring import IssueString
from comictaggerlib.comicvinetalker import ComicVineTalker, ComicVineTalkerException
from comictaggerlib.coverimagewidget import CoverImageWidget
from comictaggerlib.resulttypes import CVIssuesResults
from comictaggerlib.settings import ComicTaggerSettings
from comictaggerlib.ui.qtutils import reduce_widget_font_size
from comictaggerlib.ui.qtutils import reduceWidgetFontSize
logger = logging.getLogger(__name__)
from .comicvinetalker import ComicVineTalker, ComicVineTalkerException
from .coverimagewidget import CoverImageWidget
from .issuestring import IssueString
from .settings import ComicTaggerSettings
class IssueNumberTableWidgetItem(QtWidgets.QTableWidgetItem):
def __lt__(self, other: object) -> bool:
assert isinstance(other, QtWidgets.QTableWidgetItem)
self_str: str = self.data(QtCore.Qt.ItemDataRole.DisplayRole)
other_str: str = other.data(QtCore.Qt.ItemDataRole.DisplayRole)
return (IssueString(self_str).as_float() or 0) < (IssueString(other_str).as_float() or 0)
def __lt__(self, other):
selfStr = self.data(QtCore.Qt.DisplayRole)
otherStr = other.data(QtCore.Qt.DisplayRole)
return IssueString(selfStr).asFloat() < IssueString(otherStr).asFloat()
class IssueSelectionWindow(QtWidgets.QDialog):
volume_id = 0
def __init__(
self, parent: QtWidgets.QWidget, settings: ComicTaggerSettings, series_id: int, issue_number: str
) -> None:
super().__init__(parent)
def __init__(self, parent, settings, series_id, issue_number):
super(IssueSelectionWindow, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.get_ui_file("issueselectionwindow.ui"), self)
uic.loadUi(ComicTaggerSettings.getUIFile("issueselectionwindow.ui"), self)
self.coverWidget = CoverImageWidget(self.coverImageContainer, CoverImageWidget.AltCoverMode)
gridlayout = QtWidgets.QGridLayout(self.coverImageContainer)
gridlayout.addWidget(self.coverWidget)
gridlayout.setContentsMargins(0, 0, 0, 0)
reduce_widget_font_size(self.twList)
reduce_widget_font_size(self.teDescription, 1)
reduceWidgetFontSize(self.twList)
reduceWidgetFontSize(self.teDescription, 1)
self.setWindowFlags(
QtCore.Qt.WindowType(
self.windowFlags()
| QtCore.Qt.WindowType.WindowSystemMenuHint
| QtCore.Qt.WindowType.WindowMaximizeButtonHint
)
)
self.setWindowFlags(self.windowFlags() | QtCore.Qt.WindowSystemMenuHint | QtCore.Qt.WindowMaximizeButtonHint)
self.series_id = series_id
self.issue_id: int | None = None
self.settings = settings
self.url_fetch_thread = None
self.issue_list: list[CVIssuesResults] = []
if issue_number is None or issue_number == "":
self.issue_number = "1"
self.issue_number = 1
else:
self.issue_number = issue_number
self.initial_id: int | None = None
self.perform_query()
self.initial_id = None
self.performQuery()
self.twList.resizeColumnsToContents()
self.twList.currentItemChanged.connect(self.current_item_changed)
self.twList.cellDoubleClicked.connect(self.cell_double_clicked)
self.twList.currentItemChanged.connect(self.currentItemChanged)
self.twList.cellDoubleClicked.connect(self.cellDoubleClicked)
# now that the list has been sorted, find the initial record, and
# select it
@ -87,28 +73,29 @@ class IssueSelectionWindow(QtWidgets.QDialog):
self.twList.selectRow(0)
else:
for r in range(0, self.twList.rowCount()):
issue_id = self.twList.item(r, 0).data(QtCore.Qt.ItemDataRole.UserRole)
issue_id = self.twList.item(r, 0).data(QtCore.Qt.UserRole)
if issue_id == self.initial_id:
self.twList.selectRow(r)
break
def perform_query(self) -> None:
def performQuery(self):
QtWidgets.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.CursorShape.WaitCursor))
QtWidgets.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.WaitCursor))
try:
comic_vine = ComicVineTalker(self.settings.id_series_match_search_thresh)
comic_vine.fetch_volume_data(self.series_id)
self.issue_list = comic_vine.fetch_issues_by_volume(self.series_id)
comicVine = ComicVineTalker()
volume_data = comicVine.fetchVolumeData(self.series_id)
self.issue_list = comicVine.fetchIssuesByVolume(self.series_id)
except ComicVineTalkerException as e:
QtWidgets.QApplication.restoreOverrideCursor()
if e.code == ComicVineTalkerException.RateLimit:
QtWidgets.QMessageBox.critical(self, "Comic Vine Error", ComicVineTalker.get_rate_limit_message())
QtWidgets.QMessageBox.critical(self, self.tr("Comic Vine Error"), ComicVineTalker.getRateLimitMessage())
else:
QtWidgets.QMessageBox.critical(self, "Network Issue", "Could not connect to Comic Vine to list issues!")
QtWidgets.QMessageBox.critical(self, self.tr("Network Issue"), self.tr("Could not connect to Comic Vine to list issues!"))
return
self.twList.setRowCount(0)
while self.twList.rowCount() > 0:
self.twList.removeRow(0)
self.twList.setSortingEnabled(False)
@ -118,10 +105,10 @@ class IssueSelectionWindow(QtWidgets.QDialog):
item_text = record["issue_number"]
item = IssueNumberTableWidgetItem(item_text)
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
item.setData(QtCore.Qt.ItemDataRole.UserRole, record["id"])
item.setData(QtCore.Qt.ItemDataRole.DisplayRole, item_text)
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
item.setData(QtCore.Qt.ToolTipRole, item_text)
item.setData(QtCore.Qt.UserRole, record["id"])
item.setData(QtCore.Qt.DisplayRole, item_text)
item.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 0, item)
item_text = record["cover_date"]
@ -132,49 +119,46 @@ class IssueSelectionWindow(QtWidgets.QDialog):
if len(parts) > 1:
item_text = parts[0] + "-" + parts[1]
qtw_item = QtWidgets.QTableWidgetItem(item_text)
qtw_item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
qtw_item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
self.twList.setItem(row, 1, qtw_item)
item = QtWidgets.QTableWidgetItem(item_text)
item.setData(QtCore.Qt.ToolTipRole, item_text)
item.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 1, item)
item_text = record["name"]
if item_text is None:
item_text = ""
qtw_item = QtWidgets.QTableWidgetItem(item_text)
qtw_item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
qtw_item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
self.twList.setItem(row, 2, qtw_item)
item = QtWidgets.QTableWidgetItem(item_text)
item.setData(QtCore.Qt.ToolTipRole, item_text)
item.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 2, item)
if (
IssueString(record["issue_number"]).as_string().casefold()
== IssueString(self.issue_number).as_string().casefold()
):
if IssueString(record["issue_number"]).asString().lower() == IssueString(self.issue_number).asString().lower():
self.initial_id = record["id"]
row += 1
self.twList.setSortingEnabled(True)
self.twList.sortItems(0, QtCore.Qt.SortOrder.AscendingOrder)
self.twList.sortItems(0, QtCore.Qt.AscendingOrder)
QtWidgets.QApplication.restoreOverrideCursor()
def cell_double_clicked(self, r: int, c: int) -> None:
def cellDoubleClicked(self, r, c):
self.accept()
def current_item_changed(self, curr: QtCore.QModelIndex | None, prev: QtCore.QModelIndex | None) -> None:
def currentItemChanged(self, curr, prev):
if curr is None:
return
if prev is not None and prev.row() == curr.row():
return
self.issue_id = self.twList.item(curr.row(), 0).data(QtCore.Qt.ItemDataRole.UserRole)
self.issue_id = self.twList.item(curr.row(), 0).data(QtCore.Qt.UserRole)
# list selection was changed, update the the issue cover
for record in self.issue_list:
if record["id"] == self.issue_id:
self.issue_number = record["issue_number"]
self.coverWidget.set_issue_id(self.issue_id)
self.coverWidget.setIssueID(int(self.issue_id))
if record["description"] is None:
self.teDescription.setText("")
else:

View File

@ -0,0 +1 @@
from comicapi.issuestring import *

View File

@ -1,52 +1,35 @@
"""A PyQT4 dialog to a text file or log"""
#
# Copyright 2012-2014 Anthony Beville
#
# 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
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from PyQt5 import QtCore, QtWidgets, uic
from comictaggerlib.settings import ComicTaggerSettings
from comictaggerlib.ui import qtutils
logger = logging.getLogger(__name__)
from .settings import ComicTaggerSettings
class LogWindow(QtWidgets.QDialog):
def __init__(self, parent: QtWidgets.QWidget) -> None:
super().__init__(parent)
def __init__(self, parent):
super(LogWindow, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.get_ui_file("logwindow.ui"), self)
uic.loadUi(ComicTaggerSettings.getUIFile("logwindow.ui"), self)
self.setWindowFlags(
QtCore.Qt.WindowType(
self.windowFlags()
| QtCore.Qt.WindowType.WindowSystemMenuHint
| QtCore.Qt.WindowType.WindowMaximizeButtonHint
)
)
self.setWindowFlags(self.windowFlags() | QtCore.Qt.WindowSystemMenuHint | QtCore.Qt.WindowMaximizeButtonHint)
def set_text(self, text: str | bytes | None) -> None:
def setText(self, text):
try:
if text is not None:
if isinstance(text, bytes):
text = text.decode("utf-8")
self.textEdit.setPlainText(text)
except AttributeError:
text = text.decode()
except:
pass
except Exception as e:
logger.exception("Displaying raw tags failed")
qtutils.qt_error("Displaying raw tags failed:", e)
self.textEdit.setPlainText(text)

View File

@ -1,221 +1,103 @@
"""A python app to (automatically) tag comic archives"""
#
# Copyright 2012-2014 Anthony Beville
#
# 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.handlers
import os
import pathlib
import platform
import signal
import sys
import traceback
import types
from comicapi import utils
from comictaggerlib import cli
from comictaggerlib.comicvinetalker import ComicVineTalker
from comictaggerlib.ctversion import version
from comictaggerlib.options import parse_cmd_line
from comictaggerlib.settings import ComicTaggerSettings
from . import cli, utils
from .comicvinetalker import ComicVineTalker
from .options import Options
from .settings import ComicTaggerSettings
if sys.version_info < (3, 10):
import importlib_metadata
else:
import importlib.metadata as importlib_metadata
logger = logging.getLogger("comictagger")
logging.getLogger("comicapi").setLevel(logging.DEBUG)
logging.getLogger("comictaggerlib").setLevel(logging.DEBUG)
logger.setLevel(logging.DEBUG)
# Need to load setting before anything else
SETTINGS = ComicTaggerSettings()
try:
qt_available = True
from PyQt5 import QtCore, QtGui, QtWidgets
def show_exception_box(log_msg: str) -> None:
"""Checks if a QApplication instance is available and shows a messagebox with the exception message.
If unavailable (non-console application), log an additional notice.
"""
if QtWidgets.QApplication.instance() is not None:
errorbox = QtWidgets.QMessageBox()
errorbox.setText(f"Oops. An unexpected error occurred:\n{log_msg}")
errorbox.exec()
QtWidgets.QApplication.exit(1)
else:
logger.debug("No QApplication instance available.")
class UncaughtHook(QtCore.QObject):
_exception_caught = QtCore.pyqtSignal(object)
def __init__(self) -> None:
super().__init__()
# this registers the exception_hook() function as hook with the Python interpreter
sys.excepthook = self.exception_hook
# connect signal to execute the message box function always on main thread
self._exception_caught.connect(show_exception_box)
def exception_hook(
self, exc_type: type[BaseException], exc_value: BaseException, exc_traceback: types.TracebackType | None
) -> None:
"""Function handling uncaught exceptions.
It is triggered each time an uncaught exception occurs.
"""
if issubclass(exc_type, KeyboardInterrupt):
# ignore keyboard interrupt to support console applications
sys.__excepthook__(exc_type, exc_value, exc_traceback)
else:
exc_info = (exc_type, exc_value, exc_traceback)
log_msg = "\n".join(["".join(traceback.format_tb(exc_traceback)), f"{exc_type.__name__}: {exc_value}"])
logger.critical("Uncaught exception: %s: %s", exc_type.__name__, exc_value, exc_info=exc_info)
# trigger message box show
self._exception_caught.emit(log_msg)
qt_exception_hook = UncaughtHook()
from comictaggerlib.taggerwindow import TaggerWindow
from .taggerwindow import TaggerWindow
except ImportError as e:
def show_exception_box(log_msg: str) -> None:
...
logger.error(str(e))
qt_available = False
def rotate(handler: logging.handlers.RotatingFileHandler, filename: pathlib.Path) -> None:
if filename.is_file() and filename.stat().st_size > 0:
handler.doRollover()
def update_publishers() -> None:
json_file = ComicTaggerSettings.get_settings_folder() / "publishers.json"
if json_file.exists():
try:
utils.update_publishers(json.loads(json_file.read_text("utf-8")))
except Exception as e:
logger.exception("Failed to load publishers from %s", json_file)
show_exception_box(str(e))
def ctmain() -> None:
opts = parse_cmd_line()
settings = ComicTaggerSettings(opts.config_path)
os.makedirs(ComicTaggerSettings.get_settings_folder() / "logs", exist_ok=True)
stream_handler = logging.StreamHandler()
stream_handler.setLevel(logging.WARNING)
file_handler = logging.handlers.RotatingFileHandler(
ComicTaggerSettings.get_settings_folder() / "logs" / "ComicTagger.log", encoding="utf-8", backupCount=10
)
rotate(file_handler, ComicTaggerSettings.get_settings_folder() / "logs" / "ComicTagger.log")
logging.basicConfig(
handlers=[
stream_handler,
file_handler,
],
level=logging.WARNING,
format="%(asctime)s | %(name)s | %(levelname)s | %(message)s",
datefmt="%Y-%m-%dT%H:%M:%S",
)
# Need to load setting before anything else
def ctmain():
opts = Options()
opts.parseCmdLineArgs()
# manage the CV API key
# None comparison is used so that the empty string can unset the value
if opts.cv_api_key is not None or opts.cv_url is not None:
settings.cv_api_key = opts.cv_api_key if opts.cv_api_key is not None else settings.cv_api_key
settings.cv_url = opts.cv_url if opts.cv_url is not None else settings.cv_url
settings.save()
if opts.only_set_cv_key:
print("Key set") # noqa: T201
if opts.cv_api_key:
if opts.cv_api_key != SETTINGS.cv_api_key:
SETTINGS.cv_api_key = opts.cv_api_key
SETTINGS.save()
if opts.only_set_key:
print("Key set")
return
ComicVineTalker.api_key = settings.cv_api_key
ComicVineTalker.api_base_url = settings.cv_url
ComicVineTalker.api_key = SETTINGS.cv_api_key
signal.signal(signal.SIGINT, signal.SIG_DFL)
logger.info(
"ComicTagger Version: %s running on: %s PyInstaller: %s",
version,
platform.system(),
"Yes" if getattr(sys, "frozen", None) else "No",
)
logger.debug("Installed Packages")
for pkg in sorted(importlib_metadata.distributions(), key=lambda x: x.name):
logger.debug("%s\t%s", pkg.name, pkg.version)
utils.load_publishers()
update_publishers()
if not qt_available and not opts.no_gui:
opts.no_gui = True
logger.warning("PyQt5 is not available. ComicTagger is limited to command-line mode.")
print("PyQt5 is not available. ComicTagger is limited to command-line mode.", file=sys.stderr)
if opts.no_gui:
try:
cli.cli_mode(opts, settings)
except Exception:
logger.exception("CLI mode failed")
cli.cli_mode(opts, SETTINGS)
else:
os.environ["QtWidgets.QT_AUTO_SCREEN_SCALE_FACTOR"] = "1"
args = []
if opts.darkmode:
args.extend(["-platform", "windows:darkmode=2"])
args.extend(sys.argv)
app = QtWidgets.QApplication(args)
os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "1"
# if platform.system() == "Darwin":
# QtWidgets.QApplication.setStyle("macintosh")
# else:
# QtWidgets.QApplication.setStyle("Fusion")
app = QtWidgets.QApplication(sys.argv)
if platform.system() == "Darwin":
# Set the MacOS dock icon
app.setWindowIcon(QtGui.QIcon(ComicTaggerSettings.get_graphic("app.png")))
app.setWindowIcon(QtGui.QIcon(ComicTaggerSettings.getGraphic("app.png")))
if platform.system() == "Windows":
# For pure python, tell windows that we're not python,
# so we can have our own taskbar icon
import ctypes
myappid = "comictagger" # arbitrary string
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) # type: ignore[attr-defined]
# force close of console window
swp_hidewindow = 0x0080
console_wnd = ctypes.windll.kernel32.GetConsoleWindow() # type: ignore[attr-defined]
if console_wnd != 0:
ctypes.windll.user32.SetWindowPos(console_wnd, None, 0, 0, 0, 0, swp_hidewindow) # type: ignore[attr-defined]
myappid = u"comictagger" # arbitrary string
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
if platform.system() != "Linux":
img = QtGui.QPixmap(ComicTaggerSettings.get_graphic("tags.png"))
img = QtGui.QPixmap(ComicTaggerSettings.getGraphic("tags.png"))
splash = QtWidgets.QSplashScreen(img)
splash.show()
splash.raise_()
QtWidgets.QApplication.processEvents()
app.processEvents()
try:
tagger_window = TaggerWindow(opts.files, settings, opts=opts)
tagger_window.setWindowIcon(QtGui.QIcon(ComicTaggerSettings.get_graphic("app.png")))
tagger_window = TaggerWindow(opts.file_list, SETTINGS, opts=opts)
tagger_window.setWindowIcon(QtGui.QIcon(ComicTaggerSettings.getGraphic("app.png")))
tagger_window.show()
if platform.system() != "Linux":
splash.finish(tagger_window)
sys.exit(app.exec())
except Exception:
logger.exception("GUI mode failed")
QtWidgets.QMessageBox.critical(
QtWidgets.QMainWindow(), "Error", "Unhandled exception in app:\n" + traceback.format_exc()
)
sys.exit(app.exec_())
except Exception as e:
QtWidgets.QMessageBox.critical(QtWidgets.QMainWindow(), "Error", "Unhandled exception in app:\n" + traceback.format_exc())

View File

@ -1,41 +1,37 @@
"""A PyQT4 dialog to select from automated issue matches"""
#
# Copyright 2012-2014 Anthony Beville
#
# 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
from PyQt5 import QtCore, QtWidgets, uic
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from comicapi.comicarchive import ComicArchive
from comictaggerlib.coverimagewidget import CoverImageWidget
from comictaggerlib.resulttypes import IssueResult
from comictaggerlib.settings import ComicTaggerSettings
from comictaggerlib.ui.qtutils import reduce_widget_font_size
from comictaggerlib.ui.qtutils import reduceWidgetFontSize
logger = logging.getLogger(__name__)
from .coverimagewidget import CoverImageWidget
from .settings import ComicTaggerSettings
class MatchSelectionWindow(QtWidgets.QDialog):
volume_id = 0
def __init__(self, parent: QtWidgets.QWidget, matches: list[IssueResult], comic_archive: ComicArchive) -> None:
super().__init__(parent)
def __init__(self, parent, matches, comic_archive):
super(MatchSelectionWindow, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.get_ui_file("matchselectionwindow.ui"), self)
uic.loadUi(ComicTaggerSettings.getUIFile("matchselectionwindow.ui"), self)
self.altCoverWidget = CoverImageWidget(self.altCoverContainer, CoverImageWidget.AltCoverMode)
gridlayout = QtWidgets.QGridLayout(self.altCoverContainer)
@ -47,38 +43,33 @@ class MatchSelectionWindow(QtWidgets.QDialog):
gridlayout.addWidget(self.archiveCoverWidget)
gridlayout.setContentsMargins(0, 0, 0, 0)
reduce_widget_font_size(self.twList)
reduce_widget_font_size(self.teDescription, 1)
reduceWidgetFontSize(self.twList)
reduceWidgetFontSize(self.teDescription, 1)
self.setWindowFlags(
QtCore.Qt.WindowType(
self.windowFlags()
| QtCore.Qt.WindowType.WindowSystemMenuHint
| QtCore.Qt.WindowType.WindowMaximizeButtonHint
)
)
self.setWindowFlags(self.windowFlags() | QtCore.Qt.WindowSystemMenuHint | QtCore.Qt.WindowMaximizeButtonHint)
self.matches: list[IssueResult] = matches
self.matches = matches
self.comic_archive = comic_archive
self.twList.currentItemChanged.connect(self.current_item_changed)
self.twList.cellDoubleClicked.connect(self.cell_double_clicked)
self.twList.currentItemChanged.connect(self.currentItemChanged)
self.twList.cellDoubleClicked.connect(self.cellDoubleClicked)
self.update_data()
self.updateData()
def update_data(self) -> None:
def updateData(self):
self.set_cover_image()
self.populate_table()
self.setCoverImage()
self.populateTable()
self.twList.resizeColumnsToContents()
self.twList.selectRow(0)
path = self.comic_archive.path
self.setWindowTitle(f"Select correct match: {os.path.split(path)[1]}")
self.setWindowTitle("Select correct match: {0}".format(os.path.split(path)[1]))
def populate_table(self) -> None:
def populateTable(self):
self.twList.setRowCount(0)
while self.twList.rowCount() > 0:
self.twList.removeRow(0)
self.twList.setSortingEnabled(False)
@ -88,70 +79,70 @@ class MatchSelectionWindow(QtWidgets.QDialog):
item_text = match["series"]
item = QtWidgets.QTableWidgetItem(item_text)
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
item.setData(QtCore.Qt.ItemDataRole.UserRole, (match,))
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
item.setData(QtCore.Qt.ToolTipRole, item_text)
item.setData(QtCore.Qt.UserRole, (match,))
item.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 0, item)
if match["publisher"] is not None:
item_text = str(match["publisher"])
item_text = "{0}".format(match["publisher"])
else:
item_text = "Unknown"
item = QtWidgets.QTableWidgetItem(item_text)
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
item.setData(QtCore.Qt.ToolTipRole, item_text)
item.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 1, item)
month_str = ""
year_str = "????"
if match["month"] is not None:
month_str = f"-{int(match['month']):02d}"
month_str = "-{0:02d}".format(int(match["month"]))
if match["year"] is not None:
year_str = str(match["year"])
year_str = "{0}".format(match["year"])
item_text = year_str + month_str
item = QtWidgets.QTableWidgetItem(item_text)
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
item.setData(QtCore.Qt.ToolTipRole, item_text)
item.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 2, item)
item_text = match["issue_title"]
if item_text is None:
item_text = ""
item = QtWidgets.QTableWidgetItem(item_text)
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
item.setData(QtCore.Qt.ToolTipRole, item_text)
item.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 3, item)
row += 1
self.twList.resizeColumnsToContents()
self.twList.setSortingEnabled(True)
self.twList.sortItems(2, QtCore.Qt.SortOrder.AscendingOrder)
self.twList.sortItems(2, QtCore.Qt.AscendingOrder)
self.twList.selectRow(0)
self.twList.resizeColumnsToContents()
self.twList.horizontalHeader().setStretchLastSection(True)
def cell_double_clicked(self, r: int, c: int) -> None:
def cellDoubleClicked(self, r, c):
self.accept()
def current_item_changed(self, curr: QtCore.QModelIndex, prev: QtCore.QModelIndex) -> None:
def currentItemChanged(self, curr, prev):
if curr is None:
return
if prev is not None and prev.row() == curr.row():
return
self.altCoverWidget.set_issue_id(self.current_match()["issue_id"])
if self.current_match()["description"] is None:
self.altCoverWidget.setIssueID(self.currentMatch()["issue_id"])
if self.currentMatch()["description"] is None:
self.teDescription.setText("")
else:
self.teDescription.setText(self.current_match()["description"])
self.teDescription.setText(self.currentMatch()["description"])
def set_cover_image(self) -> None:
self.archiveCoverWidget.set_archive(self.comic_archive)
def setCoverImage(self):
self.archiveCoverWidget.setArchive(self.comic_archive)
def current_match(self) -> IssueResult:
def currentMatch(self):
row = self.twList.currentRow()
match: IssueResult = self.twList.item(row, 0).data(QtCore.Qt.ItemDataRole.UserRole)[0]
match = self.twList.item(row, 0).data(QtCore.Qt.UserRole)[0]
return match

View File

@ -6,120 +6,92 @@ checked = OptionalMessageDialog.msg(self, "Disclaimer",
"This is beta software, and you are using it at your own risk!",
)
said_yes, checked = OptionalMessageDialog.question(self, "QtWidgets.Question",
said_yes, checked = OptionalMessageDialog.question(self, "Question",
"Are you sure you wish to do this?",
)
"""
#
# Copyright 2012-2014 Anthony Beville
#
# 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
from PyQt5 import QtCore, QtWidgets
logger = logging.getLogger(__name__)
from PyQt5.QtCore import *
from PyQt5.QtGui import *
from PyQt5.QtWidgets import *
StyleMessage = 0
StyleQuestion = 1
class OptionalMessageDialog(QtWidgets.QDialog):
def __init__(
self, parent: QtWidgets.QWidget, style: int, title: str, msg: str, checked: bool = False, check_text: str = ""
) -> None:
super().__init__(parent)
class OptionalMessageDialog(QDialog):
def __init__(self, parent, style, title, msg, check_state=Qt.Unchecked, check_text=None):
QDialog.__init__(self, parent)
self.setWindowTitle(title)
self.was_accepted = False
layout = QtWidgets.QVBoxLayout(self)
self.theLabel = QtWidgets.QLabel(msg)
l = QVBoxLayout(self)
self.theLabel = QLabel(msg)
self.theLabel.setWordWrap(True)
self.theLabel.setTextFormat(QtCore.Qt.TextFormat.RichText)
self.theLabel.setTextFormat(Qt.RichText)
self.theLabel.setOpenExternalLinks(True)
self.theLabel.setTextInteractionFlags(
QtCore.Qt.TextInteractionFlag.TextSelectableByMouse
| QtCore.Qt.TextInteractionFlag.LinksAccessibleByMouse
| QtCore.Qt.TextInteractionFlag.LinksAccessibleByKeyboard
)
self.theLabel.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.LinksAccessibleByMouse | Qt.LinksAccessibleByKeyboard)
layout.addWidget(self.theLabel)
layout.insertSpacing(-1, 10)
l.addWidget(self.theLabel)
l.insertSpacing(-1, 10)
if not check_text:
if check_text is None:
if style == StyleQuestion:
check_text = "Remember this answer"
else:
check_text = "Don't show this message again"
self.theCheckBox = QtWidgets.QCheckBox(check_text)
self.theCheckBox = QCheckBox(check_text)
self.theCheckBox.setChecked(checked)
self.theCheckBox.setCheckState(check_state)
layout.addWidget(self.theCheckBox)
l.addWidget(self.theCheckBox)
btnbox_style: QtWidgets.QDialogButtonBox.StandardButtons | QtWidgets.QDialogButtonBox.StandardButton
btnbox_style = QDialogButtonBox.Ok
if style == StyleQuestion:
btnbox_style = QtWidgets.QDialogButtonBox.StandardButton.Yes | QtWidgets.QDialogButtonBox.StandardButton.No
else:
btnbox_style = QtWidgets.QDialogButtonBox.StandardButton.Ok
btnbox_style = QDialogButtonBox.Yes | QDialogButtonBox.No
self.theButtonBox = QtWidgets.QDialogButtonBox(
btnbox_style,
parent=self,
)
self.theButtonBox.accepted.connect(self.accept)
self.theButtonBox.rejected.connect(self.reject)
self.theButtonBox = QDialogButtonBox(btnbox_style, parent=self, accepted=self.accept, rejected=self.reject)
layout.addWidget(self.theButtonBox)
l.addWidget(self.theButtonBox)
def accept(self) -> None:
def accept(self):
self.was_accepted = True
QtWidgets.QDialog.accept(self)
QDialog.accept(self)
def reject(self) -> None:
def reject(self):
self.was_accepted = False
QtWidgets.QDialog.reject(self)
QDialog.reject(self)
@staticmethod
def msg(parent: QtWidgets.QWidget, title: str, msg: str, checked: bool = False, check_text: str = "") -> bool:
def msg(parent, title, msg, check_state=Qt.Unchecked, check_text=None):
d = OptionalMessageDialog(parent, StyleMessage, title, msg, checked=checked, check_text=check_text)
d = OptionalMessageDialog(parent, StyleMessage, title, msg, check_state=check_state, check_text=check_text)
d.exec()
d.exec_()
return d.theCheckBox.isChecked()
@staticmethod
def question(
parent: QtWidgets.QWidget, title: str, msg: str, checked: bool = False, check_text: str = ""
) -> tuple[bool, bool]:
def question(parent, title, msg, check_state=Qt.Unchecked, check_text=None):
d = OptionalMessageDialog(parent, StyleQuestion, title, msg, checked=checked, check_text=check_text)
d = OptionalMessageDialog(parent, StyleQuestion, title, msg, check_state=check_state, check_text=check_text)
d.exec()
d.exec_()
return d.was_accepted, d.theCheckBox.isChecked()
@staticmethod
def msg_no_checkbox(
parent: QtWidgets.QWidget, title: str, msg: str, checked: bool = False, check_text: str = ""
) -> bool:
d = OptionalMessageDialog(parent, StyleMessage, title, msg, checked=checked, check_text=check_text)
d.theCheckBox.hide()
d.exec()
return d.theCheckBox.isChecked()

View File

@ -1,412 +1,437 @@
"""CLI options class for ComicTagger app"""
#
# Copyright 2012-2014 Anthony Beville
#
# 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 argparse
import logging
import getopt
import os
import platform
import sys
import traceback
from comicapi import utils
from comicapi.comicarchive import MetaDataStyle
from comicapi.genericmetadata import GenericMetadata
from comictaggerlib import ctversion
from . import _version, utils
from .comicarchive import MetaDataStyle
from .genericmetadata import GenericMetadata
from .versionchecker import VersionChecker
logger = logging.getLogger(__name__)
try:
import argparse
except ImportError:
pass
def define_args() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="""A utility for reading and writing metadata to comic archives.
class Options:
help_text = """Usage: {0} [option] ... [file [files ...]]
If no options are given, %(prog)s will run in windowed mode.""",
epilog="For more help visit the wiki at: https://github.com/comictagger/comictagger/wiki",
formatter_class=argparse.RawTextHelpFormatter,
)
parser.add_argument(
"--version",
action="store_true",
help="Display version.",
)
commands = parser.add_mutually_exclusive_group()
commands.add_argument(
"-p",
"--print",
action="store_true",
help="""Print out tag info from file. Specify type\n(via -t) to get only info of that tag type.\n\n""",
)
commands.add_argument(
"-d",
"--delete",
action="store_true",
help="Deletes the tag block of specified type (via -t).\n",
)
commands.add_argument(
"-c",
"--copy",
type=metadata_type,
metavar="{CR,CBL,COMET}",
help="Copy the specified source tag block to\ndestination style specified via -t\n(potentially lossy operation).\n\n",
)
commands.add_argument(
"-s",
"--save",
action="store_true",
help="Save out tags as specified type (via -t).\nMust specify also at least -o, -f, or -m.\n\n",
)
commands.add_argument(
"-r",
"--rename",
action="store_true",
help="Rename the file based on specified tag style.",
)
commands.add_argument(
"-e",
"--export-to-zip",
action="store_true",
help="Export RAR archive to Zip format.",
)
commands.add_argument(
"--only-set-cv-key",
action="store_true",
help="Only set the Comic Vine API key and quit.\n\n",
)
parser.add_argument(
"-1",
"--assume-issue-one",
action="store_true",
help="""Assume issue number is 1 if not found (relevant for -s).\n\n""",
)
parser.add_argument(
"--abort-on-conflict",
action="store_true",
help="""Don't export to zip if intended new filename\nexists (otherwise, creates a new unique filename).\n\n""",
)
parser.add_argument(
"-a",
"--auto-imprint",
action="store_true",
help="""Enables the auto imprint functionality.\ne.g. if the publisher is set to 'vertigo' it\nwill be updated to 'DC Comics' and the imprint\nproperty will be set to 'Vertigo'.\n\n""",
)
parser.add_argument(
"--config",
dest="config_path",
help="""Config directory defaults to ~/.ComicTagger\non Linux/Mac and %%APPDATA%% on Windows\n""",
)
parser.add_argument(
"--cv-api-key",
help="Use the given Comic Vine API Key (persisted in settings).",
)
parser.add_argument(
"--cv-url",
help="Use the given Comic Vine URL (persisted in settings).",
)
parser.add_argument(
"--delete-rar",
action="store_true",
dest="delete_after_zip_export",
help="""Delete original RAR archive after successful\nexport to Zip.""",
)
parser.add_argument(
"-f",
"--parse-filename",
"--parsefilename",
action="store_true",
help="""Parse the filename to get some info,\nspecifically series name, issue number,\nvolume, and publication year.\n\n""",
)
parser.add_argument(
"--id",
dest="issue_id",
type=int,
help="""Use the issue ID when searching online.\nOverrides all other metadata.\n\n""",
)
parser.add_argument(
"-t",
"--type",
metavar="{CR,CBL,COMET}",
default=[],
type=metadata_type,
help="""Specify TYPE as either CR, CBL or COMET\n(as either ComicRack, ComicBookLover,\nor CoMet style tags, respectively).\nUse commas for multiple types.\nFor searching the metadata will use the first listed:\neg '-t cbl,cr' with no CBL tags, CR will be used if they exist\n\n""",
)
parser.add_argument(
"-o",
"--online",
action="store_true",
help="""Search online and attempt to identify file\nusing existing metadata and images in archive.\nMay be used in conjunction with -f and -m.\n\n""",
)
parser.add_argument(
"-m",
"--metadata",
default=GenericMetadata(),
type=parse_metadata_from_string,
help="""Explicitly define, as a list, some tags to be used. e.g.:\n"series=Plastic Man, publisher=Quality Comics"\n"series=Kickers^, Inc., issue=1, year=1986"\nName-Value pairs are comma separated. Use a\n"^" to escape an "=" or a ",", as shown in\nthe example above. Some names that can be\nused: series, issue, issue_count, year,\npublisher, title\n\n""",
)
parser.add_argument(
"-i",
"--interactive",
action="store_true",
help="""Interactively query the user when there are\nmultiple matches for an online search.\n\n""",
)
parser.add_argument(
"--no-overwrite",
"--nooverwrite",
action="store_true",
help="""Don't modify tag block if it already exists (relevant for -s or -c).""",
)
parser.add_argument(
"--noabort",
dest="abort_on_low_confidence",
action="store_false",
help="""Don't abort save operation when online match\nis of low confidence.\n\n""",
)
parser.add_argument(
"--nosummary",
dest="show_save_summary",
action="store_false",
help="Suppress the default summary after a save operation.\n\n",
)
parser.add_argument(
"--overwrite",
action="store_true",
help="""Overwrite all existing metadata.\nMay be used in conjunction with -o, -f and -m.\n\n""",
)
parser.add_argument(
"--raw", action="store_true", help="""With -p, will print out the raw tag block(s)\nfrom the file.\n"""
)
parser.add_argument(
"-R",
"--recursive",
action="store_true",
help="Recursively include files in sub-folders.",
)
parser.add_argument(
"-S",
"--script",
help="""Run an "add-on" python script that uses the\nComicTagger library for custom processing.\nScript arguments can follow the script name.\n\n""",
)
parser.add_argument(
"--split-words",
action="store_true",
help="""Splits words before parsing the filename.\ne.g. 'judgedredd' to 'judge dredd'\n\n""",
)
parser.add_argument(
"--terse",
action="store_true",
help="Don't say much (for print mode).",
)
parser.add_argument(
"-v",
"--verbose",
action="store_true",
help="Be noisy when doing what it does.",
)
parser.add_argument(
"-w",
"--wait-on-cv-rate-limit",
action="store_true",
help="""When encountering a Comic Vine rate limit\nerror, wait and retry query.\n\n""",
)
parser.add_argument(
"-n", "--dryrun", action="store_true", help="Don't actually modify file (only relevant for -d, -s, or -r).\n\n"
)
parser.add_argument(
"--darkmode",
action="store_true",
help="Windows only. Force a dark pallet",
)
parser.add_argument(
"-g",
"--glob",
action="store_true",
help="Windows only. Enable globbing",
)
parser.add_argument("files", nargs="*")
return parser
A utility for reading and writing metadata to comic archives.
If no options are given, {0} will run in windowed mode.
def metadata_type(types: str) -> list[int]:
result = []
types = types.casefold()
for typ in types.split(","):
typ = typ.strip()
if typ not in MetaDataStyle.short_name:
choices = ", ".join(MetaDataStyle.short_name)
raise argparse.ArgumentTypeError(f"invalid choice: {typ} (choose from {choices.upper()})")
result.append(MetaDataStyle.short_name.index(typ))
return result
-p, --print Print out tag info from file. Specify type
(via -t) to get only info of that tag type.
--raw With -p, will print out the raw tag block(s)
from the file.
-d, --delete Deletes the tag block of specified type (via
-t).
-c, --copy=SOURCE Copy the specified source tag block to
destination style specified via -t
(potentially lossy operation).
-s, --save Save out tags as specified type (via -t).
Must specify also at least -o, -p, or -m.
--nooverwrite Don't modify tag block if it already exists
(relevant for -s or -c).
-1, --assume-issue-one Assume issue number is 1 if not found
(relevant for -s).
-n, --dryrun Don't actually modify file (only relevant for
-d, -s, or -r).
-t, --type=TYPE Specify TYPE as either "CR", "CBL", or
"COMET" (as either ComicRack, ComicBookLover,
or CoMet style tags, respectively).
-f, --parsefilename Parse the filename to get some info,
specifically series name, issue number,
volume, and publication year.
-i, --interactive Interactively query the user when there are
multiple matches for an online search.
--nosummary Suppress the default summary after a save
operation.
-o, --online Search online and attempt to identify file
using existing metadata and images in archive.
May be used in conjunction with -f and -m.
--id=ID Use the issue ID when searching online.
Overrides all other metadata.
-m, --metadata=LIST Explicitly define, as a list, some tags to be
used. e.g.:
"series=Plastic Man, publisher=Quality Comics"
"series=Kickers^, Inc., issue=1, year=1986"
Name-Value pairs are comma separated. Use a
"^" to escape an "=" or a ",", as shown in
the example above. Some names that can be
used: series, issue, issueCount, year,
publisher, title
-r, --rename Rename the file based on specified tag style.
--noabort Don't abort save operation when online match
is of low confidence.
-e, --export-to-zip Export RAR archive to Zip format.
--delete-rar Delete original RAR archive after successful
export to Zip.
--abort-on-conflict Don't export to zip if intended new filename
exists (otherwise, creates a new unique
filename).
-S, --script=FILE Run an "add-on" python script that uses the
ComicTagger library for custom processing.
Script arguments can follow the script name.
-R, --recursive Recursively include files in sub-folders.
--cv-api-key=KEY Use the given Comic Vine API Key (persisted
in settings).
--only-set-cv-key Only set the Comic Vine API key and quit.
-w, --wait-on-cv-rate-limit When encountering a Comic Vine rate limit
error, wait and retry query.
-v, --verbose Be noisy when doing what it does.
--terse Don't say much (for print mode).
--version Display version.
-h, --help Display this message.
def parse_metadata_from_string(mdstr: str) -> GenericMetadata:
"""The metadata string is a comma separated list of name-value pairs
The names match the attributes of the internal metadata struct (for now)
The caret is the special "escape character", since it's not common in
natural language text
example = "series=Kickers^, Inc. ,issue=1, year=1986"
For more help visit the wiki at: http://code.google.com/p/comictagger/
"""
escaped_comma = "^,"
escaped_equals = "^="
replacement_token = "<_~_>"
def __init__(self):
self.data_style = None
self.no_gui = False
self.filename = None
self.verbose = False
self.terse = False
self.auto_imprint = False
self.metadata = None
self.print_tags = False
self.copy_tags = False
self.delete_tags = False
self.export_to_zip = False
self.abort_export_on_conflict = False
self.delete_rar_after_export = False
self.search_online = False
self.dryrun = False
self.abortOnLowConfidence = True
self.save_tags = False
self.parse_filename = False
self.show_save_summary = True
self.raw = False
self.cv_api_key = None
self.only_set_key = False
self.rename_file = False
self.no_overwrite = False
self.interactive = False
self.issue_id = None
self.recursive = False
self.run_script = False
self.script = None
self.wait_and_retry_on_rate_limit = False
self.assume_issue_is_one_if_not_set = False
self.file_list = []
md = GenericMetadata()
# First, replace escaped commas with with a unique token (to be changed back later)
mdstr = mdstr.replace(escaped_comma, replacement_token)
tmp_list = mdstr.split(",")
md_list = []
for item in tmp_list:
item = item.replace(replacement_token, ",")
md_list.append(item)
# Now build a nice dict from the list
md_dict = {}
for item in md_list:
# Make sure to fix any escaped equal signs
i = item.replace(escaped_equals, replacement_token)
key, value = i.split("=")
value = value.replace(replacement_token, "=").strip()
key = key.strip()
if key.casefold() == "credit":
cred_attribs = value.split(":")
role = cred_attribs[0]
person = cred_attribs[1] if len(cred_attribs) > 1 else ""
primary = len(cred_attribs) > 2
md.add_credit(person.strip(), role.strip(), primary)
def display_msg_and_quit(self, msg, code, show_help=False):
appname = os.path.basename(sys.argv[0])
if msg is not None:
print(msg)
if show_help:
print((self.help_text.format(appname)))
else:
md_dict[key] = value
print("For more help, run with '--help'")
sys.exit(code)
# Map the dict to the metadata object
for key, value in md_dict.items():
if not hasattr(md, key):
raise argparse.ArgumentTypeError(f"'{key}' is not a valid tag name")
else:
md.is_empty = False
setattr(md, key, value)
return md
def parseMetadataFromString(self, mdstr):
"""The metadata string is a comma separated list of name-value pairs
The names match the attributes of the internal metadata struct (for now)
The caret is the special "escape character", since it's not common in
natural language text
example = "series=Kickers^, Inc. ,issue=1, year=1986"
"""
def launch_script(scriptfile: str, args: list[str]) -> None:
# we were given a script. special case for the args:
# 1. ignore everything before the -S,
# 2. pass all the ones that follow (including script name) to the script
if not os.path.exists(scriptfile):
logger.error("Can't find %s", scriptfile)
else:
# I *think* this makes sense:
# assume the base name of the file is the module name
# add the folder of the given file to the python path import module
dirname = os.path.dirname(scriptfile)
module_name = os.path.splitext(os.path.basename(scriptfile))[0]
sys.path = [dirname] + sys.path
try:
script = __import__(module_name)
escaped_comma = "^,"
escaped_equals = "^="
replacement_token = "<_~_>"
# Determine if the entry point exists before trying to run it
if "main" in dir(script):
script.main(args)
md = GenericMetadata()
# First, replace escaped commas with with a unique token (to be changed
# back later)
mdstr = mdstr.replace(escaped_comma, replacement_token)
tmp_list = mdstr.split(",")
md_list = []
for item in tmp_list:
item = item.replace(replacement_token, ",")
md_list.append(item)
# Now build a nice dict from the list
md_dict = dict()
for item in md_list:
# Make sure to fix any escaped equal signs
i = item.replace(escaped_equals, replacement_token)
key, value = i.split("=")
value = value.replace(replacement_token, "=").strip()
key = key.strip()
if key.lower() == "credit":
cred_attribs = value.split(":")
role = cred_attribs[0]
person = cred_attribs[1] if len(cred_attribs) > 1 else ""
primary = cred_attribs[2] if len(cred_attribs) > 2 else None
md.addCredit(person.strip(), role.strip(), True if primary is not None else False)
else:
logger.error("Can't find entry point 'main()' in module '%s'", module_name)
except Exception:
logger.exception("Script: %s raised an unhandled exception: ", module_name)
md_dict[key] = value
sys.exit(0)
# Map the dict to the metadata object
for key in md_dict:
if not hasattr(md, key):
print("Warning: '{0}' is not a valid tag name".format(key))
else:
md.isEmpty = False
setattr(md, key, md_dict[key])
# print(md)
return md
def launch_script(self, scriptfile):
# we were given a script. special case for the args:
# 1. ignore everything before the -S,
# 2. pass all the ones that follow (including script name) to the
# script
script_args = list()
for idx, arg in enumerate(sys.argv):
if arg in ["-S", "--script"]:
# found script!
script_args = sys.argv[idx + 1 :]
break
sys.argv = script_args
if not os.path.exists(scriptfile):
print("Can't find {0}".format(scriptfile))
else:
# I *think* this makes sense:
# assume the base name of the file is the module name
# add the folder of the given file to the python path
# import module
dirname = os.path.dirname(scriptfile)
module_name = os.path.splitext(os.path.basename(scriptfile))[0]
sys.path = [dirname] + sys.path
try:
script = __import__(module_name)
def parse_cmd_line() -> argparse.Namespace:
# Determine if the entry point exists before trying to run it
if "main" in dir(script):
script.main()
else:
print('Can\'t find entry point "main()" in module "{0}"'.format(module_name))
except Exception as e:
print("Script raised an unhandled exception: ", e)
print((traceback.format_exc()))
if platform.system() == "Darwin" and getattr(sys, "frozen", False):
# remove the PSN (process serial number) argument from OS/X
input_args = [a for a in sys.argv[1:] if "-psn_0_" not in a]
else:
input_args = sys.argv[1:]
sys.exit(0)
script_args = []
def parseCmdLineArgs(self):
# first check if we're launching a script and split off script args
for n, _ in enumerate(input_args):
if input_args[n] == "--":
break
if platform.system() == "Darwin" and hasattr(sys, "frozen") and sys.frozen == 1:
# remove the PSN ("process serial number") argument from OS/X
input_args = [a for a in sys.argv[1:] if "-psn_0_" not in a]
else:
input_args = sys.argv[1:]
if input_args[n] in ["-S", "--script"] and n + 1 < len(input_args):
# insert a "--" which will cause getopt to ignore the remaining args
# so they will be passed to the script
script_args = input_args[n + 2 :]
input_args = input_args[: n + 2]
break
# first check if we're launching a script:
for n in range(len(input_args)):
if input_args[n] in ["-S", "--script"] and n + 1 < len(input_args):
# insert a "--" which will cause getopt to ignore the remaining args
# so they will be passed to the script
input_args.insert(n + 2, "--")
break
parser = define_args()
opts = parser.parse_args(input_args)
# parse command line options
try:
opts, args = getopt.getopt(
input_args,
"hpdt:fm:vownsrc:ieRS:1",
[
"help",
"print",
"delete",
"type=",
"copy=",
"parsefilename",
"metadata=",
"verbose",
"online",
"dryrun",
"save",
"rename",
"raw",
"noabort",
"terse",
"nooverwrite",
"interactive",
"nosummary",
"version",
"id=",
"recursive",
"script=",
"export-to-zip",
"delete-rar",
"abort-on-conflict",
"assume-issue-one",
"cv-api-key=",
"only-set-cv-key",
"wait-on-cv-rate-limit",
],
)
if opts.config_path:
opts.config_path = os.path.abspath(opts.config_path)
if opts.version:
parser.exit(
status=1,
message=f"ComicTagger {ctversion.version}: Copyright (c) 2012-2022 ComicTagger Team\n"
"Distributed under Apache License 2.0 (http://www.apache.org/licenses/LICENSE-2.0)\n",
)
except getopt.GetoptError as err:
self.display_msg_and_quit(str(err), 2)
opts.no_gui = any(
[
opts.print,
opts.delete,
opts.save,
opts.copy,
opts.rename,
opts.export_to_zip,
opts.only_set_cv_key,
]
)
# process options
for o, a in opts:
if o in ("-h", "--help"):
self.display_msg_and_quit(None, 0, show_help=True)
if o in ("-v", "--verbose"):
self.verbose = True
if o in ("-S", "--script"):
self.run_script = True
self.script = a
if o in ("-R", "--recursive"):
self.recursive = True
if o in ("-p", "--print"):
self.print_tags = True
if o in ("-d", "--delete"):
self.delete_tags = True
if o in ("-i", "--interactive"):
self.interactive = True
if o in ("-a", "--auto-imprint"):
self.auto_imprint = True
if o in ("-c", "--copy"):
self.copy_tags = True
if a.lower() == "cr":
self.copy_source = MetaDataStyle.CIX
elif a.lower() == "cbl":
self.copy_source = MetaDataStyle.CBI
elif a.lower() == "comet":
self.copy_source = MetaDataStyle.COMET
else:
self.display_msg_and_quit("Invalid copy tag source type", 1)
if o in ("-o", "--online"):
self.search_online = True
if o in ("-n", "--dryrun"):
self.dryrun = True
if o in ("-m", "--metadata"):
self.metadata = self.parseMetadataFromString(a)
if o in ("-s", "--save"):
self.save_tags = True
if o in ("-r", "--rename"):
self.rename_file = True
if o in ("-e", "--export_to_zip"):
self.export_to_zip = True
if o == "--delete-rar":
self.delete_rar_after_export = True
if o == "--abort-on-conflict":
self.abort_export_on_conflict = True
if o in ("-f", "--parsefilename"):
self.parse_filename = True
if o in ("-w", "--wait-on-cv-rate-limit"):
self.wait_and_retry_on_rate_limit = True
if o == "--id":
self.issue_id = a
if o == "--raw":
self.raw = True
if o == "--noabort":
self.abortOnLowConfidence = False
if o == "--terse":
self.terse = True
if o == "--nosummary":
self.show_save_summary = False
if o in ("-1", "--assume-issue-one"):
self.assume_issue_is_one_if_not_set = True
if o == "--nooverwrite":
self.no_overwrite = True
if o == "--cv-api-key":
self.cv_api_key = a
if o == "--only-set-cv-key":
self.only_set_key = True
if o == "--version":
print("ComicTagger {0}: Copyright (c) 2012-2014 Anthony Beville".format(_version.version))
print("Distributed under Apache License 2.0 (http://www.apache.org/licenses/LICENSE-2.0)")
sys.exit(0)
if o in ("-t", "--type"):
if a.lower() == "cr":
self.data_style = MetaDataStyle.CIX
elif a.lower() == "cbl":
self.data_style = MetaDataStyle.CBI
elif a.lower() == "comet":
self.data_style = MetaDataStyle.COMET
else:
self.display_msg_and_quit("Invalid tag type", 1)
if opts.script is not None:
launch_script(opts.script, script_args)
if self.print_tags or self.delete_tags or self.save_tags or self.copy_tags or self.rename_file or self.export_to_zip or self.only_set_key:
self.no_gui = True
if platform.system() == "Windows" and opts.glob:
# no globbing on windows shell, so do it for them
import glob
count = 0
if self.run_script:
count += 1
if self.print_tags:
count += 1
if self.delete_tags:
count += 1
if self.save_tags:
count += 1
if self.copy_tags:
count += 1
if self.rename_file:
count += 1
if self.export_to_zip:
count += 1
if self.only_set_key:
count += 1
globs = opts.files
opts.files = []
for item in globs:
opts.files.extend(glob.glob(item))
if count > 1:
self.display_msg_and_quit("Must choose only one action of print, delete, save, copy, rename, export, set key, or run script", 1)
if opts.only_set_cv_key and opts.cv_api_key is None and opts.cv_url is None:
parser.exit(message="Key not given!\n", status=1)
if self.script is not None:
self.launch_script(self.script)
if not opts.only_set_cv_key and opts.no_gui and not opts.files:
parser.exit(message="Command requires at least one filename!\n", status=1)
if len(args) > 0:
if platform.system() == "Windows":
# no globbing on windows shell, so do it for them
import glob
if opts.delete and not opts.type:
parser.exit(message="Please specify the type to delete with -t\n", status=1)
self.file_list = []
for item in args:
self.file_list.extend(glob.glob(item))
if len(self.file_list) > 0:
self.filename = self.file_list[0]
else:
self.filename = args[0]
self.file_list = args
if opts.save and not opts.type:
parser.exit(message="Please specify the type to save with -t\n", status=1)
if self.only_set_key and self.cv_api_key is None:
self.display_msg_and_quit("Key not given!", 1)
if opts.copy:
if not opts.type:
parser.exit(message="Please specify the type to copy to with -t\n", status=1)
if len(opts.copy) > 1:
parser.exit(message="Please specify only one type to copy to with -c\n", status=1)
opts.copy = opts.copy[0]
if (self.only_set_key == False) and self.no_gui and (self.filename is None):
self.display_msg_and_quit("Command requires at least one filename!", 1)
if opts.recursive:
opts.file_list = utils.get_recursive_filelist(opts.files)
else:
opts.file_list = opts.files
if self.delete_tags and self.data_style is None:
self.display_msg_and_quit("Please specify the type to delete with -t", 1)
return opts
if self.save_tags and self.data_style is None:
self.display_msg_and_quit("Please specify the type to save with -t", 1)
if self.copy_tags and self.data_style is None:
self.display_msg_and_quit("Please specify the type to copy to with -t", 1)
# if self.rename_file and self.data_style is None:
# self.display_msg_and_quit("Please specify the type to use for renaming with -t", 1)
if self.recursive:
self.file_list = utils.get_recursive_filelist(self.file_list)

View File

@ -1,38 +1,32 @@
"""A PyQT4 dialog to show pages of a comic archive"""
#
# Copyright 2012-2014 Anthony Beville
#
# 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 platform
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from comicapi.comicarchive import ComicArchive
from comicapi.genericmetadata import GenericMetadata
from comictaggerlib.coverimagewidget import CoverImageWidget
from comictaggerlib.settings import ComicTaggerSettings
logger = logging.getLogger(__name__)
from .coverimagewidget import CoverImageWidget
from .settings import ComicTaggerSettings
class PageBrowserWindow(QtWidgets.QDialog):
def __init__(self, parent: QtWidgets.QWidget, metadata: GenericMetadata) -> None:
super().__init__(parent)
def __init__(self, parent, metadata):
super(PageBrowserWindow, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.get_ui_file("pagebrowser.ui"), self)
uic.loadUi(ComicTaggerSettings.getUIFile("pagebrowser.ui"), self)
self.pageWidget = CoverImageWidget(self.pageContainer, CoverImageWidget.ArchiveMode)
gridlayout = QtWidgets.QGridLayout(self.pageContainer)
@ -40,77 +34,71 @@ class PageBrowserWindow(QtWidgets.QDialog):
gridlayout.setContentsMargins(0, 0, 0, 0)
self.pageWidget.showControls = False
self.setWindowFlags(
QtCore.Qt.WindowType(
self.windowFlags()
| QtCore.Qt.WindowType.WindowSystemMenuHint
| QtCore.Qt.WindowType.WindowMaximizeButtonHint
)
)
self.setWindowFlags(self.windowFlags() | QtCore.Qt.WindowSystemMenuHint | QtCore.Qt.WindowMaximizeButtonHint)
self.comic_archive: ComicArchive | None = None
self.comic_archive = None
self.page_count = 0
self.current_page_num = 0
self.metadata = metadata
self.buttonBox.button(QtWidgets.QDialogButtonBox.StandardButton.Close).setDefault(True)
self.buttonBox.button(QtWidgets.QDialogButtonBox.Close).setDefault(True)
if platform.system() == "Darwin":
self.btnPrev.setText("<<")
self.btnNext.setText(">>")
else:
self.btnPrev.setIcon(QtGui.QIcon(ComicTaggerSettings.get_graphic("left.png")))
self.btnNext.setIcon(QtGui.QIcon(ComicTaggerSettings.get_graphic("right.png")))
self.btnPrev.setIcon(QtGui.QIcon(ComicTaggerSettings.getGraphic("left.png")))
self.btnNext.setIcon(QtGui.QIcon(ComicTaggerSettings.getGraphic("right.png")))
self.btnNext.clicked.connect(self.next_page)
self.btnPrev.clicked.connect(self.prev_page)
self.btnNext.clicked.connect(self.nextPage)
self.btnPrev.clicked.connect(self.prevPage)
self.show()
self.btnNext.setEnabled(False)
self.btnPrev.setEnabled(False)
def reset(self) -> None:
def reset(self):
self.comic_archive = None
self.page_count = 0
self.current_page_num = 0
self.metadata = GenericMetadata()
self.metadata = None
self.btnNext.setEnabled(False)
self.btnPrev.setEnabled(False)
self.pageWidget.clear()
def set_comic_archive(self, ca: ComicArchive) -> None:
def setComicArchive(self, ca):
self.comic_archive = ca
self.page_count = ca.get_number_of_pages()
self.page_count = ca.getNumberOfPages()
self.current_page_num = 0
self.pageWidget.set_archive(self.comic_archive)
self.set_page()
self.pageWidget.setArchive(self.comic_archive)
self.setPage()
if self.page_count > 1:
self.btnNext.setEnabled(True)
self.btnPrev.setEnabled(True)
def next_page(self) -> None:
def nextPage(self):
if self.current_page_num + 1 < self.page_count:
self.current_page_num += 1
else:
self.current_page_num = 0
self.set_page()
self.setPage()
def prev_page(self) -> None:
def prevPage(self):
if self.current_page_num - 1 >= 0:
self.current_page_num -= 1
else:
self.current_page_num = self.page_count - 1
self.set_page()
self.setPage()
def set_page(self) -> None:
if not self.metadata.is_empty:
archive_page_index = self.metadata.get_archive_page_index(self.current_page_num)
def setPage(self):
if self.metadata is not None:
archive_page_index = self.metadata.getArchivePageIndex(self.current_page_num)
else:
archive_page_index = self.current_page_num
self.pageWidget.set_page(archive_page_index)
self.setWindowTitle(f"Page Browser - Page {self.current_page_num + 1} (of {self.page_count}) ")
self.pageWidget.setPage(archive_page_index)
self.setWindowTitle("Page Browser - Page {0} (of {1}) ".format(self.current_page_num + 1, self.page_count))

View File

@ -1,57 +1,62 @@
"""A PyQt5 widget for editing the page list info"""
#
# Copyright 2012-2014 Anthony Beville
#
# 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 sys
from operator import attrgetter, itemgetter
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from PyQt5 import uic
from PyQt5.QtCore import *
from PyQt5.QtGui import *
from PyQt5.QtWidgets import *
from comicapi.comicarchive import ComicArchive, MetaDataStyle
from comicapi.genericmetadata import ImageMetadata, PageType
from comictaggerlib.coverimagewidget import CoverImageWidget
from comictaggerlib.settings import ComicTaggerSettings
logger = logging.getLogger(__name__)
from .comicarchive import MetaDataStyle
from .coverimagewidget import CoverImageWidget
from .genericmetadata import GenericMetadata, PageType
from .settings import ComicTaggerSettings
def item_move_events(widget: QtWidgets.QWidget) -> QtCore.pyqtBoundSignal:
class Filter(QtCore.QObject):
def itemMoveEvents(widget):
class Filter(QObject):
mysignal = QtCore.pyqtSignal(str)
mysignal = pyqtSignal(str)
def eventFilter(self, obj: QtCore.QObject, event: QtCore.QEvent) -> bool:
def eventFilter(self, obj, event):
if obj == widget:
if event.type() == QtCore.QEvent.Type.ChildRemoved:
# print(event.type())
if event.type() == QEvent.ChildRemoved:
# print("ChildRemoved")
self.mysignal.emit("finish")
if event.type() == QtCore.QEvent.Type.ChildAdded:
if event.type() == QEvent.ChildAdded:
# print("ChildAdded")
self.mysignal.emit("start")
return True
return False
filt = Filter(widget)
widget.installEventFilter(filt)
return filt.mysignal
filter = Filter(widget)
widget.installEventFilter(filter)
return filter.mysignal
class PageListEditor(QtWidgets.QWidget):
firstFrontCoverChanged = QtCore.pyqtSignal(int)
listOrderChanged = QtCore.pyqtSignal()
modified = QtCore.pyqtSignal()
class PageListEditor(QWidget):
firstFrontCoverChanged = pyqtSignal(int)
listOrderChanged = pyqtSignal()
modified = pyqtSignal()
pageTypeNames = {
PageType.FrontCover: "Front Cover",
@ -67,125 +72,115 @@ class PageListEditor(QtWidgets.QWidget):
PageType.Deleted: "Deleted",
}
def __init__(self, parent: QtWidgets.QWidget) -> None:
super().__init__(parent)
def __init__(self, parent):
super(PageListEditor, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.get_ui_file("pagelisteditor.ui"), self)
uic.loadUi(ComicTaggerSettings.getUIFile("pagelisteditor.ui"), self)
self.setEnabled(True)
self.pageWidget = CoverImageWidget(self.pageContainer, CoverImageWidget.ArchiveMode)
gridlayout = QtWidgets.QGridLayout(self.pageContainer)
gridlayout = QGridLayout(self.pageContainer)
gridlayout.addWidget(self.pageWidget)
gridlayout.setContentsMargins(0, 0, 0, 0)
self.pageWidget.showControls = False
self.reset_page()
self.resetPage()
# Add the entries to the page type combobox
self.add_page_type_item("", "", "Alt+0", False)
self.add_page_type_item(self.pageTypeNames[PageType.FrontCover], PageType.FrontCover, "Alt+F")
self.add_page_type_item(self.pageTypeNames[PageType.InnerCover], PageType.InnerCover, "Alt+I")
self.add_page_type_item(self.pageTypeNames[PageType.Advertisement], PageType.Advertisement, "Alt+A")
self.add_page_type_item(self.pageTypeNames[PageType.Roundup], PageType.Roundup, "Alt+R")
self.add_page_type_item(self.pageTypeNames[PageType.Story], PageType.Story, "Alt+S")
self.add_page_type_item(self.pageTypeNames[PageType.Editorial], PageType.Editorial, "Alt+E")
self.add_page_type_item(self.pageTypeNames[PageType.Letters], PageType.Letters, "Alt+L")
self.add_page_type_item(self.pageTypeNames[PageType.Preview], PageType.Preview, "Alt+P")
self.add_page_type_item(self.pageTypeNames[PageType.BackCover], PageType.BackCover, "Alt+B")
self.add_page_type_item(self.pageTypeNames[PageType.Other], PageType.Other, "Alt+O")
self.add_page_type_item(self.pageTypeNames[PageType.Deleted], PageType.Deleted, "Alt+X")
# Add the entries to the manga combobox
self.comboBox.addItem("", "")
self.comboBox.addItem(self.pageTypeNames[PageType.FrontCover], PageType.FrontCover)
self.comboBox.addItem(self.pageTypeNames[PageType.InnerCover], PageType.InnerCover)
self.comboBox.addItem(self.pageTypeNames[PageType.Advertisement], PageType.Advertisement)
self.comboBox.addItem(self.pageTypeNames[PageType.Roundup], PageType.Roundup)
self.comboBox.addItem(self.pageTypeNames[PageType.Story], PageType.Story)
self.comboBox.addItem(self.pageTypeNames[PageType.Editorial], PageType.Editorial)
self.comboBox.addItem(self.pageTypeNames[PageType.Letters], PageType.Letters)
self.comboBox.addItem(self.pageTypeNames[PageType.Preview], PageType.Preview)
self.comboBox.addItem(self.pageTypeNames[PageType.BackCover], PageType.BackCover)
self.comboBox.addItem(self.pageTypeNames[PageType.Other], PageType.Other)
self.comboBox.addItem(self.pageTypeNames[PageType.Deleted], PageType.Deleted)
self.listWidget.itemSelectionChanged.connect(self.change_page)
item_move_events(self.listWidget).connect(self.item_move_event)
self.cbPageType.activated.connect(self.change_page_type)
self.chkDoublePage.clicked.connect(self.toggle_double_page)
self.leBookmark.editingFinished.connect(self.save_bookmark)
self.btnUp.clicked.connect(self.move_current_up)
self.btnDown.clicked.connect(self.move_current_down)
self.listWidget.itemSelectionChanged.connect(self.changePage)
itemMoveEvents(self.listWidget).connect(self.itemMoveEvent)
self.comboBox.activated.connect(self.changePageType)
self.btnUp.clicked.connect(self.moveCurrentUp)
self.btnDown.clicked.connect(self.moveCurrentDown)
self.pre_move_row = -1
self.first_front_page: int | None = None
self.first_front_page = None
self.comic_archive: ComicArchive | None = None
self.pages_list: list[ImageMetadata] = []
def toggleAd(self):
ad = self.comboBox.findData(PageType.Advertisement)
if self.comboBox.currentIndex() == ad:
self.comboBox.setCurrentIndex(0)
self.changePageType(0)
else:
self.comboBox.setCurrentIndex(ad)
self.changePageType(ad)
def reset_page(self) -> None:
def resetPage(self):
self.pageWidget.clear()
self.cbPageType.setDisabled(True)
self.chkDoublePage.setDisabled(True)
self.leBookmark.setDisabled(True)
self.comboBox.setDisabled(True)
self.comic_archive = None
self.pages_list = []
self.pages_list = None
def add_page_type_item(self, text: str, user_data: str, shortcut: str, show_shortcut: bool = True) -> None:
if show_shortcut:
text = text + " (" + shortcut + ")"
self.cbPageType.addItem(text, user_data)
action_item = QtWidgets.QAction(shortcut, self)
action_item.triggered.connect(lambda: self.select_page_type_item(self.cbPageType.findData(user_data)))
action_item.setShortcut(shortcut)
self.addAction(action_item)
def select_page_type_item(self, idx: int) -> None:
if self.cbPageType.isEnabled():
self.cbPageType.setCurrentIndex(idx)
self.change_page_type(idx)
def get_new_indexes(self, movement: int) -> list[tuple[int, int]]:
def getNewIndexes(self, movement):
selection = self.listWidget.selectionModel().selectedRows()
selection.sort(reverse=movement > 0)
new_indexes: list[int] = []
old_indexes: list[int] = []
current = 0
newindexes = []
oldindexes = []
for x in selection:
current = x.row()
old_indexes.append(current)
if 0 <= current + movement <= self.listWidget.count() - 1:
if len(new_indexes) < 1 or current + movement != new_indexes[-1]:
oldindexes.append(current)
if current + movement >= 0 and current + movement <= self.listWidget.count() - 1:
if len(newindexes) < 1 or current + movement != newindexes[-1]:
current += movement
else:
prev = current
newindexes.append(current)
oldindexes.sort()
newindexes.sort()
return list(zip(newindexes, oldindexes))
new_indexes.append(current)
old_indexes.sort()
new_indexes.sort()
return list(zip(new_indexes, old_indexes))
def set_selection(self, indexes: list[tuple[int, int]]) -> list[tuple[int, int]]:
selection_ranges: list[tuple[int, int]] = []
def SetSelection(self, indexes):
selectionRanges = []
first = 0
for i, sel in enumerate(indexes):
for i, selection in enumerate(indexes):
if i == 0:
first = sel[0]
first = selection[0]
continue
if sel[0] != indexes[i - 1][0] + 1:
selection_ranges.append((first, indexes[i - 1][0]))
first = sel[0]
if selection != indexes[i - 1][0] + 1:
selectionRanges.append((first, indexes[i - 1][0]))
first = selection[0]
selection_ranges.append((first, indexes[-1][0]))
selection = QtCore.QItemSelection()
for x in selection_ranges:
selectionRanges.append((first, indexes[-1][0]))
selection = QItemSelection()
for x in selectionRanges:
selection.merge(
QtCore.QItemSelection(self.listWidget.model().index(x[0], 0), self.listWidget.model().index(x[1], 0)),
QtCore.QItemSelectionModel.SelectionFlag.Select,
QItemSelection(self.listWidget.model().index(x[0], 0), self.listWidget.model().index(x[1], 0)), QItemSelectionModel.Select
)
self.listWidget.selectionModel().select(selection, QtCore.QItemSelectionModel.SelectionFlag.ClearAndSelect)
return selection_ranges
self.listWidget.selectionModel().select(selection, QItemSelectionModel.ClearAndSelect)
return selectionRanges
def move_current_up(self) -> None:
def moveCurrentUp(self):
row = self.listWidget.currentRow()
selection = self.get_new_indexes(-1)
selection = self.getNewIndexes(-1)
for sel in selection:
item = self.listWidget.takeItem(sel[1])
self.listWidget.insertItem(sel[0], item)
if row > 0:
self.listWidget.setCurrentRow(row - 1)
self.set_selection(selection)
self.SetSelection(selection)
self.listOrderChanged.emit()
self.emit_front_cover_change()
self.emitFrontCoverChange()
self.modified.emit()
def move_current_down(self) -> None:
def moveCurrentDown(self):
row = self.listWidget.currentRow()
selection = self.get_new_indexes(1)
selection = self.getNewIndexes(1)
selection.sort(reverse=True)
for sel in selection:
item = self.listWidget.takeItem(sel[1])
@ -194,205 +189,139 @@ class PageListEditor(QtWidgets.QWidget):
if row < self.listWidget.count() - 1:
self.listWidget.setCurrentRow(row + 1)
self.listOrderChanged.emit()
self.emit_front_cover_change()
self.set_selection(selection)
self.emitFrontCoverChange()
self.SetSelection(selection)
self.modified.emit()
def item_move_event(self, s: str) -> None:
def itemMoveEvent(self, s):
# print "move event: ", s, self.listWidget.currentRow()
if s == "start":
self.pre_move_row = self.listWidget.currentRow()
if s == "finish":
if self.pre_move_row != self.listWidget.currentRow():
self.listOrderChanged.emit()
self.emit_front_cover_change()
self.emitFrontCoverChange()
self.modified.emit()
def change_page_type(self, i: int) -> None:
new_type = self.cbPageType.itemData(i)
if self.get_current_page_type() != new_type:
self.set_current_page_type(new_type)
self.emit_front_cover_change()
def changePageType(self, i):
new_type = self.comboBox.itemData(i)
if self.getCurrentPageType() != new_type:
self.setCurrentPageType(new_type)
self.emitFrontCoverChange()
self.modified.emit()
def change_page(self) -> None:
def changePage(self):
row = self.listWidget.currentRow()
pagetype = self.get_current_page_type()
pagetype = self.getCurrentPageType()
i = self.cbPageType.findData(pagetype)
self.cbPageType.setCurrentIndex(i)
i = self.comboBox.findData(pagetype)
self.comboBox.setCurrentIndex(i)
self.chkDoublePage.setChecked("DoublePage" in self.listWidget.item(row).data(QtCore.Qt.UserRole)[0])
if "Bookmark" in self.listWidget.item(row).data(QtCore.Qt.UserRole)[0]:
self.leBookmark.setText(self.listWidget.item(row).data(QtCore.Qt.UserRole)[0]["Bookmark"])
else:
self.leBookmark.setText("")
idx = int(self.listWidget.item(row).data(QtCore.Qt.ItemDataRole.UserRole)[0]["Image"])
# idx = int(str (self.listWidget.item(row).text()))
idx = int(self.listWidget.item(row).data(Qt.UserRole)[0]["Image"])
if self.comic_archive is not None:
self.pageWidget.set_archive(self.comic_archive, idx)
self.pageWidget.setArchive(self.comic_archive, idx)
def get_first_front_cover(self) -> int:
front_cover = 0
def getFirstFrontCover(self):
frontCover = 0
for i in range(self.listWidget.count()):
item = self.listWidget.item(i)
page_dict: ImageMetadata = item.data(QtCore.Qt.ItemDataRole.UserRole)[0]
page_dict = item.data(Qt.UserRole)[0] # .toPyObject()[0]
if "Type" in page_dict and page_dict["Type"] == PageType.FrontCover:
front_cover = int(page_dict["Image"])
frontCover = int(page_dict["Image"])
break
return front_cover
return frontCover
def get_current_page_type(self) -> str:
def getCurrentPageType(self):
row = self.listWidget.currentRow()
page_dict: ImageMetadata = self.listWidget.item(row).data(QtCore.Qt.ItemDataRole.UserRole)[0]
page_dict = self.listWidget.item(row).data(Qt.UserRole)[0] # .toPyObject()[0]
if "Type" in page_dict:
return page_dict["Type"]
else:
return ""
return ""
def set_current_page_type(self, t: str) -> None:
def setCurrentPageType(self, t):
row = self.listWidget.currentRow()
page_dict: ImageMetadata = self.listWidget.item(row).data(QtCore.Qt.ItemDataRole.UserRole)[0]
page_dict = self.listWidget.item(row).data(Qt.UserRole)[0] # .toPyObject()[0]
if t == "":
if "Type" in page_dict:
del page_dict["Type"]
else:
page_dict["Type"] = t
item = self.listWidget.item(row)
# wrap the dict in a tuple to keep from being converted to QtWidgets.QStrings
item.setData(QtCore.Qt.ItemDataRole.UserRole, (page_dict,))
item.setText(self.list_entry_text(page_dict))
def toggle_double_page(self) -> None:
row = self.listWidget.currentRow()
page_dict: ImageMetadata = self.listWidget.item(row).data(QtCore.Qt.UserRole)[0]
cbx = self.sender()
if isinstance(cbx, QtWidgets.QCheckBox) and cbx.isChecked():
if "DoublePage" not in page_dict:
page_dict["DoublePage"] = True
self.modified.emit()
elif "DoublePage" in page_dict:
del page_dict["DoublePage"]
self.modified.emit()
page_dict["Type"] = str(t)
item = self.listWidget.item(row)
# wrap the dict in a tuple to keep from being converted to QStrings
item.setData(QtCore.Qt.UserRole, (page_dict,))
item.setText(self.list_entry_text(page_dict))
item.setData(Qt.UserRole, (page_dict,))
item.setText(self.listEntryText(page_dict))
self.listWidget.setFocus()
def save_bookmark(self) -> None:
row = self.listWidget.currentRow()
page_dict: ImageMetadata = self.listWidget.item(row).data(QtCore.Qt.UserRole)[0]
current_bookmark = ""
if "Bookmark" in page_dict:
current_bookmark = page_dict["Bookmark"]
if self.leBookmark.text().strip():
new_bookmark = str(self.leBookmark.text().strip())
if current_bookmark != new_bookmark:
page_dict["Bookmark"] = new_bookmark
self.modified.emit()
elif current_bookmark != "":
del page_dict["Bookmark"]
self.modified.emit()
item = self.listWidget.item(row)
# wrap the dict in a tuple to keep from being converted to QStrings
item.setData(QtCore.Qt.UserRole, (page_dict,))
item.setText(self.list_entry_text(page_dict))
self.listWidget.setFocus()
def set_data(self, comic_archive: ComicArchive, pages_list: list[ImageMetadata]) -> None:
def setData(self, comic_archive, pages_list):
self.comic_archive = comic_archive
self.pages_list = pages_list
if pages_list is not None and len(pages_list) > 0:
self.cbPageType.setDisabled(False)
self.chkDoublePage.setDisabled(False)
self.leBookmark.setDisabled(False)
self.comboBox.setDisabled(False)
self.listWidget.itemSelectionChanged.disconnect(self.change_page)
self.listWidget.itemSelectionChanged.disconnect(self.changePage)
self.listWidget.clear()
for p in pages_list:
item = QtWidgets.QListWidgetItem(self.list_entry_text(p))
# wrap the dict in a tuple to keep from being converted to QtWidgets.QStrings
item.setData(QtCore.Qt.ItemDataRole.UserRole, (p,))
item = QListWidgetItem(self.listEntryText(p))
# wrap the dict in a tuple to keep from being converted to QStrings
item.setData(Qt.UserRole, (p,))
self.listWidget.addItem(item)
self.first_front_page = self.get_first_front_cover()
self.listWidget.itemSelectionChanged.connect(self.change_page)
self.first_front_page = self.getFirstFrontCover()
self.listWidget.itemSelectionChanged.connect(self.changePage)
self.listWidget.setCurrentRow(0)
def list_entry_text(self, page_dict: ImageMetadata) -> str:
def listEntryText(self, page_dict):
text = str(int(page_dict["Image"]) + 1)
if "Type" in page_dict:
if page_dict["Type"] in self.pageTypeNames:
text += " (" + self.pageTypeNames[page_dict["Type"]] + ")"
else:
text += " (Error: " + page_dict["Type"] + ")"
if "DoublePage" in page_dict:
text += " " + "\U00002461"
if "Bookmark" in page_dict:
text += " " + "\U0001F516"
text += " (" + self.pageTypeNames[page_dict["Type"]] + ")"
return text
def get_page_list(self) -> list[ImageMetadata]:
def getPageList(self):
page_list = []
for i in range(self.listWidget.count()):
item = self.listWidget.item(i)
page_list.append(item.data(QtCore.Qt.ItemDataRole.UserRole)[0])
page_list.append(item.data(Qt.UserRole)[0]) # .toPyObject()[0]
return page_list
def emit_front_cover_change(self) -> None:
if self.first_front_page != self.get_first_front_cover():
self.first_front_page = self.get_first_front_cover()
def emitFrontCoverChange(self):
if self.first_front_page != self.getFirstFrontCover():
self.first_front_page = self.getFirstFrontCover()
self.firstFrontCoverChanged.emit(self.first_front_page)
def set_metadata_style(self, data_style: int) -> None:
def setMetadataStyle(self, data_style):
# depending on the current data style, certain fields are disabled
inactive_color = QtGui.QColor(255, 170, 150)
active_palette = self.cbPageType.palette()
inactive_color = QColor(255, 170, 150)
active_palette = self.comboBox.palette()
inactive_palette3 = self.cbPageType.palette()
inactive_palette3.setColor(QtGui.QPalette.ColorRole.Base, inactive_color)
inactive_palette3 = self.comboBox.palette()
inactive_palette3.setColor(QPalette.Base, inactive_color)
if data_style == MetaDataStyle.CIX:
self.btnUp.setEnabled(True)
self.btnDown.setEnabled(True)
self.cbPageType.setEnabled(True)
self.chkDoublePage.setEnabled(True)
self.leBookmark.setEnabled(True)
self.comboBox.setEnabled(True)
self.listWidget.setEnabled(True)
self.leBookmark.setPalette(active_palette)
self.listWidget.setPalette(active_palette)
elif data_style == MetaDataStyle.CBI:
self.btnUp.setEnabled(False)
self.btnDown.setEnabled(False)
self.cbPageType.setEnabled(False)
self.chkDoublePage.setEnabled(False)
self.leBookmark.setEnabled(False)
self.comboBox.setEnabled(False)
self.listWidget.setEnabled(False)
self.leBookmark.setPalette(inactive_palette3)
self.listWidget.setPalette(inactive_palette3)
elif data_style == MetaDataStyle.COMET:
elif data_style == MetaDataStyle.CoMet:
pass
# make sure combo is disabled when no list
if self.comic_archive is None:
self.cbPageType.setEnabled(False)
self.chkDoublePage.setEnabled(False)
self.leBookmark.setEnabled(False)
self.comboBox.setEnabled(False)

View File

@ -1,30 +1,27 @@
"""A PyQT4 class to load a page image from a ComicArchive in a background thread"""
#
# Copyright 2012-2014 Anthony Beville
#
# 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
from PyQt5 import QtCore, QtGui, uic
from PyQt5.QtCore import pyqtSignal
from PyQt5 import QtCore
from comicapi.comicarchive import ComicArchive
logger = logging.getLogger(__name__)
from comictaggerlib.ui.qtutils import getQImageFromData
class PageLoader(QtCore.QThread):
"""
This class holds onto a reference of each instance in a list since
problems occur if the ref count goes to zero and the GC tries to reap
@ -33,36 +30,39 @@ class PageLoader(QtCore.QThread):
"abandoned", and no signals will be issued.
"""
loadComplete = QtCore.pyqtSignal(bytes)
loadComplete = pyqtSignal(QtGui.QImage)
instanceList: list[QtCore.QThread] = []
instanceList = []
mutex = QtCore.QMutex()
# Remove all finished threads from the list
@staticmethod
def reap_instances() -> None:
def reapInstances():
for obj in reversed(PageLoader.instanceList):
if obj.isFinished():
PageLoader.instanceList.remove(obj)
def __init__(self, ca: ComicArchive, page_num: int) -> None:
def __init__(self, ca, page_num):
QtCore.QThread.__init__(self)
self.ca: ComicArchive = ca
self.page_num: int = page_num
self.ca = ca
self.page_num = page_num
self.abandoned = False
# remove any old instances, and then add ourself
PageLoader.mutex.lock()
PageLoader.reap_instances()
PageLoader.reapInstances()
PageLoader.instanceList.append(self)
PageLoader.mutex.unlock()
def run(self) -> None:
image_data = self.ca.get_page(self.page_num)
def run(self):
image_data = self.ca.getPage(self.page_num)
if self.abandoned:
return
if image_data:
if image_data is not None:
img = getQImageFromData(image_data)
if self.abandoned:
return
self.loadComplete.emit(image_data)
self.loadComplete.emit(img)

View File

@ -1,42 +1,33 @@
"""A PyQt5 dialog to show ID log and progress"""
#
"""A PyQT5 dialog to show ID log and progress"""
# Copyright 2012-2014 Anthony Beville
#
# 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
from PyQt5 import QtCore, QtWidgets, uic
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from comictaggerlib.settings import ComicTaggerSettings
from comictaggerlib.ui.qtutils import reduce_widget_font_size
from comictaggerlib.ui.qtutils import reduceWidgetFontSize
logger = logging.getLogger(__name__)
from .settings import ComicTaggerSettings
class IDProgressWindow(QtWidgets.QDialog):
def __init__(self, parent: QtWidgets.QWidget) -> None:
super().__init__(parent)
def __init__(self, parent):
super(IDProgressWindow, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.get_ui_file("progresswindow.ui"), self)
uic.loadUi(ComicTaggerSettings.getUIFile("progresswindow.ui"), self)
self.setWindowFlags(
QtCore.Qt.WindowType(
self.windowFlags()
| QtCore.Qt.WindowType.WindowSystemMenuHint
| QtCore.Qt.WindowType.WindowMaximizeButtonHint
)
)
self.setWindowFlags(self.windowFlags() | QtCore.Qt.WindowSystemMenuHint | QtCore.Qt.WindowMaximizeButtonHint)
reduce_widget_font_size(self.textEdit)
reduceWidgetFontSize(self.textEdit)

View File

@ -1,128 +1,91 @@
"""A PyQT4 dialog to confirm rename"""
#
# Copyright 2012-2014 Anthony Beville
#
# 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
from PyQt5 import QtCore, QtWidgets, uic
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from comicapi import utils
from comicapi.comicarchive import ComicArchive, MetaDataStyle
from comicapi.genericmetadata import GenericMetadata
from comictaggerlib.filerenamer import FileRenamer, get_rename_dir
from comictaggerlib.settings import ComicTaggerSettings
from comictaggerlib.settingswindow import SettingsWindow
from comictaggerlib.ui.qtutils import center_window_on_parent
from comictaggerlib.ui.qtutils import centerWindowOnParent
logger = logging.getLogger(__name__)
from . import utils
from .comicarchive import MetaDataStyle
from .filerenamer import FileRenamer
from .settings import ComicTaggerSettings
from .settingswindow import SettingsWindow
class RenameWindow(QtWidgets.QDialog):
def __init__(
self,
parent: QtWidgets.QWidget,
comic_archive_list: list[ComicArchive],
data_style: int,
settings: ComicTaggerSettings,
) -> None:
super().__init__(parent)
def __init__(self, parent, comic_archive_list, data_style, settings):
super(RenameWindow, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.get_ui_file("renamewindow.ui"), self)
self.label.setText(f"Preview (based on {MetaDataStyle.name[data_style]} tags):")
uic.loadUi(ComicTaggerSettings.getUIFile("renamewindow.ui"), self)
self.label.setText("Preview (based on {0} tags):".format(MetaDataStyle.name[data_style]))
self.setWindowFlags(
QtCore.Qt.WindowType(
self.windowFlags()
| QtCore.Qt.WindowType.WindowSystemMenuHint
| QtCore.Qt.WindowType.WindowMaximizeButtonHint
)
)
self.setWindowFlags(self.windowFlags() | QtCore.Qt.WindowSystemMenuHint | QtCore.Qt.WindowMaximizeButtonHint)
self.settings = settings
self.comic_archive_list = comic_archive_list
self.data_style = data_style
self.rename_list: list[str] = []
self.btnSettings.clicked.connect(self.modify_settings)
self.renamer = FileRenamer(None, platform="universal" if self.settings.rename_strict else "auto")
self.btnSettings.clicked.connect(self.modifySettings)
self.configRenamer()
self.doPreview()
self.do_preview()
def configRenamer(self):
self.renamer = FileRenamer(None)
self.renamer.setTemplate(self.settings.rename_template)
self.renamer.setIssueZeroPadding(self.settings.rename_issue_number_padding)
self.renamer.setSmartCleanup(self.settings.rename_use_smart_string_cleanup)
def config_renamer(self, ca: ComicArchive, md: GenericMetadata | None = None) -> str:
self.renamer.set_template(self.settings.rename_template)
self.renamer.set_issue_zero_padding(self.settings.rename_issue_number_padding)
self.renamer.set_smart_cleanup(self.settings.rename_use_smart_string_cleanup)
new_ext = ca.path.suffix # default
if self.settings.rename_extension_based_on_archive:
if ca.is_sevenzip():
new_ext = ".cb7"
elif ca.is_zip():
new_ext = ".cbz"
elif ca.is_rar():
new_ext = ".cbr"
if md is None:
md = ca.read_metadata(self.data_style)
if md.is_empty:
md = ca.metadata_from_filename(
self.settings.complicated_parser,
self.settings.remove_c2c,
self.settings.remove_fcbd,
self.settings.remove_publisher,
)
self.renamer.set_metadata(md)
self.renamer.move = self.settings.rename_move_dir
return new_ext
def do_preview(self) -> None:
self.twList.setRowCount(0)
def doPreview(self):
self.rename_list = []
while self.twList.rowCount() > 0:
self.twList.removeRow(0)
self.twList.setSortingEnabled(False)
for ca in self.comic_archive_list:
new_ext = self.config_renamer(ca)
new_ext = None # default
if self.settings.rename_extension_based_on_archive:
if ca.isZip():
new_ext = ".cbz"
elif ca.isRar():
new_ext = ".cbr"
md = ca.readMetadata(self.data_style)
if md.isEmpty:
md = ca.metadataFromFilename(self.settings.parse_scan_info)
self.renamer.setMetadata(md)
self.renamer.move = self.settings.rename_move_dir
try:
new_name = self.renamer.determine_name(new_ext)
except ValueError as e:
logger.exception("Invalid format string: %s", self.settings.rename_template)
new_name = self.renamer.determineName(ca.path, ext=new_ext)
except Exception as e:
QtWidgets.QMessageBox.critical(
self,
"Invalid format string!",
"Your rename template is invalid!"
f"<br/><br/>{e}<br/><br/>"
"<br/><br/>{}<br/><br/>"
"Please consult the template help in the "
"settings and the documentation on the format at "
"<a href='https://docs.python.org/3/library/string.html#format-string-syntax'>"
"https://docs.python.org/3/library/string.html#format-string-syntax</a>",
"https://docs.python.org/3/library/string.html#format-string-syntax</a>".format(e),
)
return
except Exception as e:
logger.exception(
"Formatter failure: %s metadata: %s", self.settings.rename_template, self.renamer.metadata
)
QtWidgets.QMessageBox.critical(
self,
"The formatter had an issue!",
"The formatter has experienced an unexpected error!"
f"<br/><br/>{type(e).__name__}: {e}<br/><br/>"
"Please open an issue at "
"<a href='https://github.com/comictagger/comictagger'>"
"https://github.com/comictagger/comictagger</a>",
)
row = self.twList.rowCount()
self.twList.insertRow(row)
@ -130,24 +93,27 @@ class RenameWindow(QtWidgets.QDialog):
old_name_item = QtWidgets.QTableWidgetItem()
new_name_item = QtWidgets.QTableWidgetItem()
item_text = str(ca.path.parent)
folder_item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
item_text = os.path.split(ca.path)[0]
folder_item.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 0, folder_item)
folder_item.setText(item_text)
folder_item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
folder_item.setData(QtCore.Qt.ToolTipRole, item_text)
item_text = str(ca.path.name)
old_name_item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
item_text = os.path.split(ca.path)[1]
old_name_item.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 1, old_name_item)
old_name_item.setText(item_text)
old_name_item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
old_name_item.setData(QtCore.Qt.ToolTipRole, item_text)
new_name_item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
new_name_item.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 2, new_name_item)
new_name_item.setText(new_name)
new_name_item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, new_name)
new_name_item.setData(QtCore.Qt.ToolTipRole, new_name)
self.rename_list.append(new_name)
dict_item = dict()
dict_item["archive"] = ca
dict_item["new_name"] = new_name
self.rename_list.append(dict_item)
# Adjust column sizes
self.twList.setVisible(False)
@ -158,48 +124,55 @@ class RenameWindow(QtWidgets.QDialog):
self.twList.setSortingEnabled(True)
def modify_settings(self) -> None:
def modifySettings(self):
settingswin = SettingsWindow(self, self.settings)
settingswin.setModal(True)
settingswin.show_rename_tab()
settingswin.exec()
settingswin.showRenameTab()
settingswin.exec_()
if settingswin.result():
self.do_preview()
self.configRenamer()
self.doPreview()
def accept(self) -> None:
def accept(self):
prog_dialog = QtWidgets.QProgressDialog("", "Cancel", 0, len(self.rename_list), self)
prog_dialog.setWindowTitle("Renaming Archives")
prog_dialog.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
prog_dialog.setMinimumDuration(100)
center_window_on_parent(prog_dialog)
progdialog = QtWidgets.QProgressDialog("", "Cancel", 0, len(self.rename_list), self)
progdialog.setWindowTitle("Renaming Archives")
progdialog.setWindowModality(QtCore.Qt.WindowModal)
progdialog.setMinimumDuration(100)
centerWindowOnParent(progdialog)
# progdialog.show()
QtCore.QCoreApplication.processEvents()
for idx, comic in enumerate(zip(self.comic_archive_list, self.rename_list)):
for idx, item in enumerate(self.rename_list):
QtCore.QCoreApplication.processEvents()
if prog_dialog.wasCanceled():
if progdialog.wasCanceled():
break
idx += 1
prog_dialog.setValue(idx)
prog_dialog.setLabelText(comic[1])
center_window_on_parent(prog_dialog)
progdialog.setValue(idx)
progdialog.setLabelText(item["new_name"])
centerWindowOnParent(progdialog)
QtCore.QCoreApplication.processEvents()
folder = get_rename_dir(comic[0], self.settings.rename_dir if self.settings.rename_move_dir else None)
folder = os.path.dirname(os.path.abspath(item["archive"].path))
if self.settings.rename_move_dir and len(self.settings.rename_dir.strip()) > 3:
folder = self.settings.rename_dir.strip()
full_path = folder / comic[1]
new_abs_path = utils.unique_file(os.path.join(folder, item["new_name"]))
if full_path == comic[0].path:
logger.info("%s: Filename is already good!", comic[1])
if os.path.join(folder, item["new_name"]) == item["archive"].path:
print(item["new_name"], "Filename is already good!")
continue
if not comic[0].is_writable(check_rar_status=False):
if not item["archive"].isWritable(check_rar_status=False):
continue
comic[0].rename(utils.unique_file(full_path))
os.makedirs(os.path.dirname(new_abs_path), 0o777, True)
os.rename(item["archive"].path, new_abs_path)
prog_dialog.hide()
item["archive"].rename(new_abs_path)
progdialog.hide()
QtCore.QCoreApplication.processEvents()
QtWidgets.QDialog.accept(self)

View File

@ -1,162 +0,0 @@
from __future__ import annotations
from typing_extensions import NotRequired, Required, TypedDict
from comicapi.comicarchive import ComicArchive
class IssueResult(TypedDict):
series: str
distance: int
issue_number: str
cv_issue_count: int
url_image_hash: int
issue_title: str
issue_id: int # int?
volume_id: int # int?
month: int | None
year: int | None
publisher: str | None
image_url: str
thumb_url: str
page_url: str
description: str
class OnlineMatchResults:
def __init__(self) -> None:
self.good_matches: list[str] = []
self.no_matches: list[str] = []
self.multiple_matches: list[MultipleMatch] = []
self.low_confidence_matches: list[MultipleMatch] = []
self.write_failures: list[str] = []
self.fetch_data_failures: list[str] = []
class MultipleMatch:
def __init__(self, ca: ComicArchive, match_list: list[IssueResult]) -> None:
self.ca: ComicArchive = ca
self.matches: list[IssueResult] = match_list
class SelectDetails(TypedDict):
image_url: str | None
thumb_image_url: str | None
cover_date: str | None
site_detail_url: str | None
class CVResult(TypedDict):
error: str
limit: int
offset: int
number_of_page_results: int
number_of_total_results: int
status_code: int
results: (
CVIssuesResults
| CVIssueDetailResults
| CVVolumeResults
| list[CVIssuesResults]
| list[CVVolumeResults]
| list[CVIssueDetailResults]
)
version: str
class CVImage(TypedDict, total=False):
icon_url: str
medium_url: str
screen_url: str
screen_large_url: str
small_url: str
super_url: Required[str]
thumb_url: str
tiny_url: str
original_url: str
image_tags: str
class CVVolume(TypedDict):
api_detail_url: str
id: int
name: str
site_detail_url: str
class CVIssuesResults(TypedDict):
cover_date: str
description: str
id: int
image: CVImage
issue_number: str
name: str
site_detail_url: str
volume: NotRequired[CVVolume]
aliases: str
class CVPublisher(TypedDict, total=False):
api_detail_url: str
id: int
name: Required[str]
class CVVolumeResults(TypedDict):
count_of_issues: int
description: NotRequired[str]
id: int
image: NotRequired[CVImage]
name: str
publisher: CVPublisher
start_year: str
resource_type: NotRequired[str]
aliases: NotRequired[str | None]
class CVCredits(TypedDict):
api_detail_url: str
id: int
name: str
site_detail_url: str
class CVPersonCredits(TypedDict):
api_detail_url: str
id: int
name: str
site_detail_url: str
role: str
class CVIssueDetailResults(TypedDict):
aliases: None
api_detail_url: str
character_credits: list[CVCredits]
character_died_in: None
concept_credits: list[CVCredits]
cover_date: str
date_added: str
date_last_updated: str
deck: None
description: str
first_appearance_characters: None
first_appearance_concepts: None
first_appearance_locations: None
first_appearance_objects: None
first_appearance_storyarcs: None
first_appearance_teams: None
has_staff_review: bool
id: int
image: CVImage
issue_number: str
location_credits: list[CVCredits]
name: str
object_credits: list[CVCredits]
person_credits: list[CVPersonCredits]
site_detail_url: str
store_date: str
story_arc_credits: list[CVCredits]
team_credits: list[CVCredits]
team_disbanded_in: None
volume: CVVolume

View File

@ -1,65 +1,70 @@
"""Settings class for ComicTagger app"""
#
# Copyright 2012-2014 Anthony Beville
#
# 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 codecs
import configparser
import logging
import os
import pathlib
import platform
import sys
import uuid
from collections.abc import Iterator
from typing import TextIO, no_type_check
from comicapi import utils
logger = logging.getLogger(__name__)
from . import utils
class ComicTaggerSettings:
folder: pathlib.Path | str = ""
@staticmethod
def getSettingsFolder():
filename_encoding = sys.getfilesystemencoding()
if platform.system() == "Windows":
folder = os.path.join(os.environ["APPDATA"], "ComicTagger")
else:
folder = os.path.join(os.path.expanduser("~"), ".ComicTagger")
if folder is not None:
folder = folder
return folder
@staticmethod
def get_settings_folder() -> pathlib.Path:
if not ComicTaggerSettings.folder:
if platform.system() == "Windows":
ComicTaggerSettings.folder = pathlib.Path(os.environ["APPDATA"]) / "ComicTagger"
else:
ComicTaggerSettings.folder = pathlib.Path(os.path.expanduser("~")) / ".ComicTagger"
return pathlib.Path(ComicTaggerSettings.folder)
def defaultLibunrarPath():
return ComicTaggerSettings.baseDir() + "/libunrar.so"
@staticmethod
def base_dir() -> pathlib.Path:
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
return pathlib.Path(sys._MEIPASS) # type: ignore[attr-defined]
return pathlib.Path(__file__).parent
def haveOwnUnrarLib():
return os.path.exists(ComicTaggerSettings.defaultLibunrarPath())
@staticmethod
def get_graphic(filename: str | pathlib.Path) -> str:
return str(ComicTaggerSettings.base_dir() / "graphics" / filename)
def baseDir():
if getattr(sys, "frozen", None):
return sys._MEIPASS
else:
return os.path.dirname(os.path.abspath(__file__))
@staticmethod
def get_ui_file(filename: str | pathlib.Path) -> pathlib.Path:
return ComicTaggerSettings.base_dir() / "ui" / filename
def getGraphic(filename):
graphic_folder = os.path.join(ComicTaggerSettings.baseDir(), "graphics")
return os.path.join(graphic_folder, filename)
def __init__(self, folder: str | pathlib.Path | None) -> None:
@staticmethod
def getUIFile(filename):
ui_folder = os.path.join(ComicTaggerSettings.baseDir(), "ui")
return os.path.join(ui_folder, filename)
def setDefaultValues(self):
# General Settings
self.rar_exe_path = ""
self.unrar_lib_path = ""
self.allow_cbi_in_rar = True
self.check_for_new_version = False
self.send_usage_stats = False
@ -79,35 +84,27 @@ class ComicTaggerSettings:
self.last_filelist_sorted_order = 0
# identifier settings
self.id_series_match_search_thresh = 90
self.id_series_match_identify_thresh = 91
self.id_publisher_filter = "Panini Comics, Abril, Planeta DeAgostini, Editorial Televisa, Dino Comics"
self.id_length_delta_thresh = 5
self.id_publisher_blacklist = "Panini Comics, Abril, Planeta DeAgostini, Editorial Televisa, Dino Comics"
# Show/ask dialog flags
self.ask_about_cbi_in_rar = True
self.show_disclaimer = True
self.dont_notify_about_this_version = ""
self.ask_about_usage_stats = True
self.show_no_unrar_warning = True
# filename parsing settings
self.complicated_parser = False
self.remove_c2c = False
self.remove_fcbd = False
self.remove_publisher = False
self.parse_scan_info = True
# Comic Vine settings
self.use_series_start_as_volume = False
self.clear_form_before_populating_from_cv = False
self.remove_html_tables = False
self.cv_api_key = ""
self.cv_url = ""
self.auto_imprint = False
self.sort_series_by_year = True
self.exact_series_matches_first = True
self.always_use_publisher_filter = False
# CBL Transform settings
# CBL Tranform settings
self.assume_lone_credit_is_primary = False
self.copy_characters_to_tags = False
@ -120,13 +117,12 @@ class ComicTaggerSettings:
self.apply_cbl_transform_on_bulk_operation = False
# Rename settings
self.rename_template = "{series} #{issue} ({year})"
self.rename_template = "{publisher}/{series}/{series} #{issue} - {title} ({year})"
self.rename_issue_number_padding = 3
self.rename_use_smart_string_cleanup = True
self.rename_extension_based_on_archive = True
self.rename_dir = ""
self.rename_move_dir = False
self.rename_strict = False
# Auto-tag stickies
self.save_on_low_confidence = False
@ -136,16 +132,19 @@ class ComicTaggerSettings:
self.remove_archive_after_successful_match = False
self.wait_and_retry_on_rate_limit = False
def __init__(self):
self.settings_file = ""
self.folder = ""
self.setDefaultValues()
self.config = configparser.RawConfigParser()
if folder:
ComicTaggerSettings.folder = pathlib.Path(folder)
else:
ComicTaggerSettings.folder = ComicTaggerSettings.get_settings_folder()
self.folder = ComicTaggerSettings.getSettingsFolder()
if not os.path.exists(ComicTaggerSettings.folder):
os.makedirs(ComicTaggerSettings.folder)
if not os.path.exists(self.folder):
os.makedirs(self.folder)
self.settings_file = os.path.join(ComicTaggerSettings.folder, "settings")
self.settings_file = os.path.join(self.folder, "settings")
# if config file doesn't exist, write one out
if not os.path.exists(self.settings_file):
@ -157,38 +156,75 @@ class ComicTaggerSettings:
if self.rar_exe_path == "":
if platform.system() == "Windows":
# look in some likely places for Windows machines
if os.path.exists(r"C:\Program Files\WinRAR\Rar.exe"):
self.rar_exe_path = r"C:\Program Files\WinRAR\Rar.exe"
elif os.path.exists(r"C:\Program Files (x86)\WinRAR\Rar.exe"):
self.rar_exe_path = r"C:\Program Files (x86)\WinRAR\Rar.exe"
if os.path.exists("C:\Program Files\WinRAR\Rar.exe"):
self.rar_exe_path = "C:\Program Files\WinRAR\Rar.exe"
elif os.path.exists("C:\Program Files (x86)\WinRAR\Rar.exe"):
self.rar_exe_path = "C:\Program Files (x86)\WinRAR\Rar.exe"
else:
if os.path.exists("/opt/homebrew/bin"):
utils.add_to_path("/opt/homebrew/bin")
# see if it's in the path of unix user
rarpath = utils.which("rar")
if rarpath is not None:
self.rar_exe_path = "rar"
if utils.which("rar") is not None:
self.rar_exe_path = utils.which("rar")
if self.rar_exe_path != "":
self.save()
if self.rar_exe_path != "":
# make sure rar program is now in the path for the rar class
utils.add_to_path(os.path.dirname(self.rar_exe_path))
utils.addtopath(os.path.dirname(self.rar_exe_path))
if self.haveOwnUnrarLib():
# We have a 'personal' copy of the unrar lib in the basedir, so
# don't search and change the setting
# NOTE: a manual edit of the settings file overrides this below
os.environ["UNRAR_LIB_PATH"] = self.defaultLibunrarPath()
elif self.unrar_lib_path == "":
# Priority is for unrar lib search is:
# 1. explicit setting in settings file
# 2. UNRAR_LIB_PATH in environment
# 3. check some likely platform specific places
if "UNRAR_LIB_PATH" in os.environ:
self.unrar_lib_path = os.environ["UNRAR_LIB_PATH"]
else:
# look in some platform specific places:
if platform.system() == "Windows":
# Default location for the RARLab DLL installer
if platform.architecture()[0] == "64bit" and os.path.exists("C:\\Program Files (x86)\\UnrarDLL\\x64\\UnRAR64.dll"):
self.unrar_lib_path = "C:\\Program Files (x86)\\UnrarDLL\\x64\\UnRAR64.dll"
elif platform.architecture()[0] == "32bit" and os.path.exists("C:\\Program Files\\UnrarDLL\\UnRAR.dll"):
self.unrar_lib_path = "C:\\Program Files\\UnrarDLL\\UnRAR.dll"
elif platform.system() == "Darwin":
# Look for the brew unrar library
if os.path.exists("/usr/local/lib/libunrar.dylib"):
self.unrar_lib_path = "/usr/local/lib/libunrar.dylib"
elif platform.system() == "Linux":
if os.path.exists("/usr/local/lib/libunrar.so"):
self.unrar_lib_path = "/usr/local/lib/libunrar.so"
elif os.path.exists("/usr/lib/libunrar.so"):
self.unrar_lib_path = "/usr/lib/libunrar.so"
if self.unrar_lib_path != "":
self.save()
if self.unrar_lib_path != "":
# This needs to occur before the unrar module is loaded for the first time
os.environ["UNRAR_LIB_PATH"] = self.unrar_lib_path
def reset(self):
os.unlink(self.settings_file)
self.__init__(ComicTaggerSettings.folder)
self.__init__()
def load(self) -> None:
def readline_generator(f: TextIO) -> Iterator[str]:
def load(self):
def readline_generator(f):
line = f.readline()
while line:
yield line
line = f.readline()
with open(self.settings_file, encoding="utf-8") as f:
self.config.read_file(readline_generator(f))
# self.config.readfp(codecs.open(self.settings_file, "r", "utf8"))
self.config.read_file(readline_generator(codecs.open(self.settings_file, "r", "utf8")))
self.rar_exe_path = self.config.get("settings", "rar_exe_path")
if self.config.has_option("settings", "unrar_lib_path"):
self.unrar_lib_path = self.config.get("settings", "unrar_lib_path")
if self.config.has_option("settings", "check_for_new_version"):
self.check_for_new_version = self.config.getboolean("settings", "check_for_new_version")
if self.config.has_option("settings", "send_usage_stats"):
@ -219,21 +255,13 @@ class ComicTaggerSettings:
if self.config.has_option("auto", "last_filelist_sorted_order"):
self.last_filelist_sorted_order = self.config.getint("auto", "last_filelist_sorted_order")
if self.config.has_option("identifier", "id_series_match_search_thresh"):
self.id_series_match_search_thresh = self.config.getint("identifier", "id_series_match_search_thresh")
if self.config.has_option("identifier", "id_series_match_identify_thresh"):
self.id_series_match_identify_thresh = self.config.getint("identifier", "id_series_match_identify_thresh")
if self.config.has_option("identifier", "id_publisher_filter"):
self.id_publisher_filter = self.config.get("identifier", "id_publisher_filter")
if self.config.has_option("identifier", "id_length_delta_thresh"):
self.id_length_delta_thresh = self.config.getint("identifier", "id_length_delta_thresh")
if self.config.has_option("identifier", "id_publisher_blacklist"):
self.id_publisher_blacklist = self.config.get("identifier", "id_publisher_blacklist")
if self.config.has_option("filenameparser", "complicated_parser"):
self.complicated_parser = self.config.getboolean("filenameparser", "complicated_parser")
if self.config.has_option("filenameparser", "remove_c2c"):
self.remove_c2c = self.config.getboolean("filenameparser", "remove_c2c")
if self.config.has_option("filenameparser", "remove_fcbd"):
self.remove_fcbd = self.config.getboolean("filenameparser", "remove_fcbd")
if self.config.has_option("filenameparser", "remove_publisher"):
self.remove_publisher = self.config.getboolean("filenameparser", "remove_publisher")
if self.config.has_option("filenameparser", "parse_scan_info"):
self.parse_scan_info = self.config.getboolean("filenameparser", "parse_scan_info")
if self.config.has_option("dialogflags", "ask_about_cbi_in_rar"):
self.ask_about_cbi_in_rar = self.config.getboolean("dialogflags", "ask_about_cbi_in_rar")
@ -243,33 +271,20 @@ class ComicTaggerSettings:
self.dont_notify_about_this_version = self.config.get("dialogflags", "dont_notify_about_this_version")
if self.config.has_option("dialogflags", "ask_about_usage_stats"):
self.ask_about_usage_stats = self.config.getboolean("dialogflags", "ask_about_usage_stats")
if self.config.has_option("dialogflags", "show_no_unrar_warning"):
self.show_no_unrar_warning = self.config.getboolean("dialogflags", "show_no_unrar_warning")
if self.config.has_option("comicvine", "use_series_start_as_volume"):
self.use_series_start_as_volume = self.config.getboolean("comicvine", "use_series_start_as_volume")
if self.config.has_option("comicvine", "clear_form_before_populating_from_cv"):
self.clear_form_before_populating_from_cv = self.config.getboolean(
"comicvine", "clear_form_before_populating_from_cv"
)
self.clear_form_before_populating_from_cv = self.config.getboolean("comicvine", "clear_form_before_populating_from_cv")
if self.config.has_option("comicvine", "remove_html_tables"):
self.remove_html_tables = self.config.getboolean("comicvine", "remove_html_tables")
if self.config.has_option("comicvine", "sort_series_by_year"):
self.sort_series_by_year = self.config.getboolean("comicvine", "sort_series_by_year")
if self.config.has_option("comicvine", "exact_series_matches_first"):
self.exact_series_matches_first = self.config.getboolean("comicvine", "exact_series_matches_first")
if self.config.has_option("comicvine", "always_use_publisher_filter"):
self.always_use_publisher_filter = self.config.getboolean("comicvine", "always_use_publisher_filter")
if self.config.has_option("comicvine", "cv_api_key"):
self.cv_api_key = self.config.get("comicvine", "cv_api_key")
if self.config.has_option("comicvine", "cv_url"):
self.cv_url = self.config.get("comicvine", "cv_url")
if self.config.has_option("cbl_transform", "assume_lone_credit_is_primary"):
self.assume_lone_credit_is_primary = self.config.getboolean(
"cbl_transform", "assume_lone_credit_is_primary"
)
self.assume_lone_credit_is_primary = self.config.getboolean("cbl_transform", "assume_lone_credit_is_primary")
if self.config.has_option("cbl_transform", "copy_characters_to_tags"):
self.copy_characters_to_tags = self.config.getboolean("cbl_transform", "copy_characters_to_tags")
if self.config.has_option("cbl_transform", "copy_teams_to_tags"):
@ -283,13 +298,9 @@ class ComicTaggerSettings:
if self.config.has_option("cbl_transform", "copy_weblink_to_comments"):
self.copy_weblink_to_comments = self.config.getboolean("cbl_transform", "copy_weblink_to_comments")
if self.config.has_option("cbl_transform", "apply_cbl_transform_on_cv_import"):
self.apply_cbl_transform_on_cv_import = self.config.getboolean(
"cbl_transform", "apply_cbl_transform_on_cv_import"
)
self.apply_cbl_transform_on_cv_import = self.config.getboolean("cbl_transform", "apply_cbl_transform_on_cv_import")
if self.config.has_option("cbl_transform", "apply_cbl_transform_on_bulk_operation"):
self.apply_cbl_transform_on_bulk_operation = self.config.getboolean(
"cbl_transform", "apply_cbl_transform_on_bulk_operation"
)
self.apply_cbl_transform_on_bulk_operation = self.config.getboolean("cbl_transform", "apply_cbl_transform_on_bulk_operation")
if self.config.has_option("rename", "rename_template"):
self.rename_template = self.config.get("rename", "rename_template")
@ -298,15 +309,11 @@ class ComicTaggerSettings:
if self.config.has_option("rename", "rename_use_smart_string_cleanup"):
self.rename_use_smart_string_cleanup = self.config.getboolean("rename", "rename_use_smart_string_cleanup")
if self.config.has_option("rename", "rename_extension_based_on_archive"):
self.rename_extension_based_on_archive = self.config.getboolean(
"rename", "rename_extension_based_on_archive"
)
self.rename_extension_based_on_archive = self.config.getboolean("rename", "rename_extension_based_on_archive")
if self.config.has_option("rename", "rename_dir"):
self.rename_dir = self.config.get("rename", "rename_dir")
if self.config.has_option("rename", "rename_move_dir"):
self.rename_move_dir = self.config.getboolean("rename", "rename_move_dir")
if self.config.has_option("rename", "rename_strict"):
self.rename_strict = self.config.getboolean("rename", "rename_strict")
if self.config.has_option("autotag", "save_on_low_confidence"):
self.save_on_low_confidence = self.config.getboolean("autotag", "save_on_low_confidence")
@ -315,26 +322,22 @@ class ComicTaggerSettings:
if self.config.has_option("autotag", "assume_1_if_no_issue_num"):
self.assume_1_if_no_issue_num = self.config.getboolean("autotag", "assume_1_if_no_issue_num")
if self.config.has_option("autotag", "ignore_leading_numbers_in_filename"):
self.ignore_leading_numbers_in_filename = self.config.getboolean(
"autotag", "ignore_leading_numbers_in_filename"
)
self.ignore_leading_numbers_in_filename = self.config.getboolean("autotag", "ignore_leading_numbers_in_filename")
if self.config.has_option("autotag", "remove_archive_after_successful_match"):
self.remove_archive_after_successful_match = self.config.getboolean(
"autotag", "remove_archive_after_successful_match"
)
self.remove_archive_after_successful_match = self.config.getboolean("autotag", "remove_archive_after_successful_match")
if self.config.has_option("autotag", "wait_and_retry_on_rate_limit"):
self.wait_and_retry_on_rate_limit = self.config.getboolean("autotag", "wait_and_retry_on_rate_limit")
if self.config.has_option("autotag", "auto_imprint"):
self.auto_imprint = self.config.getboolean("autotag", "auto_imprint")
@no_type_check
def save(self) -> None:
def save(self):
if not self.config.has_section("settings"):
self.config.add_section("settings")
self.config.set("settings", "check_for_new_version", self.check_for_new_version)
self.config.set("settings", "rar_exe_path", self.rar_exe_path)
self.config.set("settings", "unrar_lib_path", self.unrar_lib_path)
self.config.set("settings", "send_usage_stats", self.send_usage_stats)
if not self.config.has_section("auto"):
@ -356,9 +359,8 @@ class ComicTaggerSettings:
if not self.config.has_section("identifier"):
self.config.add_section("identifier")
self.config.set("identifier", "id_series_match_search_thresh", self.id_series_match_search_thresh)
self.config.set("identifier", "id_series_match_identify_thresh", self.id_series_match_identify_thresh)
self.config.set("identifier", "id_publisher_filter", self.id_publisher_filter)
self.config.set("identifier", "id_length_delta_thresh", self.id_length_delta_thresh)
self.config.set("identifier", "id_publisher_blacklist", self.id_publisher_blacklist)
if not self.config.has_section("dialogflags"):
self.config.add_section("dialogflags")
@ -367,14 +369,12 @@ class ComicTaggerSettings:
self.config.set("dialogflags", "show_disclaimer", self.show_disclaimer)
self.config.set("dialogflags", "dont_notify_about_this_version", self.dont_notify_about_this_version)
self.config.set("dialogflags", "ask_about_usage_stats", self.ask_about_usage_stats)
self.config.set("dialogflags", "show_no_unrar_warning", self.show_no_unrar_warning)
if not self.config.has_section("filenameparser"):
self.config.add_section("filenameparser")
self.config.set("filenameparser", "complicated_parser", self.complicated_parser)
self.config.set("filenameparser", "remove_c2c", self.remove_c2c)
self.config.set("filenameparser", "remove_fcbd", self.remove_fcbd)
self.config.set("filenameparser", "remove_publisher", self.remove_publisher)
self.config.set("filenameparser", "parse_scan_info", self.parse_scan_info)
if not self.config.has_section("comicvine"):
self.config.add_section("comicvine")
@ -382,13 +382,7 @@ class ComicTaggerSettings:
self.config.set("comicvine", "use_series_start_as_volume", self.use_series_start_as_volume)
self.config.set("comicvine", "clear_form_before_populating_from_cv", self.clear_form_before_populating_from_cv)
self.config.set("comicvine", "remove_html_tables", self.remove_html_tables)
self.config.set("comicvine", "sort_series_by_year", self.sort_series_by_year)
self.config.set("comicvine", "exact_series_matches_first", self.exact_series_matches_first)
self.config.set("comicvine", "always_use_publisher_filter", self.always_use_publisher_filter)
self.config.set("comicvine", "cv_api_key", self.cv_api_key)
self.config.set("comicvine", "cv_url", self.cv_url)
if not self.config.has_section("cbl_transform"):
self.config.add_section("cbl_transform")
@ -401,11 +395,7 @@ class ComicTaggerSettings:
self.config.set("cbl_transform", "copy_notes_to_comments", self.copy_notes_to_comments)
self.config.set("cbl_transform", "copy_weblink_to_comments", self.copy_weblink_to_comments)
self.config.set("cbl_transform", "apply_cbl_transform_on_cv_import", self.apply_cbl_transform_on_cv_import)
self.config.set(
"cbl_transform",
"apply_cbl_transform_on_bulk_operation",
self.apply_cbl_transform_on_bulk_operation,
)
self.config.set("cbl_transform", "apply_cbl_transform_on_bulk_operation", self.apply_cbl_transform_on_bulk_operation)
if not self.config.has_section("rename"):
self.config.add_section("rename")
@ -416,7 +406,6 @@ class ComicTaggerSettings:
self.config.set("rename", "rename_extension_based_on_archive", self.rename_extension_based_on_archive)
self.config.set("rename", "rename_dir", self.rename_dir)
self.config.set("rename", "rename_move_dir", self.rename_move_dir)
self.config.set("rename", "rename_strict", self.rename_strict)
if not self.config.has_section("autotag"):
self.config.add_section("autotag")
@ -428,5 +417,10 @@ class ComicTaggerSettings:
self.config.set("autotag", "wait_and_retry_on_rate_limit", self.wait_and_retry_on_rate_limit)
self.config.set("autotag", "auto_imprint", self.auto_imprint)
with open(self.settings_file, "w", encoding="utf-8") as configfile:
with codecs.open(self.settings_file, "wb", "utf8") as configfile:
self.config.write(configfile)
# make sure the basedir is cached, in case we're on Windows running a
# script from frozen binary
ComicTaggerSettings.baseDir()

View File

@ -1,334 +1,316 @@
"""A PyQT4 dialog to enter app settings"""
#
# Copyright 2012-2014 Anthony Beville
#
# 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 html
import logging
import os
import pathlib
import platform
import sys
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from comicapi import utils
from comicapi.genericmetadata import md_test
from comictaggerlib.comiccacher import ComicCacher
from comictaggerlib.comicvinetalker import ComicVineTalker
from comictaggerlib.filerenamer import FileRenamer
from comictaggerlib.imagefetcher import ImageFetcher
from comictaggerlib.settings import ComicTaggerSettings
logger = logging.getLogger(__name__)
from . import utils
from .comicvinecacher import ComicVineCacher
from .comicvinetalker import ComicVineTalker
from .filerenamer import FileRenamer
from .genericmetadata import GenericMetadata
from .imagefetcher import ImageFetcher
from .settings import ComicTaggerSettings
windowsRarHelp = """
<html><head/><body><p>To write to CBR/RAR archives,
you will need to have the tools from
<span style=" text-decoration: underline; color:#0000ff;">
<a href="http://www.win-rar.com/download.html">WINRar</a></span>
installed. (ComicTagger only uses the command-line rar tool.)
</p></body></html>
<html><head/><body><p>To write to CBR/RAR archives,
you will need to have the tools from
<span style=" text-decoration: underline; color:#0000ff;">
<a href="http://www.win-rar.com/download.html">WINRar</a></span>
installed. (ComicTagger only uses the command-line rar tool,
which is free to use.)</p></body></html>
"""
linuxRarHelp = """
<html><head/><body><p>To write to CBR/RAR archives,
you will need to have the shareware rar tool from RARLab installed.
Your package manager should have rar (e.g. "apt-get install rar"). If not, download it
<span style=" text-decoration: underline; color:#0000ff;">
<a href="https://www.rarlab.com/download.htm">here</a></span>,
and install in your path. </p></body></html>
"""
<html><head/><body><p>To write to CBR/RAR archives,
you will need to have the shareware rar tool from RARLab installed.
Your package manager should have rar (e.g. "apt-get install rar"). If not, download it
<span style=" text-decoration: underline; color:#0000ff;">
<a href="https://www.rarlab.com/download.htm">here</a></span>,
and install in your path. </p></body></html>
"""
macRarHelp = """
<html><head/><body><p>To write to CBR/RAR archives,
you will need the rar tool. The easiest way to get this is
to install <span style=" text-decoration: underline; color:#0000ff;">
<a href="https://brew.sh/">homebrew</a></span>.
</p>Once homebrew is installed, run: <b>brew install caskroom/cask/rar</b></body></html>
<html><head/><body><p>To write to CBR/RAR archives,
you will need the rar tool. The easiest way to get this is
to install <span style=" text-decoration: underline; color:#0000ff;">
<a href="https://brew.sh/">homebrew</a></span>.
</p>Once homebrew is installed, run: <b>brew install caskroom/cask/rar</b></body></html>
"""
windowsUnrarHelp = """
<html><head/><body><p>To read CBR/RAR archives,
you will need to have the unrar DLL from
<span style=" text-decoration: underline; color:#0000ff;">
<a href="https://www.rarlab.com/rar_add.htm">
RARLab</a></span> installed. </p></body></html>
"""
template_tooltip = """
The template for the new filename. Uses python format strings https://docs.python.org/3/library/string.html#format-string-syntax
Accepts the following variables:
{is_empty} (boolean)
{tag_origin} (string)
{series} (string)
{issue} (string)
{title} (string)
{publisher} (string)
{month} (integer)
{year} (integer)
{day} (integer)
{issue_count} (integer)
{volume} (integer)
{genre} (string)
{language} (string)
{comments} (string)
{volume_count} (integer)
{critical_rating} (float)
{country} (string)
{alternate_series} (string)
{alternate_number} (string)
{alternate_count} (integer)
{imprint} (string)
{notes} (string)
{web_link} (string)
{format} (string)
{manga} (string)
{black_and_white} (boolean)
{page_count} (integer)
{maturity_rating} (string)
{story_arc} (string)
{series_group} (string)
{scan_info} (string)
{characters} (string)
{teams} (string)
{locations} (string)
{credits} (list of dict({'role': string, 'person': string, 'primary': boolean}))
{writer} (string)
{penciller} (string)
{inker} (string)
{colorist} (string)
{letterer} (string)
{cover artist} (string)
{editor} (string)
{tags} (list of str)
{pages} (list of dict({'Image': string(int), 'Type': string, 'Bookmark': string, 'DoublePage': boolean}))
linuxUnrarHelp = """
<html><head/><body><p>To read CBR/RAR archives,
you will need to have the unrar library from RARLab installed.
Look <span style=" text-decoration: underline; color:#0000ff;">
<a href="https://github.com/beville/libunrar-binaries/releases">here</a></span>
for pre-compiled binaries, or <span style=" text-decoration: underline; color:#0000ff;">
<a href="https://www.rarlab.com/rar_add.htm">here</a></span>
for the UnRAR source (which is easy to compile on Linux). </p></body></html>
"""
CoMet-only items:
{price} (float)
{is_version_of} (string)
{rights} (string)
{identifier} (string)
{last_mark} (string)
{cover_image} (string)
Examples:
{series} {issue} ({year})
Spider-Geddon 1 (2018)
{series} #{issue} - {title}
Spider-Geddon #1 - New Players; Check In
"""
macUnrarHelp = """
<html><head/><body><p>To read CBR/RAR archives,
you will need the unrar library. The easiest way to get this is
to install <span style=" text-decoration: underline; color:#0000ff;">
<a href="https://brew.sh/homebrew">homebrew</a></span>.
</p>Once homebrew is installed, run: <b>brew install unrar</b></body></html>
"""
class SettingsWindow(QtWidgets.QDialog):
def __init__(self, parent: QtWidgets.QWidget, settings: ComicTaggerSettings) -> None:
super().__init__(parent)
def __init__(self, parent, settings):
super(SettingsWindow, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.get_ui_file("settingswindow.ui"), self)
uic.loadUi(ComicTaggerSettings.getUIFile("settingswindow.ui"), self)
self.setWindowFlags(
QtCore.Qt.WindowType(self.windowFlags() & ~QtCore.Qt.WindowType.WindowContextHelpButtonHint)
)
self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint)
self.settings = settings
self.name = "Settings"
self.priorUnrarLibPath = self.settings.unrar_lib_path
if self.settings.haveOwnUnrarLib():
# We have our own unrarlib, so no need for this GUI
self.grpBoxUnrar.hide()
if platform.system() == "Windows":
self.lblRarHelp.setText(windowsRarHelp)
self.lblUnrarHelp.setText(windowsUnrarHelp)
elif platform.system() == "Linux":
self.lblRarHelp.setText(linuxRarHelp)
self.lblUnrarHelp.setText(linuxUnrarHelp)
elif platform.system() == "Darwin":
# Mac file dialog hides "/usr" and others, so allow user to type
self.leUnrarLibPath.setReadOnly(False)
self.leRarExePath.setReadOnly(False)
self.lblRarHelp.setText(macRarHelp)
self.lblUnrarHelp.setText(macUnrarHelp)
self.name = "Preferences"
self.setWindowTitle("ComicTagger " + self.name)
self.lblDefaultSettings.setText("Revert to default " + self.name.casefold())
self.lblDefaultSettings.setText("Revert to default " + self.name.lower())
self.btnResetSettings.setText("Default " + self.name)
nmit_tip = """<html>The <b>Name Match Ratio Threshold: Auto-Identify</b> is for eliminating automatic
search matches that are too long compared to your series name search. The lower
nldtTip = """<html>The <b>Default Name Length Match Tolerance</b> is for eliminating automatic
search matches that are too long compared to your series name search. The higher
it is, the more likely to have a good match, but each search will take longer and
use more bandwidth. Too high, and only the very closest matches will be explored.</html>"""
nmst_tip = """<html>The <b>Name Match Ratio Threshold: Search</b> is for reducing the total
number of results that are returned from a search. The lower it is, the more pages will
be returned (max 5 pages or 500 results)</html>"""
use more bandwidth. Too low, and only the very closest lexical matches will be
explored.</html>"""
self.sbNameMatchIdentifyThresh.setToolTip(nmit_tip)
self.sbNameMatchSearchThresh.setToolTip(nmst_tip)
self.leNameLengthDeltaThresh.setToolTip(nldtTip)
pbl_tip = """<html>
The <b>Publisher Filter</b> is for eliminating automatic matches to certain publishers
pblTip = """<html>
The <b>Publisher Blacklist</b> is for eliminating automatic matches to certain publishers
that you know are incorrect. Useful for avoiding international re-prints with same
covers or series names. Enter publisher names separated by commas.
</html>"""
self.tePublisherFilter.setToolTip(pbl_tip)
self.tePublisherBlacklist.setToolTip(pblTip)
validator = QtGui.QIntValidator(1, 4, self)
self.leIssueNumPadding.setValidator(validator)
self.leRenameTemplate.setToolTip(f"<pre>{html.escape(template_tooltip)}</pre>")
self.settings_to_form()
self.rename_error: Exception | None = None
self.rename_test()
self.dir_test()
validator = QtGui.QIntValidator(0, 99, self)
self.leNameLengthDeltaThresh.setValidator(validator)
self.btnBrowseRar.clicked.connect(self.select_rar)
self.btnClearCache.clicked.connect(self.clear_cache)
self.btnResetSettings.clicked.connect(self.reset_settings)
self.btnTestKey.clicked.connect(self.test_api_key)
self.btnTemplateHelp.clicked.connect(self.show_template_help)
self.leRenameTemplate.textEdited.connect(self._rename_test)
self.cbxMoveFiles.clicked.connect(self.rename_test)
self.cbxMoveFiles.clicked.connect(self.dir_test)
self.cbxRenameStrict.clicked.connect(self.rename_test)
self.leDirectory.textEdited.connect(self.dir_test)
self.cbxComplicatedParser.clicked.connect(self.switch_parser)
self.settingsToForm()
def rename_test(self) -> None:
self._rename_test(self.leRenameTemplate.text())
self.btnBrowseRar.clicked.connect(self.selectRar)
self.btnBrowseUnrar.clicked.connect(self.selectUnrar)
self.btnClearCache.clicked.connect(self.clearCache)
self.btnResetSettings.clicked.connect(self.resetSettings)
self.btnTestKey.clicked.connect(self.testAPIKey)
self.btnTemplateHelp.clicked.connect(self.showTemplateHelp)
def dir_test(self) -> None:
self.lblDir.setText(
str(pathlib.Path(self.leDirectory.text().strip()).absolute()) if self.cbxMoveFiles.isChecked() else ""
)
def configRenamer(self):
md = GenericMetadata()
md.isEmpty = False
md.tagOrigin = "testing"
def _rename_test(self, template: str) -> None:
fr = FileRenamer(md_test, platform="universal" if self.cbxRenameStrict.isChecked() else "auto")
fr.move = self.cbxMoveFiles.isChecked()
fr.set_template(template)
fr.set_issue_zero_padding(int(self.leIssueNumPadding.text()))
fr.set_smart_cleanup(self.cbxSmartCleanup.isChecked())
try:
self.lblRenameTest.setText(fr.determine_name(".cbz"))
self.rename_error = None
except Exception as e:
self.rename_error = e
self.lblRenameTest.setText(str(e))
md.series = "series name"
md.issue = "1"
md.title = "issue title"
md.publisher = "lordwelch"
md.month = 4
md.year = 1998
md.day = 4
md.issueCount = 1
md.volume = 256
md.genre = "test"
md.language = "en" # 2 letter iso code
md.comments = "This is definitly not something you want to read." # use same way as Summary in CIX
def switch_parser(self) -> None:
complicated = self.cbxComplicatedParser.isChecked()
md.volumeCount = 4096
md.criticalRating = "Worst Comic Ever"
md.country = "US"
self.cbxRemoveC2C.setEnabled(complicated)
self.cbxRemoveFCBD.setEnabled(complicated)
self.cbxRemovePublisher.setEnabled(complicated)
md.alternateSeries = "None"
md.alternateNumber = 4.4
md.alternateCount = 4444
md.imprint = "Welch Publishing"
md.notes = "This doesn't actually exist"
md.webLink = "https://example.com/series name/1"
md.format = "Box Set"
md.manga = "Yes"
md.blackAndWhite = False
md.pageCount = 4
md.maturityRating = "Everyone"
md.storyArc = "None of your buisness"
md.seriesGroup = "Advertures of buisness"
md.scanInfo = "(lordwelch)"
md.characters = "lordwelch, Welch"
md.teams = "None"
md.locations = "Earth, 444 B.C."
md.credits = [dict({"role": "Everything", "person": "lordwelch", "primary": True})]
md.tags = ["testing", "not testing", "fake"]
md.pages = [dict({"Image": "0", "Type": "Front Cover"}), dict({"Image": "1", "Type": "Story"})]
# Some CoMet-only items
md.price = 0.00
md.isVersionOf = "SERIES #1"
md.rights = "None"
md.identifier = "LW4444-Comic"
md.lastMark = "0"
md.coverImage = "https://example.com/series name/1/cover"
self.renamer = FileRenamer(md)
self.renamer.setTemplate(str(self.leRenameTemplate.text()))
self.renamer.setIssueZeroPadding(self.settings.rename_issue_number_padding)
self.renamer.setSmartCleanup(self.settings.rename_use_smart_string_cleanup)
def settingsToForm(self):
def settings_to_form(self) -> None:
# Copy values from settings to form
self.leRarExePath.setText(self.settings.rar_exe_path)
self.sbNameMatchIdentifyThresh.setValue(self.settings.id_series_match_identify_thresh)
self.sbNameMatchSearchThresh.setValue(self.settings.id_series_match_search_thresh)
self.tePublisherFilter.setPlainText(self.settings.id_publisher_filter)
self.leUnrarLibPath.setText(self.settings.unrar_lib_path)
self.leNameLengthDeltaThresh.setText(str(self.settings.id_length_delta_thresh))
self.tePublisherBlacklist.setPlainText(self.settings.id_publisher_blacklist)
self.cbxCheckForNewVersion.setChecked(self.settings.check_for_new_version)
if self.settings.check_for_new_version:
self.cbxCheckForNewVersion.setCheckState(QtCore.Qt.Checked)
self.cbxComplicatedParser.setChecked(self.settings.complicated_parser)
self.cbxRemoveC2C.setChecked(self.settings.remove_c2c)
self.cbxRemoveFCBD.setChecked(self.settings.remove_fcbd)
self.cbxRemovePublisher.setChecked(self.settings.remove_publisher)
self.switch_parser()
if self.settings.parse_scan_info:
self.cbxParseScanInfo.setCheckState(QtCore.Qt.Checked)
self.cbxUseSeriesStartAsVolume.setChecked(self.settings.use_series_start_as_volume)
self.cbxClearFormBeforePopulating.setChecked(self.settings.clear_form_before_populating_from_cv)
self.cbxRemoveHtmlTables.setChecked(self.settings.remove_html_tables)
if self.settings.use_series_start_as_volume:
self.cbxUseSeriesStartAsVolume.setCheckState(QtCore.Qt.Checked)
if self.settings.clear_form_before_populating_from_cv:
self.cbxClearFormBeforePopulating.setCheckState(QtCore.Qt.Checked)
if self.settings.remove_html_tables:
self.cbxRemoveHtmlTables.setCheckState(QtCore.Qt.Checked)
self.leKey.setText(str(self.settings.cv_api_key))
self.cbxUseFilter.setChecked(self.settings.always_use_publisher_filter)
self.cbxSortByYear.setChecked(self.settings.sort_series_by_year)
self.cbxExactMatches.setChecked(self.settings.exact_series_matches_first)
self.leKey.setText(self.settings.cv_api_key)
self.leURL.setText(self.settings.cv_url)
self.cbxAssumeLoneCreditIsPrimary.setChecked(self.settings.assume_lone_credit_is_primary)
self.cbxCopyCharactersToTags.setChecked(self.settings.copy_characters_to_tags)
self.cbxCopyTeamsToTags.setChecked(self.settings.copy_teams_to_tags)
self.cbxCopyLocationsToTags.setChecked(self.settings.copy_locations_to_tags)
self.cbxCopyStoryArcsToTags.setChecked(self.settings.copy_storyarcs_to_tags)
self.cbxCopyNotesToComments.setChecked(self.settings.copy_notes_to_comments)
self.cbxCopyWebLinkToComments.setChecked(self.settings.copy_weblink_to_comments)
self.cbxApplyCBLTransformOnCVIMport.setChecked(self.settings.apply_cbl_transform_on_cv_import)
self.cbxApplyCBLTransformOnBatchOperation.setChecked(self.settings.apply_cbl_transform_on_bulk_operation)
if self.settings.assume_lone_credit_is_primary:
self.cbxAssumeLoneCreditIsPrimary.setCheckState(QtCore.Qt.Checked)
if self.settings.copy_characters_to_tags:
self.cbxCopyCharactersToTags.setCheckState(QtCore.Qt.Checked)
if self.settings.copy_teams_to_tags:
self.cbxCopyTeamsToTags.setCheckState(QtCore.Qt.Checked)
if self.settings.copy_locations_to_tags:
self.cbxCopyLocationsToTags.setCheckState(QtCore.Qt.Checked)
if self.settings.copy_storyarcs_to_tags:
self.cbxCopyStoryArcsToTags.setCheckState(QtCore.Qt.Checked)
if self.settings.copy_notes_to_comments:
self.cbxCopyNotesToComments.setCheckState(QtCore.Qt.Checked)
if self.settings.copy_weblink_to_comments:
self.cbxCopyWebLinkToComments.setCheckState(QtCore.Qt.Checked)
if self.settings.apply_cbl_transform_on_cv_import:
self.cbxApplyCBLTransformOnCVIMport.setCheckState(QtCore.Qt.Checked)
if self.settings.apply_cbl_transform_on_bulk_operation:
self.cbxApplyCBLTransformOnBatchOperation.setCheckState(QtCore.Qt.Checked)
self.leRenameTemplate.setText(self.settings.rename_template)
self.leIssueNumPadding.setText(str(self.settings.rename_issue_number_padding))
self.cbxSmartCleanup.setChecked(self.settings.rename_use_smart_string_cleanup)
self.cbxChangeExtension.setChecked(self.settings.rename_extension_based_on_archive)
self.cbxMoveFiles.setChecked(self.settings.rename_move_dir)
if self.settings.rename_use_smart_string_cleanup:
self.cbxSmartCleanup.setCheckState(QtCore.Qt.Checked)
if self.settings.rename_extension_based_on_archive:
self.cbxChangeExtension.setCheckState(QtCore.Qt.Checked)
if self.settings.rename_move_dir:
self.cbxMoveFiles.setCheckState(QtCore.Qt.Checked)
self.leDirectory.setText(self.settings.rename_dir)
self.cbxRenameStrict.setChecked(self.settings.rename_strict)
def accept(self) -> None:
self.rename_test()
if self.rename_error is not None:
if isinstance(self.rename_error, ValueError):
logger.exception("Invalid format string: %s", self.settings.rename_template)
QtWidgets.QMessageBox.critical(
self,
"Invalid format string!",
"Your rename template is invalid!"
f"<br/><br/>{self.rename_error}<br/><br/>"
"Please consult the template help in the "
"settings and the documentation on the format at "
"<a href='https://docs.python.org/3/library/string.html#format-string-syntax'>"
"https://docs.python.org/3/library/string.html#format-string-syntax</a>",
)
return
else:
logger.exception(
"Formatter failure: %s metadata: %s", self.settings.rename_template, self.renamer.metadata
)
QtWidgets.QMessageBox.critical(
self,
"The formatter had an issue!",
"The formatter has experienced an unexpected error!"
f"<br/><br/>{type(self.rename_error).__name__}: {self.rename_error}<br/><br/>"
"Please open an issue at "
"<a href='https://github.com/comictagger/comictagger'>"
"https://github.com/comictagger/comictagger</a>",
)
def accept(self):
self.configRenamer()
try:
new_name = self.renamer.determineName("test.cbz")
except Exception as e:
QtWidgets.QMessageBox.critical(
self,
"Invalid format string!",
"Your rename template is invalid!"
"<br/><br/>{}<br/><br/>"
"Please consult the template help in the "
"settings and the documentation on the format at "
"<a href='https://docs.python.org/3/library/string.html#format-string-syntax'>"
"https://docs.python.org/3/library/string.html#format-string-syntax</a>".format(e),
)
return
# Copy values from form to settings and save
self.settings.rar_exe_path = str(self.leRarExePath.text())
# Don't accept the form info if we have our own unrar lib
if not self.settings.haveOwnUnrarLib():
self.settings.unrar_lib_path = str(self.leUnrarLibPath.text())
# make sure rar program is now in the path for the rar class
if self.settings.rar_exe_path:
utils.add_to_path(os.path.dirname(self.settings.rar_exe_path))
utils.addtopath(os.path.dirname(self.settings.rar_exe_path))
if self.settings.unrar_lib_path:
os.environ["UNRAR_LIB_PATH"] = self.settings.unrar_lib_path
# This doesn't do anything... we need to restart!
if not str(self.leNameLengthDeltaThresh.text()).isdigit():
self.leNameLengthDeltaThresh.setText("0")
if not str(self.leIssueNumPadding.text()).isdigit():
self.leIssueNumPadding.setText("0")
self.settings.check_for_new_version = self.cbxCheckForNewVersion.isChecked()
self.settings.id_series_match_identify_thresh = self.sbNameMatchIdentifyThresh.value()
self.settings.id_series_match_search_thresh = self.sbNameMatchSearchThresh.value()
self.settings.id_publisher_filter = str(self.tePublisherFilter.toPlainText())
self.settings.id_length_delta_thresh = int(self.leNameLengthDeltaThresh.text())
self.settings.id_publisher_blacklist = str(self.tePublisherBlacklist.toPlainText())
self.settings.complicated_parser = self.cbxComplicatedParser.isChecked()
self.settings.remove_c2c = self.cbxRemoveC2C.isChecked()
self.settings.remove_fcbd = self.cbxRemoveFCBD.isChecked()
self.settings.remove_publisher = self.cbxRemovePublisher.isChecked()
self.settings.parse_scan_info = self.cbxParseScanInfo.isChecked()
self.settings.use_series_start_as_volume = self.cbxUseSeriesStartAsVolume.isChecked()
self.settings.clear_form_before_populating_from_cv = self.cbxClearFormBeforePopulating.isChecked()
self.settings.remove_html_tables = self.cbxRemoveHtmlTables.isChecked()
self.settings.always_use_publisher_filter = self.cbxUseFilter.isChecked()
self.settings.sort_series_by_year = self.cbxSortByYear.isChecked()
self.settings.exact_series_matches_first = self.cbxExactMatches.isChecked()
self.settings.cv_api_key = self.leKey.text().strip()
ComicVineTalker.api_key = self.settings.cv_api_key
self.settings.cv_url = self.leURL.text().strip()
ComicVineTalker.api_base_url = self.settings.cv_url
self.settings.cv_api_key = str(self.leKey.text())
ComicVineTalker.api_key = self.settings.cv_api_key.strip()
self.settings.assume_lone_credit_is_primary = self.cbxAssumeLoneCreditIsPrimary.isChecked()
self.settings.copy_characters_to_tags = self.cbxCopyCharactersToTags.isChecked()
self.settings.copy_teams_to_tags = self.cbxCopyTeamsToTags.isChecked()
@ -346,43 +328,49 @@ class SettingsWindow(QtWidgets.QDialog):
self.settings.rename_move_dir = self.cbxMoveFiles.isChecked()
self.settings.rename_dir = self.leDirectory.text()
self.settings.rename_strict = self.cbxRenameStrict.isChecked()
self.settings.save()
QtWidgets.QDialog.accept(self)
def select_rar(self) -> None:
self.select_file(self.leRarExePath, "RAR")
if self.priorUnrarLibPath != self.settings.unrar_lib_path:
QtWidgets.QMessageBox.information(self, "UnRar Library Change", "ComicTagger will need to be restarted for changes to take effect.")
def clear_cache(self) -> None:
ImageFetcher().clear_cache()
ComicCacher().clear_cache()
def selectRar(self):
self.selectFile(self.leRarExePath, "RAR")
def selectUnrar(self):
self.selectFile(self.leUnrarLibPath, "UnRAR")
def clearCache(self):
ImageFetcher().clearCache()
ComicVineCacher().clearCache()
QtWidgets.QMessageBox.information(self, self.name, "Cache has been cleared.")
def test_api_key(self) -> None:
if ComicVineTalker().test_key(self.leKey.text().strip(), self.leURL.text().strip()):
def testAPIKey(self):
if ComicVineTalker().testKey(str(self.leKey.text()).strip()):
QtWidgets.QMessageBox.information(self, "API Key Test", "Key is valid!")
else:
QtWidgets.QMessageBox.warning(self, "API Key Test", "Key is NOT valid.")
def reset_settings(self) -> None:
def resetSettings(self):
self.settings.reset()
self.settings_to_form()
self.settingsToForm()
QtWidgets.QMessageBox.information(self, self.name, self.name + " have been returned to default values.")
def select_file(self, control: QtWidgets.QLineEdit, name: str) -> None:
def selectFile(self, control, name):
dialog = QtWidgets.QFileDialog(self)
dialog.setFileMode(QtWidgets.QFileDialog.FileMode.ExistingFile)
dialog.setFileMode(QtWidgets.QFileDialog.ExistingFile)
if platform.system() == "Windows":
if name == "RAR":
flt = "Rar Program (Rar.exe)"
filter = self.tr("Rar Program (Rar.exe)")
else:
flt = "Libraries (*.dll)"
dialog.setNameFilter(flt)
filter = self.tr("Libraries (*.dll)")
dialog.setNameFilter(filter)
else:
dialog.setFilter(QtCore.QDir.Filter.Files)
# QtCore.QDir.Executable | QtCore.QDir.Files)
dialog.setFilter(QtCore.QDir.Files)
pass
dialog.setDirectory(os.path.dirname(str(control.text())))
if name == "RAR":
@ -390,21 +378,21 @@ class SettingsWindow(QtWidgets.QDialog):
else:
dialog.setWindowTitle("Find " + name + " library")
if dialog.exec():
file_list = dialog.selectedFiles()
control.setText(str(file_list[0]))
if dialog.exec_():
fileList = dialog.selectedFiles()
control.setText(str(fileList[0]))
def show_rename_tab(self) -> None:
def showRenameTab(self):
self.tabWidget.setCurrentIndex(5)
def show_template_help(self) -> None:
template_help_win = TemplateHelpWindow(self)
template_help_win.setModal(False)
template_help_win.show()
def showTemplateHelp(self):
TemplateHelpWin = TemplateHelpWindow(self)
TemplateHelpWin.setModal(False)
TemplateHelpWin.show()
class TemplateHelpWindow(QtWidgets.QDialog):
def __init__(self, parent: QtWidgets.QWidget) -> None:
super().__init__(parent)
def __init__(self, parent):
super(TemplateHelpWindow, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.get_ui_file("TemplateHelp.ui"), self)
uic.loadUi(ComicTaggerSettings.getUIFile("TemplateHelp.ui"), self)

File diff suppressed because it is too large Load Diff

View File

@ -27,94 +27,62 @@
<number>2</number>
</property>
<item>
<widget class="QTextBrowser" name="textEdit">
<property name="readOnly">
<bool>true</bool>
</property>
<widget class="QTextBrowser" name="textBrowser">
<property name="html">
<string>&lt;html&gt;
&lt;head&gt;
&lt;style&gt;
table {
font-family: arial, sans-serif;
border-collapse: collapse;
width: 100%;
}
&lt;head/&gt;
&lt;body&gt;
&lt;h1 style=&quot;text-align: center&quot;&gt;Template help&lt;/h1&gt;
&lt;p&gt;The template uses Python format strings, in the simplest use it replaces the field (e.g. {issue}) with the value for that particular comic (e.g. 1) for advanced formatting please reference the
td, th {
border: 1px solid #dddddd;
text-align: left;
padding: 8px;
}
&lt;a href=&quot;https://docs.python.org/3/library/string.html#format-string-syntax&quot;&gt;Python 3 documentation&lt;/a&gt;&lt;/p&gt;
&lt;pre&gt;Accepts the following variables:
{isEmpty}&#009;&#009;(boolean)
{tagOrigin}&#009;&#009;(string)
{series}&#009;&#009;(string)
{issue}&#009;&#009;(string)
{title}&#009;&#009;(string)
{publisher}&#009;&#009;(string)
{month}&#009;&#009;(integer)
{year}&#009;&#009;(integer)
{day}&#009;&#009;(integer)
{issueCount}&#009;(integer)
{volume}&#009;&#009;(integer)
{genre}&#009;&#009;(string)
{language}&#009;&#009;(string)
{comments}&#009;&#009;(string)
{volumeCount}&#009;(integer)
{criticalRating}&#009;(string)
{country}&#009;&#009;(string)
{alternateSeries}&#009;(string)
{alternateNumber}&#009;(string)
{alternateCount}&#009;(integer)
{imprint}&#009;&#009;(string)
{notes}&#009;&#009;(string)
{webLink}&#009;&#009;(string)
{format}&#009;&#009;(string)
{manga}&#009;&#009;(string)
{blackAndWhite}&#009;(boolean)
{pageCount}&#009;&#009;(integer)
{maturityRating}&#009;(string)
{storyArc}&#009;&#009;(string)
{seriesGroup}&#009;(string)
{scanInfo}&#009;&#009;(string)
{characters}&#009;(string)
{teams}&#009;&#009;(string)
{locations}&#009;&#009;(string)
{credits}&#009;&#009;(list of dict({&apos;role&apos;: &apos;str&apos;, &apos;person&apos;: &apos;str&apos;, &apos;primary&apos;: boolean}))
{tags}&#009;&#009;(list of str)
{pages}&#009;&#009;(list of dict({&apos;Image&apos;: &apos;str(int)&apos;, &apos;Type&apos;: &apos;str&apos;}))
tr:nth-child(even) {
background-color: #dddddd;
}
&lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;h1 style="text-align: center"&gt;Template help&lt;/h1&gt;
&lt;p&gt;The template uses Python format strings, in the simplest use it replaces the field (e.g. {issue}) with the value for that particular comic (e.g. 1) for advanced formatting please reference the
CoMet-only items:
{price}&#009;&#009;(float)
{isVersionOf}&#009;(string)
{rights}&#009;&#009;(string)
{identifier}&#009;(string)
{lastMark}&#009;&#009;(string)
{coverImage}&#009;(string)
&lt;a href="https://docs.python.org/3/library/string.html#format-string-syntax"&gt;Python 3 documentation&lt;/a&gt;&lt;/p&gt;
Accepts the following variables:
&lt;table&gt;
&lt;tr&gt;
&lt;th&gt;Tag name&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{is_empty}&lt;/td&gt;&lt;td&gt;boolean&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{tag_origin}&lt;/td&gt;&lt;td&gt;string&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{series}&lt;/td&gt;&lt;td&gt;string&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{issue}&lt;/td&gt;&lt;td&gt;string&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{title}&lt;/td&gt;&lt;td&gt;string&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{publisher}&lt;/td&gt;&lt;td&gt;string&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{month}&lt;/td&gt;&lt;td&gt;integer&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{year}&lt;/td&gt;&lt;td&gt;integer&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{day}&lt;/td&gt;&lt;td&gt;integer&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{issue_count}&lt;/td&gt;&lt;td&gt;integer&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{volume}&lt;/td&gt;&lt;td&gt;integer&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{genre}&lt;/td&gt;&lt;td&gt;string&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{language}&lt;/td&gt;&lt;td&gt;string&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{comments}&lt;/td&gt;&lt;td&gt;string&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{volume_count}&lt;/td&gt;&lt;td&gt;integer&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{critical_rating}&lt;/td&gt;&lt;td&gt;float&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{country}&lt;/td&gt;&lt;td&gt;string&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{alternate_series}&lt;/td&gt;&lt;td&gt;string&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{alternate_number}&lt;/td&gt;&lt;td&gt;string&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{alternate_count}&lt;/td&gt;&lt;td&gt;integer&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{imprint}&lt;/td&gt;&lt;td&gt;string&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{notes}&lt;/td&gt;&lt;td&gt;string&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{web_link}&lt;/td&gt;&lt;td&gt;string&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{format}&lt;/td&gt;&lt;td&gt;string&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{manga}&lt;/td&gt;&lt;td&gt;string&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{black_and_white}&lt;/td&gt;&lt;td&gt;boolean&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{page_count}&lt;/td&gt;&lt;td&gt;integer&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{maturity_rating}&lt;/td&gt;&lt;td&gt;string&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{story_arc}&lt;/td&gt;&lt;td&gt;string&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{series_group}&lt;/td&gt;&lt;td&gt;string&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{scan_info}&lt;/td&gt;&lt;td&gt;string&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{characters}&lt;/td&gt;&lt;td&gt;string&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{teams}&lt;/td&gt;&lt;td&gt;string&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{locations}&lt;/td&gt;&lt;td&gt;string&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{credits}&lt;/td&gt;&lt;td&gt;list of dict({&apos;role&apos;: string, &apos;person&apos;: string, &apos;primary&apos;: boolean})&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{writer}&lt;/td&gt;&lt;td&gt;(string)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{penciller}&lt;/td&gt;&lt;td&gt;(string)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{inker}&lt;/td&gt;&lt;td&gt;(string)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{colorist}&lt;/td&gt;&lt;td&gt;(string)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{letterer}&lt;/td&gt;&lt;td&gt;(string)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{cover artist}&lt;/td&gt;&lt;td&gt;(string)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{editor}&lt;/td&gt;&lt;td&gt;(string)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{tags}&lt;/td&gt;&lt;td&gt;list of str&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{pages}&lt;/td&gt;&lt;td&gt;list of dict({&apos;Image&apos;: string(int), &apos;Type&apos;: string, &apos;Bookmark&apos;: string, &apos;DoublePage&apos;: boolean})&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{price}&lt;/td&gt;&lt;td&gt;float&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{is_version_of}&lt;/td&gt;&lt;td&gt;string&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{rights}&lt;/td&gt;&lt;td&gt;string&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{identifier}&lt;/td&gt;&lt;td&gt;string&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{last_mark}&lt;/td&gt;&lt;td&gt;string&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{cover_image}&lt;/td&gt;&lt;td&gt;string&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;
&lt;pre&gt;
Examples:
{series} {issue} ({year})
@ -122,12 +90,9 @@ Spider-Geddon 1 (2018)
{series} #{issue} - {title}
Spider-Geddon #1 - New Players; Check In
&lt;/pre&gt;
&lt;/body&gt;
&lt;/html&gt;</string></property>
<property name="textInteractionFlags">
<set>Qt::TextBrowserInteraction</set>
&lt;/body&gt;
&lt;/html&gt;</string>
</property>
<property name="openExternalLinks">
<bool>true</bool>

View File

@ -1 +0,0 @@
from __future__ import annotations

View File

@ -1,77 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Dialog</class>
<widget class="QDialog" name="Dialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>400</width>
<height>300</height>
</rect>
</property>
<property name="windowTitle">
<string>Log Window</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QTextEdit" name="textEdit">
<property name="readOnly">
<bool>true</bool>
</property>
<property name="acceptRichText">
<bool>false</bool>
</property>
<property name="textInteractionFlags">
<set>Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse|Qt::TextBrowserInteraction|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse</set>
</property>
</widget>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::Close</set>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections>
<connection>
<sender>buttonBox</sender>
<signal>accepted()</signal>
<receiver>Dialog</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>248</x>
<y>254</y>
</hint>
<hint type="destinationlabel">
<x>157</x>
<y>274</y>
</hint>
</hints>
</connection>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>Dialog</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>316</x>
<y>260</y>
</hint>
<hint type="destinationlabel">
<x>286</x>
<y>274</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@ -21,7 +21,7 @@
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="1">
<widget class="QWidget" name="archiveCoverContainer" native="true">
<widget class="QWidget" name="archiveCoverContainer">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Expanding">
<horstretch>0</horstretch>
@ -43,7 +43,7 @@
</widget>
</item>
<item row="0" column="4">
<widget class="QWidget" name="testCoverContainer" native="true">
<widget class="QWidget" name="testCoverContainer">
<property name="minimumSize">
<size>
<width>110</width>

View File

@ -10,7 +10,7 @@
<x>0</x>
<y>0</y>
<width>519</width>
<height>440</height>
<height>378</height>
</rect>
</property>
<property name="sizePolicy">
@ -44,17 +44,7 @@
</item>
<item row="1" column="0">
<layout class="QGridLayout" name="gridLayout">
<item row="6" column="0">
<widget class="QCheckBox" name="cbxAutoImprint">
<property name="toolTip">
<string>Checks the publisher against a list of imprints.</string>
</property>
<property name="text">
<string>Auto Imprint</string>
</property>
</widget>
</item>
<item row="9" column="0">
<item row="7" column="0">
<widget class="QCheckBox" name="cbxSpecifySearchString">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
@ -67,19 +57,6 @@
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QCheckBox" name="cbxIgnoreLeadingDigitsInFilename">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Ignore leading (sequence) numbers in filename</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QCheckBox" name="cbxSaveOnLowConfidence">
<property name="sizePolicy">
@ -93,31 +70,8 @@
</property>
</widget>
</item>
<item row="10" column="0">
<widget class="QLineEdit" name="leSearchString">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
</item>
<item row="11" column="0">
<widget class="QLabel" name="label_3">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Adjust Name Match Ratio Threshold: Auto-Identify</string>
</property>
</widget>
</item>
<item row="8" column="0">
<widget class="QCheckBox" name="cbxSplitWords">
<item row="4" column="0">
<widget class="QCheckBox" name="cbxIgnoreLeadingDigitsInFilename">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
<horstretch>0</horstretch>
@ -125,43 +79,7 @@
</sizepolicy>
</property>
<property name="text">
<string>Split words in filenames (e.g. 'judgedredd' to 'judge dredd') (Experimental)</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QCheckBox" name="cbxDontUseYear">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Don't use publication year in identification process</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QCheckBox" name="cbxAssumeIssueOne">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>If no issue number, assume &quot;1&quot;</string>
</property>
</widget>
</item>
<item row="7" column="0">
<widget class="QCheckBox" name="cbxRemoveMetadata">
<property name="toolTip">
<string>Removes existing metadata before applying retrieved metadata</string>
</property>
<property name="text">
<string>Overwrite metadata</string>
<string>Ignore leading (sequence) numbers in filename</string>
</property>
</widget>
</item>
@ -185,22 +103,78 @@
</property>
</widget>
</item>
<item row="12" column="0">
<widget class="QSpinBox" name="sbNameMatchSearchThresh">
<item row="1" column="0">
<widget class="QCheckBox" name="cbxDontUseYear">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Don't use publication year in indentification process</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QCheckBox" name="cbxAssumeIssueOne">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>If no issue number, assume &quot;1&quot;</string>
</property>
</widget>
</item>
<item row="6" column="0">
<widget class="QCheckBox" name="cbxAutoImprint">
<property name="toolTip">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Checks the publisher against a list of imprints.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="text">
<string>Auto Imprint</string>
</property>
</widget>
</item>
<item row="10" column="0">
<widget class="QLineEdit" name="leNameLengthMatchTolerance">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximumSize">
<size>
<width>60</width>
<width>50</width>
<height>16777215</height>
</size>
</property>
<property name="suffix">
<string>%</string>
</widget>
</item>
<item row="8" column="0">
<widget class="QLineEdit" name="leSearchString">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimum">
<number>1</number>
</widget>
</item>
<item row="9" column="0">
<widget class="QLabel" name="label_3">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximum">
<number>100</number>
<property name="text">
<string>Adjust Name Length Match Tolerance:</string>
</property>
</widget>
</item>

View File

@ -14,24 +14,15 @@
<string>Form</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<property name="horizontalSpacing">
<number>0</number>
</property>
<property name="verticalSpacing">
<number>4</number>
</property>
<property name="margin">
<number>0</number>
</property>
<item row="1" column="0">
<layout class="QHBoxLayout" name="horizontalLayout">
<property name="spacing">

View File

@ -10,7 +10,7 @@
<x>0</x>
<y>0</y>
<width>533</width>
<height>231</height>
<height>202</height>
</rect>
</property>
<property name="sizePolicy">

View File

@ -28,12 +28,12 @@
<property name="textElideMode">
<enum>Qt::ElideMiddle</enum>
</property>
<attribute name="horizontalHeaderMinimumSectionSize">
<number>36</number>
</attribute>
<attribute name="horizontalHeaderDefaultSectionSize">
<number>61</number>
</attribute>
<attribute name="horizontalHeaderMinimumSectionSize">
<number>36</number>
</attribute>
<attribute name="horizontalHeaderStretchLastSection">
<bool>false</bool>
</attribute>
@ -45,7 +45,7 @@
<string>File Name</string>
</property>
<property name="textAlignment">
<set>AlignCenter</set>
<set>AlignHCenter|AlignVCenter|AlignCenter</set>
</property>
</column>
<column>
@ -56,7 +56,7 @@
<string>Has ComicRack Tags</string>
</property>
<property name="textAlignment">
<set>AlignCenter</set>
<set>AlignHCenter|AlignVCenter|AlignCenter</set>
</property>
</column>
<column>
@ -67,7 +67,7 @@
<string>Has ComicBookLover Tags</string>
</property>
<property name="textAlignment">
<set>AlignCenter</set>
<set>AlignHCenter|AlignVCenter|AlignCenter</set>
</property>
</column>
<column>
@ -78,7 +78,7 @@
<string>Archive Type</string>
</property>
<property name="textAlignment">
<set>AlignCenter</set>
<set>AlignHCenter|AlignVCenter|AlignCenter</set>
</property>
</column>
<column>
@ -89,7 +89,7 @@
<string>Read-Only</string>
</property>
<property name="textAlignment">
<set>AlignCenter</set>
<set>AlignHCenter|AlignVCenter|AlignCenter</set>
</property>
</column>
<column>
@ -100,7 +100,7 @@
<string>File Location</string>
</property>
<property name="textAlignment">
<set>AlignCenter</set>
<set>AlignHCenter|AlignVCenter|AlignCenter</set>
</property>
</column>
</widget>

View File

@ -75,7 +75,7 @@
<string>Title</string>
</property>
<property name="textAlignment">
<set>AlignCenter</set>
<set>AlignHCenter|AlignVCenter|AlignCenter</set>
</property>
</column>
</widget>

View File

@ -87,47 +87,16 @@
<item>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<layout class="QGridLayout" name="gridLayout_1">
<layout class="QFormLayout" name="formLayout">
<item row="0" column="0">
<widget class="QLabel" name="lblPageType">
<widget class="QLabel" name="label_2">
<property name="text">
<string>Page Type:</string>
</property>
<property name="alignment">
<set>Qt::AlignRight</set>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QComboBox" name="cbPageType"/>
</item>
<item row="0" column="2">
<widget class="QCheckBox" name="chkDoublePage">
<property name="text">
<string>&amp;Double Page?</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QFormLayout" name="formLayout_2">
<item row="0" column="0">
<widget class="QLabel" name="lblBookmark">
<property name="text">
<string>Book&amp;mark:</string>
</property>
<property name="buddy">
<cstring>leBookmark</cstring>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLineEdit" name="leBookmark">
<property name="acceptDrops">
<bool>false</bool>
</property>
</widget>
<widget class="QComboBox" name="comboBox"/>
</item>
</layout>
</item>

View File

@ -28,12 +28,11 @@
</item>
<item>
<widget class="QTextEdit" name="textEdit">
<property name="font">
<property name="font">
<font>
<family>Courier</family>
</font>
</property>
<property name="readOnly">
</property> <property name="readOnly">
<bool>true</bool>
</property>
</widget>

View File

@ -1,88 +1,87 @@
"""Some utilities for the GUI"""
from __future__ import annotations
# import StringIO
import io
import logging
import traceback
# from PIL import Image
from comictaggerlib.settings import ComicTaggerSettings
logger = logging.getLogger(__name__)
try:
from PyQt5 import QtGui, QtWidgets
from PyQt5 import QtGui
qt_available = True
except ImportError:
qt_available = False
if qt_available:
try:
from PIL import Image, ImageQt
pil_available = True
except ImportError:
pil_available = False
def reduce_widget_font_size(widget: QtWidgets.QWidget, delta: int = 2) -> None:
def reduceWidgetFontSize(widget, delta=2):
f = widget.font()
if f.pointSize() > 10:
f.setPointSize(f.pointSize() - delta)
widget.setFont(f)
def center_window_on_screen(window: QtWidgets.QWidget) -> None:
def centerWindowOnScreen(window):
"""Center the window on screen.
This implementation will handle the window
being resized or the screen resolution changing.
"""
# Get the current screens' dimensions...
screen = QtGui.QGuiApplication.primaryScreen().geometry()
# The horizontal position is calculated as (screen width - window width) / 2
hpos = int((screen.width() - window.width()) / 2)
screen = QtGui.QDesktopWidget().screenGeometry()
# ... and get this windows' dimensions
mysize = window.geometry()
# The horizontal position is calculated as screen width - window width
# / 2
hpos = (screen.width() - window.width()) / 2
# And vertical position the same, but with the height dimensions
vpos = int((screen.height() - window.height()) / 2)
vpos = (screen.height() - window.height()) / 2
# And the move call repositions the window
window.move(hpos, vpos)
def center_window_on_parent(window: QtWidgets.QWidget) -> None:
def centerWindowOnParent(window):
top_level = window
while top_level.parent() is not None:
parent = top_level.parent()
if isinstance(parent, QtWidgets.QWidget):
top_level = parent
top_level = top_level.parent()
# Get the current screens' dimensions...
main_window_size = top_level.geometry()
# ... and get this windows' dimensions
# The horizontal position is calculated as (screen width - window width) / 2
hpos = int((main_window_size.width() - window.width()) / 2)
mysize = window.geometry()
# The horizontal position is calculated as screen width - window width
# /2
hpos = (main_window_size.width() - window.width()) / 2
# And vertical position the same, but with the height dimensions
vpos = int((main_window_size.height() - window.height()) / 2)
vpos = (main_window_size.height() - window.height()) / 2
# And the move call repositions the window
window.move(hpos + main_window_size.left(), vpos + main_window_size.top())
def get_qimage_from_data(image_data: bytes) -> QtGui.QImage:
try:
import io
from PIL import Image, WebPImagePlugin
pil_available = True
except ImportError:
pil_available = False
def getQImageFromData(image_data):
img = QtGui.QImage()
success = img.loadFromData(image_data)
if not success:
try:
if pil_available:
# Qt doesn't understand the format, but maybe PIL does
img = ImageQt.ImageQt(Image.open(io.BytesIO(image_data)))
success = True
except Exception:
# Qt doesn't understand the format, but maybe PIL does
# so try to convert the image data to uncompressed tiff
# format
im = Image.open(io.StringIO(image_data))
output = io.StringIO()
im.save(output, format="PNG")
success = img.loadFromData(output.getvalue())
except Exception as e:
pass
# if still nothing, go with default image
if not success:
img.load(ComicTaggerSettings.get_graphic("nocover.png"))
img.load(ComicTaggerSettings.getGraphic("nocover.png"))
return img
def qt_error(msg: str, e: Exception | None = None) -> None:
trace = ""
if e:
trace = "\n".join(traceback.format_exception(type(e), e, e.__traceback__))
QtWidgets.QMessageBox.critical(QtWidgets.QMainWindow(), "Error", msg + trace)

View File

@ -7,7 +7,7 @@
<x>0</x>
<y>0</y>
<width>702</width>
<height>488</height>
<height>432</height>
</rect>
</property>
<property name="windowTitle">
@ -30,7 +30,7 @@
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="tGeneral">
<widget class="QWidget" name="tab">
<attribute name="title">
<string>General</string>
</attribute>
@ -131,15 +131,15 @@
</item>
</layout>
</widget>
<widget class="QWidget" name="tSearching">
<widget class="QWidget" name="tab_2">
<attribute name="title">
<string>Searching</string>
<string>Identifier</string>
</attribute>
<layout class="QGridLayout" name="gridLayout_3">
<item row="0" column="0">
<widget class="QLabel" name="label_5">
<property name="text">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;These settings are for the automatic issue identifier which searches online for matches. &lt;/p&gt;&lt;p&gt;Hover the mouse over an entry field for more info.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;These settings are for the automatic issue identifier which searches online for matches. They will not affect &amp;quot;manual&amp;quot; searching.&lt;/p&gt;&lt;p&gt;Hover the mouse over an entry field for more info.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="wordWrap">
<bool>true</bool>
@ -164,46 +164,38 @@
<string/>
</property>
<property name="text">
<string>Default Name Match Ratio Threshold: Search:</string>
<string>Default Name Length Match Tolerance:</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Default Name Match Ratio Threshold: Auto-Identify:</string>
<item row="0" column="1">
<widget class="QLineEdit" name="leNameLengthDeltaThresh">
<property name="sizePolicy">
<sizepolicy hsizetype="Maximum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
<property name="maximumSize">
<size>
<width>50</width>
<height>16777215</height>
</size>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_9">
<property name="text">
<string>Always use Publisher Filter on &quot;manual&quot; searches:</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QCheckBox" name="cbxUseFilter">
<property name="toolTip">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Applies the &lt;span style=&quot; font-weight:600;&quot;&gt;Publisher Filter&lt;/span&gt; on all searches.&lt;br/&gt;The search window has a dynamic toggle to show the unfiltered results.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="3" column="0">
<item row="1" column="0">
<widget class="QLabel" name="label_4">
<property name="text">
<string>Publisher Filter:</string>
<string>Publisher Blacklist:</string>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QPlainTextEdit" name="tePublisherFilter">
<item row="1" column="1">
<widget class="QPlainTextEdit" name="tePublisherBlacklist">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
@ -212,103 +204,29 @@
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QSpinBox" name="sbNameMatchSearchThresh">
<property name="maximumSize">
<size>
<width>60</width>
<height>16777215</height>
</size>
</property>
<property name="suffix">
<string>%</string>
</property>
<property name="minimum">
<number>1</number>
</property>
<property name="maximum">
<number>100</number>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QSpinBox" name="sbNameMatchIdentifyThresh">
<property name="maximumSize">
<size>
<width>60</width>
<height>16777215</height>
</size>
</property>
<property name="suffix">
<string>%</string>
</property>
<property name="minimum">
<number>1</number>
</property>
<property name="maximum">
<number>100</number>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<widget class="QWidget" name="tFilenameParser">
<widget class="QWidget" name="tab_6">
<attribute name="title">
<string>Filename Parser</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_6">
<item>
<widget class="QGroupBox" name="groupBox_2">
<layout class="QVBoxLayout" name="verticalLayout_7">
<item>
<widget class="QCheckBox" name="cbxComplicatedParser">
<property name="text">
<string>Use &quot;Complicated&quot; Parser</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="cbxRemoveC2C">
<property name="text">
<string>Remove 'C2C' from Scan Info</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="cbxRemoveFCBD">
<property name="text">
<string>Remove 'FCBD' from Scan Info</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="cbxRemovePublisher">
<property name="text">
<string>Remove Publisher from filename</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<spacer name="verticalSpacer_4">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
</layout>
<widget class="QCheckBox" name="cbxParseScanInfo">
<property name="geometry">
<rect>
<x>30</x>
<y>30</y>
<width>421</width>
<height>25</height>
</rect>
</property>
<property name="text">
<string>Parse Scan Info From Filename (Experimental)</string>
</property>
</widget>
</widget>
<widget class="QWidget" name="tComicVine">
<widget class="QWidget" name="tab_3">
<attribute name="title">
<string>Comic Vine</string>
</attribute>
@ -345,33 +263,6 @@
</property>
</widget>
</item>
<item>
<widget class="Line" name="line_4">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="cbxSortByYear">
<property name="text">
<string>Initially sort Series search results by Starting Year instead of No. Issues</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="cbxExactMatches">
<property name="text">
<string>Initially show Series Name exact matches first</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
@ -399,6 +290,44 @@
</sizepolicy>
</property>
<layout class="QGridLayout" name="gridLayout_8">
<item row="1" column="1">
<widget class="QLineEdit" name="leKey">
<property name="sizePolicy">
<sizepolicy hsizetype="MinimumExpanding" vsizetype="Maximum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="readOnly">
<bool>false</bool>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_8">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>120</width>
<height>0</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>200</width>
<height>16777215</height>
</size>
</property>
<property name="text">
<string>Comic Vine API Key</string>
</property>
</widget>
</item>
<item row="0" column="0" colspan="3">
<widget class="QLabel" name="lblKeyHelp">
<property name="sizePolicy">
@ -430,58 +359,10 @@
<item row="1" column="2">
<widget class="QPushButton" name="btnTestKey">
<property name="text">
<string>Test Key</string>
<string>Tesk Key</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QLineEdit" name="leKey">
<property name="sizePolicy">
<sizepolicy hsizetype="MinimumExpanding" vsizetype="Maximum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="readOnly">
<bool>false</bool>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="lblKey">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>120</width>
<height>0</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>200</width>
<height>16777215</height>
</size>
</property>
<property name="text">
<string>Comic Vine API Key</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="lblURL">
<property name="text">
<string>Comic Vine URL</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QLineEdit" name="leURL"/>
</item>
</layout>
</widget>
</item>
@ -500,7 +381,7 @@
</item>
</layout>
</widget>
<widget class="QWidget" name="tCBL">
<widget class="QWidget" name="tab_4">
<attribute name="title">
<string>CBL</string>
</attribute>
@ -611,7 +492,7 @@
</item>
</layout>
</widget>
<widget class="QWidget" name="tRename">
<widget class="QWidget" name="tab_5">
<attribute name="title">
<string>Rename</string>
</attribute>
@ -629,7 +510,64 @@
</widget>
</item>
<item row="1" column="1">
<widget class="QLineEdit" name="leRenameTemplate"/>
<widget class="QLineEdit" name="leRenameTemplate">
<property name="toolTip">
<string>&lt;pre&gt;The template for the new filename. Uses python format strings https://docs.python.org/3/library/string.html#format-string-syntax
Accepts the following variables:
{isEmpty} (boolean)
{tagOrigin} (string)
{series} (string)
{issue} (string)
{title} (string)
{publisher} (string)
{month} (integer)
{year} (integer)
{day} (integer)
{issueCount} (integer)
{volume} (integer)
{genre} (string)
{language} (string)
{comments} (string)
{volumeCount} (integer)
{criticalRating} (string)
{country} (string)
{alternateSeries} (string)
{alternateNumber} (string)
{alternateCount} (integer)
{imprint} (string)
{notes} (string)
{webLink} (string)
{format} (string)
{manga} (string)
{blackAndWhite} (boolean)
{pageCount} (integer)
{maturityRating} (string)
{storyArc} (string)
{seriesGroup} (string)
{scanInfo} (string)
{characters} (string)
{teams} (string)
{locations} (string)
{credits} (list of dict({'role': 'str', 'person': 'str', 'primary': boolean}))
{tags} (list of str)
{pages} (list of dict({'Image': 'str(int)', 'Type': 'str'}))
CoMet-only items:
{price} (float)
{isVersionOf} (string)
{rights} (string)
{identifier} (string)
{lastMark} (string)
{coverImage} (string)
Examples:
{series} {issue} ({year})
{series} #{issue} - {title}
&lt;/pre&gt;</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QPushButton" name="btnTemplateHelp">
@ -638,19 +576,6 @@
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QLabel" name="lblRenameTest">
<property name="text">
<string/>
</property>
<property name="textFormat">
<enum>Qt::PlainText</enum>
</property>
<property name="textInteractionFlags">
<set>Qt::LinksAccessibleByMouse|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse</set>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="lblPadding">
<property name="text">
@ -694,7 +619,7 @@
</property>
</widget>
</item>
<item row="7" column="0">
<item row="6" column="0">
<widget class="QCheckBox" name="cbxMoveFiles">
<property name="toolTip">
<string>If checked moves files to specified folder</string>
@ -704,39 +629,86 @@
</property>
</widget>
</item>
<item row="9" column="0">
<item row="7" column="0">
<widget class="QLabel" name="lblDirectory">
<property name="text">
<string>Destination Directory:</string>
</property>
</widget>
</item>
<item row="9" column="1">
<item row="7" column="1">
<widget class="QLineEdit" name="leDirectory"/>
</item>
<item row="8" column="0">
<widget class="QCheckBox" name="cbxRenameStrict">
<property name="toolTip">
<string>If checked will ensure reserved characters and filenames are removed for all Operating Systems.
By default only removes restricted characters and filenames for the current Operating System.</string>
</property>
<property name="text">
<string>Strict renaming</string>
</property>
</widget>
</item>
<item row="10" column="1">
<widget class="QLabel" name="lblDir"/>
</item>
</layout>
</item>
</layout>
</widget>
<widget class="QWidget" name="tRARTools">
<widget class="QWidget" name="tab_7">
<attribute name="title">
<string>RAR Tools</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QGroupBox" name="grpBoxUnrar">
<layout class="QGridLayout" name="gridLayout">
<item row="1" column="0">
<widget class="QLabel" name="lblUnrar">
<property name="minimumSize">
<size>
<width>120</width>
<height>0</height>
</size>
</property>
<property name="text">
<string>UnRAR library</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QLineEdit" name="leUnrarLibPath">
<property name="readOnly">
<bool>true</bool>
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="QPushButton" name="btnBrowseUnrar">
<property name="text">
<string>...</string>
</property>
</widget>
</item>
<item row="0" column="0" colspan="3">
<widget class="QLabel" name="lblUnrarHelp">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;In order to read CBR/RAR archives, you will need to have the unrar library from &lt;a href=&quot;www.win-rar.com/download.html&quot;&gt;&lt;span style=&quot; text-decoration: underline; color:#0000ff;&quot;&gt;WinRAR&lt;/span&gt;&lt;/a&gt; installed. &lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="textFormat">
<enum>Qt::RichText</enum>
</property>
<property name="alignment">
<set>Qt::AlignBottom|Qt::AlignLeading|Qt::AlignLeft</set>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
<property name="openExternalLinks">
<bool>true</bool>
</property>
<property name="textInteractionFlags">
<set>Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse</set>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="grpBoxRar">
<layout class="QGridLayout" name="gridLayout">
@ -794,7 +766,7 @@ By default only removes restricted characters and filenames for the current Oper
<item row="0" column="0" colspan="3">
<widget class="QLabel" name="lblRarHelp">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Minimum">
<sizepolicy hsizetype="Preferred" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>

View File

@ -108,16 +108,7 @@
<enum>QFrame::Sunken</enum>
</property>
<layout class="QVBoxLayout" name="verticalLayout_4">
<property name="leftMargin">
<number>6</number>
</property>
<property name="topMargin">
<number>6</number>
</property>
<property name="rightMargin">
<number>6</number>
</property>
<property name="bottomMargin">
<property name="margin">
<number>6</number>
</property>
<item>
@ -521,6 +512,26 @@
</property>
</widget>
</item>
<item row="10" column="1">
<widget class="QLineEdit" name="leSeriesPubYear"/>
</item>
<item row="9" column="1">
<widget class="QLabel" name="label_7">
<property name="text">
<string>Series Year</string>
</property>
</widget>
</item>
<item row="11" column="1">
<widget class="QPushButton" name="btnAutoImprint">
<property name="toolTip">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Checks the publisher against a list of imprints.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="text">
<string>Auto Imprint</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
@ -816,7 +827,7 @@
<string>Primary</string>
</property>
<property name="textAlignment">
<set>AlignCenter</set>
<set>AlignHCenter|AlignVCenter|AlignCenter</set>
</property>
</column>
<column>
@ -938,58 +949,35 @@
</widget>
</item>
<item row="2" column="1">
<layout class="QGridLayout" name="gridLayout_7">
<item row="0" column="0">
<widget class="QLineEdit" name="leWebLink">
<property name="acceptDrops">
<bool>false</bool>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QPushButton" name="btnOpenWebLink">
<property name="sizePolicy">
<sizepolicy hsizetype="Maximum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximumSize">
<size>
<width>40</width>
<height>16777215</height>
</size>
</property>
</widget>
</item>
</layout>
</item>
<item row="4" column="0">
<widget class="QLabel" name="lblCriticalRating">
<property name="text">
<string>Critical Rating</string>
<widget class="QLineEdit" name="leWebLink">
<property name="acceptDrops">
<bool>false</bool>
</property>
</widget>
</item>
<item row="4" column="1">
<widget class="QDoubleSpinBox" name="dsbCriticalRating">
<property name="minimumSize">
<item row="3" column="0">
<widget class="QLabel" name="userRatingLabel">
<property name="text">
<string>User Rating</string>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QLineEdit" name="leCriticalRating">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximumSize">
<size>
<width>80</width>
<height>0</height>
<height>16777215</height>
</size>
</property>
<property name="decimals">
<number>1</number>
</property>
<property name="minimum">
<double>0.000000000000000</double>
</property>
<property name="maximum">
<double>5.000000000000000</double>
</property>
<property name="singleStep">
<double>0.100000000000000</double>
<property name="acceptDrops">
<bool>false</bool>
</property>
</widget>
</item>
@ -1156,7 +1144,7 @@
<x>0</x>
<y>0</y>
<width>1096</width>
<height>30</height>
<height>22</height>
</rect>
</property>
<widget class="QMenu" name="menuComicTagger">
@ -1206,21 +1194,18 @@
</property>
<addaction name="actionClearEntryForm"/>
<addaction name="actionParse_Filename"/>
<addaction name="actionParse_Filename_split_words"/>
<addaction name="actionSearchOnline"/>
<addaction name="actionAutoIdentify"/>
<addaction name="actionAutoImprint"/>
<addaction name="actionLiteralSearch"/>
<addaction name="separator"/>
<addaction name="actionApplyCBLTransform"/>
<addaction name="actionReCalcPageDims"/>
<addaction name="actionMarkAd"/>
</widget>
<widget class="QMenu" name="menuWindow">
<property name="title">
<string>Window</string>
</property>
<addaction name="actionPageBrowser"/>
<addaction name="actionLogWindow"/>
</widget>
<addaction name="menuComicTagger"/>
<addaction name="menuTags"/>
@ -1366,7 +1351,7 @@
</action>
<action name="actionComicTaggerForum">
<property name="text">
<string>ComicTagger Discussions...</string>
<string>ComicTagger Forum...</string>
</property>
</action>
<action name="actionWiki">
@ -1389,11 +1374,6 @@
<string>Apply CBL Transform</string>
</property>
</action>
<action name="actionReCalcPageDims">
<property name="text">
<string>Re-Calculate Page Dimensions</string>
</property>
</action>
<action name="actionLoadFolder">
<property name="text">
<string>Open Folder</string>
@ -1415,27 +1395,6 @@
<string>Search online for tags,auto-identify best match, and save to archive</string>
</property>
</action>
<action name="actionAutoImprint">
<property name="text">
<string>Auto Imprint</string>
</property>
<property name="toolTip">
<string>Normalize the publisher and map imprints to their parent publisher (e.g. Vertigo is an imprint of DC Comics)</string>
</property>
</action>
<action name="actionParse_Filename_split_words">
<property name="text">
<string>Parse Filename and split words</string>
</property>
<property name="toolTip">
<string>Parse Filename and split words</string>
</property>
</action>
<action name="actionLogWindow">
<property name="text">
<string>Show Log Window</string>
</property>
</action>
<action name="actionLiteralSearch">
<property name="text">
<string>Literal Search</string>
@ -1444,6 +1403,14 @@
<string>perform a literal search on the series and return the first 50 results</string>
</property>
</action>
<action name="actionMarkAd">
<property name="text">
<string>Mark Advertisement</string>
</property>
<property name="toolTip">
<string>mark the current page as an advertisement</string>
</property>
</action>
</widget>
<layoutdefault spacing="6" margin="11"/>
<resources/>

View File

@ -6,7 +6,7 @@
<rect>
<x>0</x>
<y>0</y>
<width>893</width>
<width>849</width>
<height>476</height>
</rect>
</property>
@ -86,7 +86,7 @@
<string>Year</string>
</property>
<property name="textAlignment">
<set>AlignCenter</set>
<set>AlignHCenter|AlignVCenter|AlignCenter</set>
</property>
</column>
<column>
@ -94,7 +94,7 @@
<string>Issues</string>
</property>
<property name="textAlignment">
<set>AlignCenter</set>
<set>AlignHCenter|AlignVCenter|AlignCenter</set>
</property>
</column>
<column>
@ -148,16 +148,6 @@
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="cbxFilter">
<property name="toolTip">
<string>Filter the publishers based on the publisher filter.</string>
</property>
<property name="text">
<string>Filter Publishers</string>
</property>
</widget>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="orientation">

1
comictaggerlib/utils.py Normal file
View File

@ -0,0 +1 @@
from comicapi.utils import *

View File

@ -1,38 +1,52 @@
"""Version checker"""
#
# Copyright 2013 Anthony Beville
#
# 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 platform
import sys
import urllib.parse
import requests
from comictaggerlib import ctversion
from . import _version
logger = logging.getLogger(__name__)
try:
from PyQt5.QtCore import QByteArray, QObject, QUrl, pyqtSignal
from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest
except ImportError:
# No Qt, so define a few dummy QObjects to help us compile
class QObject:
def __init__(self, *args):
pass
class pyqtSignal:
def __init__(self, *args):
pass
def emit(a, b, c):
pass
class VersionChecker:
def get_request_url(self, uuid: str, use_stats: bool) -> tuple[str, dict[str, str]]:
class VersionChecker(QObject):
def getRequestUrl(self, uuid, use_stats):
base_url = "http://comictagger1.appspot.com/latest"
params = {}
args = ""
params = dict()
if use_stats:
params = {"uuid": uuid, "version": ctversion.version}
params = {"uuid": uuid, "version": _version.version}
if platform.system() == "Windows":
params["platform"] = "win"
elif platform.system() == "Linux":
@ -45,15 +59,36 @@ class VersionChecker:
if not getattr(sys, "frozen", None):
params["src"] = "T"
return base_url, params
return (base_url, params)
def get_latest_version(self, uuid: str, use_stats: bool = True) -> str:
def getLatestVersion(self, uuid, use_stats=True):
try:
url, params = self.get_request_url(uuid, use_stats)
url, params = self.getRequestUrl(uuid, use_stats)
new_version = requests.get(url, params=params).text
except Exception:
return ""
except Exception as e:
return None
if new_version is None or new_version == "":
return ""
return None
return new_version.strip()
versionRequestComplete = pyqtSignal(str)
def asyncGetLatestVersion(self, uuid, use_stats):
url, params = self.getRequestUrl(uuid, use_stats)
self.nam = QNetworkAccessManager()
self.nam.finished.connect(self.asyncGetLatestVersionComplete)
self.nam.get(QNetworkRequest(QUrl(str(url + "?" + urllib.parse.urlencode(params)))))
def asyncGetLatestVersionComplete(self, reply):
if reply.error() != QNetworkReply.NoError:
return
# read in the response
new_version = str(reply.readAll())
if new_version is None or new_version == "":
return
self.versionRequestComplete.emit(new_version.strip())

View File

@ -1,64 +1,54 @@
"""A PyQT4 dialog to select specific series/volume from list"""
#
# Copyright 2012-2014 Anthony Beville
#
# 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 itertools
import logging
from collections import deque
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from PyQt5.QtCore import pyqtSignal
from PyQt5.QtCore import QUrl, pyqtSignal
from comicapi import utils
from comicapi.comicarchive import ComicArchive
from comicapi.genericmetadata import GenericMetadata
from comictaggerlib.comicvinetalker import ComicVineTalker, ComicVineTalkerException
from comictaggerlib.coverimagewidget import CoverImageWidget
from comictaggerlib.issueidentifier import IssueIdentifier
from comictaggerlib.issueselectionwindow import IssueSelectionWindow
from comictaggerlib.matchselectionwindow import MatchSelectionWindow
from comictaggerlib.progresswindow import IDProgressWindow
from comictaggerlib.resulttypes import CVVolumeResults
from comictaggerlib.settings import ComicTaggerSettings
from comictaggerlib.ui.qtutils import reduce_widget_font_size
from comictaggerlib.ui.qtutils import centerWindowOnParent, reduceWidgetFontSize
logger = logging.getLogger(__name__)
from .comicvinetalker import ComicVineTalker, ComicVineTalkerException
from .coverimagewidget import CoverImageWidget
from .genericmetadata import GenericMetadata
from .issueidentifier import IssueIdentifier
from .issueselectionwindow import IssueSelectionWindow
from .matchselectionwindow import MatchSelectionWindow
from .progresswindow import IDProgressWindow
from .settings import ComicTaggerSettings
class SearchThread(QtCore.QThread):
searchComplete = pyqtSignal()
progressUpdate = pyqtSignal(int, int)
def __init__(self, series_name: str, refresh: bool, literal: bool = False, series_match_thresh: int = 90) -> None:
def __init__(self, series_name, refresh, literal=False):
QtCore.QThread.__init__(self)
self.series_name = series_name
self.refresh: bool = refresh
self.error_code: int | None = None
self.cv_error = False
self.cv_search_results: list[CVVolumeResults] = []
self.refresh = refresh
self.error_code = None
self.literal = literal
self.series_match_thresh = series_match_thresh
def run(self) -> None:
comic_vine = ComicVineTalker(self.series_match_thresh)
def run(self):
comicVine = ComicVineTalker()
try:
self.cv_error = False
self.cv_search_results = comic_vine.search_for_series(
self.series_name, self.prog_callback, self.refresh, self.literal
)
if self.literal:
self.cv_search_results = comicVine.literalSearchForSeries(self.series_name, callback=self.prog_callback)
else:
self.cv_search_results = comicVine.searchForSeries(self.series_name, callback=self.prog_callback, refresh_cache=self.refresh)
except ComicVineTalkerException as e:
self.cv_search_results = []
self.cv_error = True
@ -67,67 +57,53 @@ class SearchThread(QtCore.QThread):
finally:
self.searchComplete.emit()
def prog_callback(self, current: int, total: int) -> None:
def prog_callback(self, current, total):
self.progressUpdate.emit(current, total)
class IdentifyThread(QtCore.QThread):
identifyComplete = pyqtSignal()
identifyLogMsg = pyqtSignal(str)
identifyProgress = pyqtSignal(int, int)
def __init__(self, identifier: IssueIdentifier) -> None:
def __init__(self, identifier):
QtCore.QThread.__init__(self)
self.identifier = identifier
self.identifier.set_output_function(self.log_output)
self.identifier.set_progress_callback(self.progress_callback)
self.identifier.setOutputFunction(self.logOutput)
self.identifier.setProgressCallback(self.progressCallback)
def log_output(self, text: str) -> None:
self.identifyLogMsg.emit(str(text))
def logOutput(self, text):
self.identifyLogMsg.emit(text)
def progress_callback(self, cur: int, total: int) -> None:
def progressCallback(self, cur, total):
self.identifyProgress.emit(cur, total)
def run(self) -> None:
self.identifier.search()
def run(self):
matches = self.identifier.search()
self.identifyComplete.emit()
class VolumeSelectionWindow(QtWidgets.QDialog):
def __init__(
self,
parent: QtWidgets.QWidget,
series_name: str,
issue_number: str,
year: int | None,
issue_count: int,
cover_index_list: list[int],
comic_archive: ComicArchive,
settings: ComicTaggerSettings,
autoselect: bool = False,
literal: bool = False,
) -> None:
super().__init__(parent)
self, parent, series_name, issue_number, year, issue_count, cover_index_list, comic_archive, settings, autoselect=False, literal=False
):
super(VolumeSelectionWindow, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.get_ui_file("volumeselectionwindow.ui"), self)
uic.loadUi(ComicTaggerSettings.getUIFile("volumeselectionwindow.ui"), self)
self.imageWidget = CoverImageWidget(self.imageContainer, CoverImageWidget.URLMode)
gridlayout = QtWidgets.QGridLayout(self.imageContainer)
gridlayout.addWidget(self.imageWidget)
gridlayout.setContentsMargins(0, 0, 0, 0)
reduce_widget_font_size(self.teDetails, 1)
reduce_widget_font_size(self.twList)
reduceWidgetFontSize(self.teDetails, 1)
reduceWidgetFontSize(self.twList)
self.setWindowFlags(
QtCore.Qt.WindowType(
self.windowFlags()
| QtCore.Qt.WindowType.WindowSystemMenuHint
| QtCore.Qt.WindowType.WindowMaximizeButtonHint
)
)
self.setWindowFlags(self.windowFlags() | QtCore.Qt.WindowSystemMenuHint | QtCore.Qt.WindowMaximizeButtonHint)
self.settings = settings
self.parent = parent
self.series_name = series_name
self.issue_number = issue_number
self.year = year
@ -136,47 +112,37 @@ class VolumeSelectionWindow(QtWidgets.QDialog):
self.comic_archive = comic_archive
self.immediate_autoselect = autoselect
self.cover_index_list = cover_index_list
self.cv_search_results: list[CVVolumeResults] = []
self.cv_search_results = None
self.literal = literal
self.ii: IssueIdentifier | None = None
self.iddialog: IDProgressWindow | None = None
self.id_thread: IdentifyThread | None = None
self.progdialog: QtWidgets.QProgressDialog | None = None
self.search_thread: SearchThread | None = None
self.use_filter = self.settings.always_use_publisher_filter
self.twList.resizeColumnsToContents()
self.twList.currentItemChanged.connect(self.current_item_changed)
self.twList.cellDoubleClicked.connect(self.cell_double_clicked)
self.twList.currentItemChanged.connect(self.currentItemChanged)
self.twList.cellDoubleClicked.connect(self.cellDoubleClicked)
self.btnRequery.clicked.connect(self.requery)
self.btnIssues.clicked.connect(self.show_issues)
self.btnAutoSelect.clicked.connect(self.auto_select)
self.btnIssues.clicked.connect(self.showIssues)
self.btnAutoSelect.clicked.connect(self.autoSelect)
self.cbxFilter.setChecked(self.use_filter)
self.cbxFilter.toggled.connect(self.filter_toggled)
self.update_buttons()
self.perform_query()
self.updateButtons()
self.performQuery()
self.twList.selectRow(0)
def update_buttons(self) -> None:
enabled = bool(self.cv_search_results)
def updateButtons(self):
if self.cv_search_results is not None and len(self.cv_search_results) > 0:
enabled = True
else:
enabled = False
self.btnRequery.setEnabled(enabled)
self.btnIssues.setEnabled(enabled)
self.btnAutoSelect.setEnabled(enabled)
self.buttonBox.button(QtWidgets.QDialogButtonBox.StandardButton.Ok).setEnabled(enabled)
self.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(enabled)
def requery(self) -> None:
self.perform_query(refresh=True)
def requery(self):
self.performQuery(refresh=True)
self.twList.selectRow(0)
def filter_toggled(self) -> None:
self.use_filter = not self.use_filter
self.perform_query(refresh=False)
def autoSelect(self):
def auto_select(self) -> None:
if self.comic_archive is None:
QtWidgets.QMessageBox.information(self, "Auto-Select", "You need to load a comic first!")
return
@ -187,7 +153,7 @@ class VolumeSelectionWindow(QtWidgets.QDialog):
self.iddialog = IDProgressWindow(self)
self.iddialog.setModal(True)
self.iddialog.rejected.connect(self.identify_cancel)
self.iddialog.rejected.connect(self.identifyCancel)
self.iddialog.show()
self.ii = IssueIdentifier(self.comic_archive, self.settings)
@ -196,89 +162,78 @@ class VolumeSelectionWindow(QtWidgets.QDialog):
md.series = self.series_name
md.issue = self.issue_number
md.year = self.year
md.issue_count = self.issue_count
md.issueCount = self.issue_count
self.ii.set_additional_metadata(md)
self.ii.only_use_additional_meta_data = True
self.ii.setAdditionalMetadata(md)
self.ii.onlyUseAdditionalMetaData = True
self.ii.cover_page_index = int(self.cover_index_list[0])
self.id_thread = IdentifyThread(self.ii)
self.id_thread.identifyComplete.connect(self.identify_complete)
self.id_thread.identifyLogMsg.connect(self.log_id_output)
self.id_thread.identifyProgress.connect(self.identify_progress)
self.id_thread.identifyComplete.connect(self.identifyComplete)
self.id_thread.identifyLogMsg.connect(self.logIDOutput)
self.id_thread.identifyProgress.connect(self.identifyProgress)
self.id_thread.start()
self.iddialog.exec()
self.iddialog.exec_()
def log_id_output(self, text: str) -> None:
if self.iddialog is not None:
print(text, end=" ") # noqa: T201
self.iddialog.textEdit.ensureCursorVisible()
self.iddialog.textEdit.insertPlainText(text)
def logIDOutput(self, text):
print(str(text), end=" ")
self.iddialog.textEdit.ensureCursorVisible()
self.iddialog.textEdit.insertPlainText(text)
def identify_progress(self, cur: int, total: int) -> None:
if self.iddialog is not None:
self.iddialog.progressBar.setMaximum(total)
self.iddialog.progressBar.setValue(cur)
def identifyProgress(self, cur, total):
self.iddialog.progressBar.setMaximum(total)
self.iddialog.progressBar.setValue(cur)
def identify_cancel(self) -> None:
if self.ii is not None:
self.ii.cancel = True
def identifyCancel(self):
self.ii.cancel = True
def identify_complete(self) -> None:
if self.ii is not None and self.iddialog is not None:
def identifyComplete(self):
matches = self.ii.match_list
result = self.ii.search_result
matches = self.ii.match_list
result = self.ii.search_result
match_index = 0
found_match = None
choices = False
if result == self.ii.result_no_matches:
QtWidgets.QMessageBox.information(self, "Auto-Select Result", " No matches found :-(")
elif result == self.ii.result_found_match_but_bad_cover_score:
QtWidgets.QMessageBox.information(
self,
"Auto-Select Result",
" Found a match, but cover doesn't seem the same. Verify before committing!",
)
found_match = matches[0]
elif result == self.ii.result_found_match_but_not_first_page:
QtWidgets.QMessageBox.information(
self, "Auto-Select Result", " Found a match, but not with the first page of the archive."
)
found_match = matches[0]
elif result == self.ii.result_multiple_matches_with_bad_image_scores:
QtWidgets.QMessageBox.information(
self, "Auto-Select Result", " Found some possibilities, but no confidence. Proceed manually."
)
choices = True
elif result == self.ii.result_one_good_match:
found_match = matches[0]
elif result == self.ii.result_multiple_good_matches:
QtWidgets.QMessageBox.information(
self, "Auto-Select Result", " Found multiple likely matches. Please select."
)
choices = True
found_match = None
choices = False
if result == self.ii.ResultNoMatches:
QtWidgets.QMessageBox.information(self, "Auto-Select Result", " No matches found :-(")
elif result == self.ii.ResultFoundMatchButBadCoverScore:
QtWidgets.QMessageBox.information(
self, "Auto-Select Result", " Found a match, but cover doesn't seem the same. Verify before commiting!"
)
found_match = matches[0]
elif result == self.ii.ResultFoundMatchButNotFirstPage:
QtWidgets.QMessageBox.information(self, "Auto-Select Result", " Found a match, but not with the first page of the archive.")
found_match = matches[0]
elif result == self.ii.ResultMultipleMatchesWithBadImageScores:
QtWidgets.QMessageBox.information(self, "Auto-Select Result", " Found some possibilities, but no confidence. Proceed manually.")
choices = True
elif result == self.ii.ResultOneGoodMatch:
found_match = matches[0]
elif result == self.ii.ResultMultipleGoodMatches:
QtWidgets.QMessageBox.information(self, "Auto-Select Result", " Found multiple likely matches. Please select.")
choices = True
if choices:
selector = MatchSelectionWindow(self, matches, self.comic_archive)
selector.setModal(True)
selector.exec()
if selector.result():
# we should now have a list index
found_match = selector.current_match()
if choices:
selector = MatchSelectionWindow(self, matches, self.comic_archive)
selector.setModal(True)
selector.exec_()
if selector.result():
# we should now have a list index
found_match = selector.currentMatch()
if found_match is not None:
self.iddialog.accept()
if found_match is not None:
self.iddialog.accept()
self.volume_id = utils.xlate(found_match["volume_id"])
self.issue_number = found_match["issue_number"]
self.select_by_id()
self.show_issues()
self.volume_id = found_match["volume_id"]
self.issue_number = found_match["issue_number"]
self.selectByID()
self.showIssues()
def show_issues(self) -> None:
def showIssues(self):
selector = IssueSelectionWindow(self, self.settings, self.volume_id, self.issue_number)
title = ""
for record in self.cv_search_results:
@ -290,196 +245,132 @@ class VolumeSelectionWindow(QtWidgets.QDialog):
selector.setWindowTitle(title + "Select Issue")
selector.setModal(True)
selector.exec()
selector.exec_()
if selector.result():
# we should now have a volume ID
self.issue_number = selector.issue_number
self.accept()
return
def select_by_id(self) -> None:
def selectByID(self):
for r in range(0, self.twList.rowCount()):
volume_id = self.twList.item(r, 0).data(QtCore.Qt.ItemDataRole.UserRole)
volume_id = self.twList.item(r, 0).data(QtCore.Qt.UserRole)
if volume_id == self.volume_id:
self.twList.selectRow(r)
break
def perform_query(self, refresh: bool = False) -> None:
def performQuery(self, refresh=False):
self.progdialog = QtWidgets.QProgressDialog("Searching Online", "Cancel", 0, 100, self)
self.progdialog.setWindowTitle("Online Search")
self.progdialog.canceled.connect(self.search_canceled)
self.progdialog.canceled.connect(self.searchCanceled)
self.progdialog.setModal(True)
self.progdialog.setMinimumDuration(300)
self.search_thread = SearchThread(
self.series_name, refresh, self.literal, self.settings.id_series_match_search_thresh
)
self.search_thread.searchComplete.connect(self.search_complete)
self.search_thread.progressUpdate.connect(self.search_progress_update)
QtCore.QCoreApplication.processEvents()
self.search_thread = SearchThread(self.series_name, refresh, self.literal)
self.search_thread.searchComplete.connect(self.searchComplete)
self.search_thread.progressUpdate.connect(self.searchProgressUpdate)
self.search_thread.start()
self.progdialog.exec()
self.progdialog.exec_()
def search_canceled(self) -> None:
if self.progdialog is not None:
logger.info("query cancelled")
if self.search_thread is not None:
self.search_thread.searchComplete.disconnect()
self.search_thread.progressUpdate.disconnect()
self.progdialog.canceled.disconnect()
self.progdialog.reject()
QtCore.QTimer.singleShot(200, self.close_me)
def searchCanceled(self):
print("query cancelled")
self.search_thread.searchComplete.disconnect(self.searchComplete)
self.search_thread.progressUpdate.disconnect(self.searchProgressUpdate)
self.progdialog.canceled.disconnect(self.searchCanceled)
self.progdialog.reject()
QtCore.QTimer.singleShot(200, self.closeMe)
def close_me(self) -> None:
def closeMe(self):
print("closeme")
self.reject()
def search_progress_update(self, current: int, total: int) -> None:
if self.progdialog is not None:
self.progdialog.setMaximum(total)
self.progdialog.setValue(current + 1)
def searchProgressUpdate(self, current, total):
self.progdialog.setMaximum(total)
self.progdialog.setValue(current + 1)
def search_complete(self) -> None:
if self.progdialog is not None:
self.progdialog.accept()
del self.progdialog
if self.search_thread is not None and self.search_thread.cv_error:
if self.search_thread.error_code == ComicVineTalkerException.RateLimit:
QtWidgets.QMessageBox.critical(self, "Comic Vine Error", ComicVineTalker.get_rate_limit_message())
else:
QtWidgets.QMessageBox.critical(
self, "Network Issue", "Could not connect to Comic Vine to search for series!"
)
return
self.cv_search_results = self.search_thread.cv_search_results if self.search_thread is not None else []
# filter the publishers if enabled set
if self.use_filter:
try:
publisher_filter = {s.strip().casefold() for s in self.settings.id_publisher_filter.split(",")}
# use '' as publisher name if None
self.cv_search_results = list(
filter(
lambda d: ("" if d["publisher"] is None else str(d["publisher"]["name"]).casefold())
not in publisher_filter,
self.cv_search_results,
)
)
except Exception:
logger.exception("bad data error filtering publishers")
# pre sort the data - so that we can put exact matches first afterwards
# compare as str in case extra chars ie. '1976?'
# - missing (none) values being converted to 'None' - consistent with prior behaviour in v1.2.3
# sort by start_year if set
if self.settings.sort_series_by_year:
try:
self.cv_search_results = sorted(
self.cv_search_results,
key=lambda i: (str(i["start_year"]), str(i["count_of_issues"])),
reverse=True,
)
except Exception:
logger.exception("bad data error sorting results by start_year,count_of_issues")
def searchComplete(self):
self.progdialog.accept()
del self.progdialog
QtCore.QCoreApplication.processEvents()
if self.search_thread.cv_error:
if self.search_thread.error_code == ComicVineTalkerException.RateLimit:
QtWidgets.QMessageBox.critical(self, self.tr("Comic Vine Error"), ComicVineTalker.getRateLimitMessage())
else:
try:
self.cv_search_results = sorted(
self.cv_search_results, key=lambda i: str(i["count_of_issues"]), reverse=True
)
except Exception:
logger.exception("bad data error sorting results by count_of_issues")
QtWidgets.QMessageBox.critical(self, self.tr("Network Issue"), self.tr("Could not connect to Comic Vine to search for series!"))
return
# move sanitized matches to the front
if self.settings.exact_series_matches_first:
try:
sanitized = utils.sanitize_title(self.series_name, False).casefold()
sanitized_no_articles = utils.sanitize_title(self.series_name, True).casefold()
self.cv_search_results = self.search_thread.cv_search_results
self.updateButtons()
deques: list[deque[CVVolumeResults]] = [deque(), deque(), deque()]
self.twList.setSortingEnabled(False)
def categorize(result: CVVolumeResults) -> int:
# We don't remove anything on this one so that we only get exact matches
if utils.sanitize_title(result["name"], True).casefold() == sanitized_no_articles:
return 0
while self.twList.rowCount() > 0:
self.twList.removeRow(0)
# this ensures that 'The Joker' is near the top even if you search 'Joker'
if utils.sanitize_title(result["name"], False).casefold() in sanitized:
return 1
return 2
row = 0
for record in self.cv_search_results:
self.twList.insertRow(row)
for comic in self.cv_search_results:
deques[categorize(comic)].append(comic)
logger.info("Length: %d, %d, %d", len(deques[0]), len(deques[1]), len(deques[2]))
self.cv_search_results = list(itertools.chain.from_iterable(deques))
except Exception:
logger.exception("bad data error filtering exact/near matches")
item_text = record["name"]
item = QtWidgets.QTableWidgetItem(item_text)
item.setData(QtCore.Qt.ToolTipRole, item_text)
item.setData(QtCore.Qt.UserRole, record["id"])
item.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 0, item)
self.update_buttons()
item_text = str(record["start_year"])
item = QtWidgets.QTableWidgetItem(item_text)
item.setData(QtCore.Qt.ToolTipRole, item_text)
item.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 1, item)
self.twList.setSortingEnabled(False)
item_text = record["count_of_issues"]
item = QtWidgets.QTableWidgetItem(item_text)
item.setData(QtCore.Qt.ToolTipRole, item_text)
item.setData(QtCore.Qt.DisplayRole, record["count_of_issues"])
item.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 2, item)
self.twList.setRowCount(0)
row = 0
for record in self.cv_search_results:
self.twList.insertRow(row)
item_text = record["name"]
if record["publisher"] is not None:
item_text = record["publisher"]["name"]
item.setData(QtCore.Qt.ToolTipRole, item_text)
item = QtWidgets.QTableWidgetItem(item_text)
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
item.setData(QtCore.Qt.ItemDataRole.UserRole, record["id"])
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
self.twList.setItem(row, 0, item)
item.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 3, item)
item_text = str(record["start_year"])
item = QtWidgets.QTableWidgetItem(item_text)
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
self.twList.setItem(row, 1, item)
row += 1
item_text = str(record["count_of_issues"])
item = QtWidgets.QTableWidgetItem(item_text)
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
item.setData(QtCore.Qt.ItemDataRole.DisplayRole, record["count_of_issues"])
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
self.twList.setItem(row, 2, item)
self.twList.resizeColumnsToContents()
self.twList.setSortingEnabled(True)
self.twList.sortItems(2, QtCore.Qt.DescendingOrder)
self.twList.selectRow(0)
self.twList.resizeColumnsToContents()
if record["publisher"] is not None:
item_text = record["publisher"]["name"]
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
item = QtWidgets.QTableWidgetItem(item_text)
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
self.twList.setItem(row, 3, item)
row += 1
self.twList.setSortingEnabled(True)
self.twList.selectRow(0)
self.twList.resizeColumnsToContents()
def showEvent(self, event: QtGui.QShowEvent) -> None:
if not self.cv_search_results:
if len(self.cv_search_results) == 0:
QtCore.QCoreApplication.processEvents()
QtWidgets.QMessageBox.information(self, "Search Result", "No matches found!")
QtCore.QTimer.singleShot(200, self.close_me)
elif self.immediate_autoselect:
if self.immediate_autoselect and len(self.cv_search_results) > 0:
# defer the immediate autoselect so this dialog has time to pop up
QtCore.QCoreApplication.processEvents()
QtCore.QTimer.singleShot(10, self.do_immediate_autoselect)
QtCore.QTimer.singleShot(10, self.doImmediateAutoselect)
def do_immediate_autoselect(self) -> None:
def doImmediateAutoselect(self):
self.immediate_autoselect = False
self.auto_select()
self.autoSelect()
def cell_double_clicked(self, r: int, c: int) -> None:
self.show_issues()
def cellDoubleClicked(self, r, c):
self.showIssues()
def current_item_changed(self, curr: QtCore.QModelIndex | None, prev: QtCore.QModelIndex | None) -> None:
def currentItemChanged(self, curr, prev):
if curr is None:
return
if prev is not None and prev.row() == curr.row():
return
self.volume_id = self.twList.item(curr.row(), 0).data(QtCore.Qt.ItemDataRole.UserRole)
self.volume_id = self.twList.item(curr.row(), 0).data(QtCore.Qt.UserRole)
# list selection was changed, update the info on the volume
for record in self.cv_search_results:
@ -488,5 +379,5 @@ class VolumeSelectionWindow(QtWidgets.QDialog):
self.teDetails.setText("")
else:
self.teDetails.setText(record["description"])
self.imageWidget.set_url(record["image"]["super_url"])
self.imageWidget.setURL(record["image"]["super_url"])
break

View File

@ -1,4 +1,4 @@
This file is a placeholder that will automatically be replaced with a symlink to
This file is a placeholder that will automaticlly be replaced with a symlink to
the local machine's Python framework python binary.
When pip does an uninstall, it will remove the link.

View File

@ -5,7 +5,7 @@
# binary to call the CT script. This is all so that the
# Mac menu doesn't say "Python".
realpath()
realpath()
{
[[ $1 = /* ]] && echo "$1" || echo "$PWD/${1#./}"
}

52
file_version_info.py Normal file
View File

@ -0,0 +1,52 @@
# UTF-8
#
# For more details about fixed file info 'ffi' see:
# http://msdn.microsoft.com/en-us/library/ms646997.aspx
exec(
'''
with open("comictaggerlib/_version.py") as file:
exec(file.read())
version_tuple = version_tuple + (0,) if len(version_tuple) < 4 else version_tuple
version_tuple = version_tuple if str(version_tuple[3]).isnumeric() else version_tuple[:3] + (int(version_tuple[3].replace("dev","")),)
'''
) or VSVersionInfo(
ffi=FixedFileInfo(
# filevers and prodvers should be always a tuple with four items: (1, 2, 3, 4)
# Set not needed items to zero 0.
filevers=version_tuple,
prodvers=version_tuple,
# Contains a bitmask that specifies the valid bits 'flags'r
mask=0x3F,
# Contains a bitmask that specifies the Boolean attributes of the file.
flags=0x0,
# The operating system for which this file was designed.
# 0x4 - NT and there is no need to change it.
OS=0x40004,
# The general type of file.
# 0x1 - the file is an application.
fileType=0x1,
# The function of the file.
# 0x0 - the function is not defined for this fileType
subtype=0x0,
# Creation date and time stamp.
date=(0, 0),
),
kids=[
StringFileInfo(
[
StringTable(
"040904B0",
[
StringStruct("CompanyName", "ComicTagger team"),
StringStruct("FileDescription", "A cross-platform GUI/CLI app for writing metadata to comic archives"),
StringStruct("FileVersion", version),
StringStruct("OriginalFilename", "comictagger.exe"),
StringStruct("ProductName", "ComicTagger"),
StringStruct("ProductVersion", version),
],
)
]
),
VarFileInfo([VarStruct("Translation", [1033, 1200])]),
],
)

View File

@ -1,50 +0,0 @@
from __future__ import annotations
import locale
import logging
import os
import subprocess
import sys
def _lang_code_mac():
"""
stolen from https://github.com/mu-editor/mu
Returns the user's language preference as defined in the Language & Region
preference pane in macOS's System Preferences.
"""
# Uses the shell command `defaults read -g AppleLocale` that prints out a
# language code to standard output. Assumptions about the command:
# - It exists and is in the shell's PATH.
# - It accepts those arguments.
# - It returns a usable language code.
#
# Reference documentation:
# - The man page for the `defaults` command on macOS.
# - The macOS underlying API:
# https://developer.apple.com/documentation/foundation/nsuserdefaults.
lang_detect_command = "defaults read -g AppleLocale"
status, output = subprocess.getstatusoutput(lang_detect_command)
if status == 0:
# Command was successful.
lang_code = output
else:
logging.warning("Language detection command failed: %r", output)
lang_code = ""
return lang_code
def configure_locale():
if sys.platform == "darwin" and "LANG" not in os.environ:
code = _lang_code_mac()
if code != "":
os.environ["LANG"] = f"{code}.utf-8"
locale.setlocale(locale.LC_ALL, "")
sys.stdout.reconfigure(encoding=sys.getdefaultencoding())
sys.stderr.reconfigure(encoding=sys.getdefaultencoding())
sys.stdin.reconfigure(encoding=sys.getdefaultencoding())

View File

@ -1,9 +1,11 @@
#PYINSTALLER_CMD := VERSIONER_PYTHON_PREFER_32_BIT=yes arch -i386 python $(HOME)/pyinstaller-2.0/pyinstaller.py
#PYINSTALLER_CMD := python $(HOME)/pyinstaller-2.0/pyinstaller.py
PYINSTALLER_CMD := pyinstaller
TAGGER_BASE ?= ../
TAGGER_SRC := $(TAGGER_BASE)/comictaggerlib
APP_NAME := ComicTagger
VERSION_STR := $(shell cd .. && python setup.py --version)
VERSION_STR := $(shell grep version $(TAGGER_SRC)/ctversion.py| cut -d= -f2 | sed 's/\"//g')
MAC_BASE := $(TAGGER_BASE)/mac
DIST_DIR := $(MAC_BASE)/dist
@ -15,12 +17,22 @@ DMG_FILE := $(VOLUME_NAME).dmg
all: clean dist diskimage
dist:
$(PYINSTALLER_CMD) $(TAGGER_BASE)/comictagger.py -w -n $(APP_NAME) -s
#$(PYINSTALLER_CMD) $(TAGGER_BASE)/comictagger.py -o $(MAC_BASE) -w -n $(APP_NAME) -s
$(PYINSTALLER_CMD) $(TAGGER_BASE)/comictagger.py -w -n $(APP_NAME) -s
cp -a $(TAGGER_SRC)/ui $(APP_BUNDLE)/Contents/MacOS
cp -a $(TAGGER_SRC)/graphics $(APP_BUNDLE)/Contents/MacOS
cp $(MAC_BASE)/libunrar.so $(APP_BUNDLE)/Contents/MacOS
cp $(MAC_BASE)/app.icns $(APP_BUNDLE)/Contents/Resources/icon-windowed.icns
# fix the version string in the Info.plist
sed -i -e 's/0\.0\.0/$(VERSION_STR)/' $(MAC_BASE)/dist/ComicTagger.app/Contents/Info.plist
# strip out PPC/x64
#./make_thin.sh dist/ComicTagger.app/Contents/MacOS
#./make_thin.sh dist/ComicTagger.app/Contents/MacOS/qt4_plugins/accessible
#./make_thin.sh dist/ComicTagger.app/Contents/MacOS/qt4_plugins/bearer
#./make_thin.sh dist/ComicTagger.app/Contents/MacOS/qt4_plugins/codecs
#./make_thin.sh dist/ComicTagger.app/Contents/MacOS/qt4_plugins/graphicssystems
#./make_thin.sh dist/ComicTagger.app/Contents/MacOS/qt4_plugins/iconengines
#./make_thin.sh dist/ComicTagger.app/Contents/MacOS/qt4_plugins/imageformats
clean:
rm -rf $(DIST_DIR) $(MAC_BASE)/build
@ -30,7 +42,7 @@ clean:
rm -f raw*.dmg
echo $(VERSION_STR)
diskimage:
# Set up disk image staging folder
#Set up disk image staging folder
rm -rf $(STAGING)
mkdir $(STAGING)
cp $(TAGGER_BASE)/release_notes.txt $(STAGING)
@ -39,27 +51,28 @@ diskimage:
cp $(MAC_BASE)/volume.icns $(STAGING)/.VolumeIcon.icns
SetFile -c icnC $(STAGING)/.VolumeIcon.icns
# generate raw disk image
##generate raw disk image
rm -f $(DMG_FILE)
hdiutil create -srcfolder $(STAGING) -volname $(VOLUME_NAME) -format UDRW -ov raw-$(DMG_FILE)
hdiutil create -srcfolder $(STAGING) -volname $(VOLUME_NAME) -format UDRW -ov raw-$(DMG_FILE)
# remove working files and folders
#remove working files and folders
rm -rf $(STAGING)
# we now have a raw DMG file.
# remount it so we can set the volume icon properly
mkdir -p $(STAGING)
hdiutil attach raw-$(DMG_FILE) -mountpoint $(STAGING)
SetFile -a C $(STAGING)
hdiutil detach $(STAGING)
rm -rf $(STAGING)
# convert the raw image
rm -f $(DMG_FILE)
hdiutil convert raw-$(DMG_FILE) -format UDZO -o $(DMG_FILE)
rm -f raw-$(DMG_FILE)
# move finished product to release folder
#move finished product to release folder
mkdir -p $(TAGGER_BASE)/release
mv $(DMG_FILE) $(TAGGER_BASE)/release

View File

@ -8,12 +8,12 @@ do
then
echo "Fat Binary: $FILE"
mkdir -p thin
lipo -thin i386 -output thin/$FILE $BINFOLDER/$FILE
lipo -thin i386 -output thin/$FILE $BINFOLDER/$FILE
fi
done
if [ -d thin ]
then
then
mv thin/* $BINFOLDER
else
echo No files to lipo

View File

@ -1,25 +1,14 @@
[tool.black]
line-length = 120
force-exclude = "scripts"
line-length = 150
[tool.isort]
line_length = 120
extend_skip = ["scripts"]
profile = "black"
line_length = 150
[build-system]
requires = ["setuptools>=42", "wheel", "setuptools_scm[toml]>=3.4"]
build-backend = "setuptools.build_meta"
[tool.setuptools_scm]
write_to = "comictaggerlib/ctversion.py"
write_to = "comictaggerlib/_version.py"
local_scheme = "no-local-version"
[tool.pylint.messages_control]
disable = "C0330, C0326, C0115, C0116, C0103"
[tool.pylint.format]
max-line-length=120
[tool.pylint.master]
extension-pkg-whitelist="PyQt5"
version_scheme = "python-simplified-semver"

View File

@ -1,221 +1,221 @@
---------------------------------
1.1.16-beta-rc - 07-Apr-2017
---------------------------------
* Fix ComicVine SSL problems (issue #87)
---------------------------------
1.1.15-beta - 13-Jun-2014
---------------------------------
* WebP support
* Added user-configurable API key for Comic Vine access
* Experimental option to wait and retry after exceeding Comic Vine rate limit
---------------------------------
1.1.14-beta - 13-Apr-2014
---------------------------------
* Make sure app gets raised when enforcing single instance
* Added warning dialog for when opening rar files, and no (un)rar tool
* remove pil from python package requirements
---------------------------------
1.1.13-beta - 9-Apr-2014
---------------------------------
* Handle non-ascii user names properly
* better parsing of html table in summary text, and optional removal
* Python package should auto-install requirements
* Specify default GUI tag style on command-line
* enforce single GUI instance
* new CBL transform to copy story arcs to generic tags
* Persist some auto-tag settings
---------------------------------
1.1.12-beta - 23-Mar-2014
---------------------------------
* Fixed noisy version update error
---------------------------------
1.1.11-beta - 23-Mar-2014
---------------------------------
* Updated unrar library to hand Rar tools 5.0 and greater
* Other misc bug fixes
---------------------------------
1.1.10-beta - 30-Jan-2014
---------------------------------
* Updated series query to match changes on Comic Vine side
* Added a message when not able to open a file or folder
* Fixed an issue where series names with periods would fail on search
* Other misc bug fixes
---------------------------------
1.1.9-beta - 8-May-2013
---------------------------------
* Filename parser and identification enhancements
* Misc bug fixes
---------------------------------
1.1.8-beta - 21-Apr-2013
---------------------------------
* Handle occasional error 500 from Comic Vine by retrying a few times
* Nicer handling of colon (":") in file rename
* Fixed command-line option parsing issue for add-on scripts
* Misc bug fixes
---------------------------------
1.1.7-beta - 12-Apr-2013
---------------------------------
* Added description and cover date to issue selection dialogs
* Added notification of new version
* Added setting to attempt to parse scan info from file name
* Last sorted column in the file list is now remembered
* Added CLI option ('-1') to assume issue #1 if not found/parsed
* Misc bug fixes
---------------------------------
1.1.6-beta - 3-Apr-2013
---------------------------------
* More ComicVine API-related fixes
* More efficient automated search using new CV API issue filters
* Minor bug fixes
---------------------------------
1.1.5-beta - 30-Mar-2013
---------------------------------
* More updates for handling changes to ComicVine API and result sets
* Even better handling of non-numeric issue "numbers" ("½", "X")
---------------------------------
1.1.4-beta - 27-Mar-2013
---------------------------------
* Updated to match the changes to the ComicVine API and result sets
* Better handling of weird issue numbers ("0.1", "6au")
---------------------------------
1.1.3-beta - 25-Feb-2013
---------------------------------
Bug Fixes:
* Fixed a bug when renaming on non-English systems
* Fixed issue when saving settings on non-English systems
* Fixed a bug when comic contains non-RGB images
* Fixed a rare crash when comic image is not-RGB format
* Fixed sequence order of ComicInfo.xml items
Note:
New requirement for users of the python package: "configparser"
---------------------------------
1.1.2-beta - 14-Feb-2013
---------------------------------
Changes:
* Source is now packaged using Python distutils
* Recursive mode for CLI
* Run custom add-on scripts from CLI
* Minor UI tweaks
* Misc bug fixes
---------------------------------
1.1.0-beta - 06-Feb-2013
---------------------------------
Changes:
* Enhanced identification process to use alternative covers from ComicVine
* Post auto-tag manual matching now includes single low-confidence matches (CLI & GUI)
* Page and cover view mini-browser available throughout app. Most images can be
double-clicked for enlarged view
* Export-to-zip in CLI (very handy in scripts!)
* More rename template variables
* Misc GUI & CLI Tweaks
---------------------------------
1.0.3-beta - 31-Jan-2013
---------------------------------
Changes:
Misc bug fixes and enhancements
---------------------------------
1.0.2-beta - 25-Jan-2013
---------------------------------
Changes:
More verbose logging during auto-tag
Added %month% and %month_name% for renaming
Better parsing of volume numbers in file name
Bugs:
Better exception handling with corrupted image data
Fixed issues with RAR reading on OS X
Other minor bug fixes
---------------------------------
1.0.1-beta - 24-Jan-2013
---------------------------------
Bug Fix:
Fixed an issue where unicode strings can't be printed to OS X Console
---------------------------------
1.0.0-beta - 23-Jan-2013
---------------------------------
Version 1! New multi-file processing in GUI!
GUI Changes:
Open multiple files and/or folders via drag/drop or file dialog
File management list for easy viewing and selection
Batch tag remove
Batch export as zip
Batch rename
Batch tag copy
Batch auto-tag (automatic identification and save!)
---------------------------------
0.9.5-beta - 16-Jan-2013
---------------------------------
Changes:
Added CLI option to search by Comic Vine issue ID
Some image loading optimizations
Bug Fix: Some CBL fields that should have been ints were written as strings
---------------------------------
0.9.4-beta - 7-Jan-2013
---------------------------------
Changes:
Better handling of non-ascii characters in file names and data
Add CBL Transform to copy Web Link and Notes to comments
Minor bug fixes
---------------------------------
0.9.3-beta - 19-Dec-2012
---------------------------------
Changes:
File rename in GUI
Setting for file rename
Option to use series start year as volume
Added "CBL Transform" to handle primary credits copying data into the generic tags field
Bug Fix: unicode characters in credits caused crash
Bug Fix: bad or non-image data in file caused crash
Note:
The user should clear the cache and delete the existing settings when first running this version.
---------------------------------
0.9.2-beta - 13-Dec-2012
---------------------------------
Page List/Type editing in GUI
File globbing for windows CLI (i.e. use of wildcards like '*.cbz')
Fixed RAR writing bug on windows
Minor bug and crash fixes
---------------------------------
0.9.1-beta - 07-Dec-2012
---------------------------------
Export as ZIP Archive
Added help menu option for websites
Added Primary Credit Flag editing
Menu enhancements
CLI Enhancements:
Interactive selection of matches
Tag copy
Better output
CoMet support
Minor bug and crash fixes
---------------------------------
0.9.0-beta - 30-Nov-2012
---------------------------------
Initial beta release
---------------------------------
1.1.16-beta-rc - 07-Apr-2017
---------------------------------
* Fix ComicVine SSL problems (issue #87)
---------------------------------
1.1.15-beta - 13-Jun-2014
---------------------------------
* WebP support
* Added user-configurable API key for Comic Vine access
* Experimental option to wait and retry after exceeding Comic Vine rate limit
---------------------------------
1.1.14-beta - 13-Apr-2014
---------------------------------
* Make sure app gets raised when enforcing single instance
* Added warning dialog for when opening rar files, and no (un)rar tool
* remove pil from python package requirements
---------------------------------
1.1.13-beta - 9-Apr-2014
---------------------------------
* Handle non-ascii user names properly
* better parsing of html table in summary text, and optional removal
* Python package should auto-install requirements
* Specify default GUI tag style on command-line
* enforce single GUI instance
* new CBL transform to copy story arcs to generic tags
* Persist some auto-tag settings
---------------------------------
1.1.12-beta - 23-Mar-2014
---------------------------------
* Fixed noisy version update error
---------------------------------
1.1.11-beta - 23-Mar-2014
---------------------------------
* Updated unrar library to hand Rar tools 5.0 and greater
* Other misc bug fixes
---------------------------------
1.1.10-beta - 30-Jan-2014
---------------------------------
* Updated series query to match changes on Comic Vine side
* Added a message when not able to open a file or folder
* Fixed an issue where series names with periods would fail on search
* Other misc bug fixes
---------------------------------
1.1.9-beta - 8-May-2013
---------------------------------
* Filename parser and identification enhancements
* Misc bug fixes
---------------------------------
1.1.8-beta - 21-Apr-2013
---------------------------------
* Handle occasional error 500 from Comic Vine by retrying a few times
* Nicer handling of colon (":") in file rename
* Fixed command-line option parsing issue for add-on scripts
* Misc bug fixes
---------------------------------
1.1.7-beta - 12-Apr-2013
---------------------------------
* Added description and cover date to issue selection dialogs
* Added notification of new version
* Added setting to attempt to parse scan info from file name
* Last sorted column in the file list is now remembered
* Added CLI option ('-1') to assume issue #1 if not found/parsed
* Misc bug fixes
---------------------------------
1.1.6-beta - 3-Apr-2013
---------------------------------
* More ComicVine API-related fixes
* More efficient automated search using new CV API issue filters
* Minor bug fixes
---------------------------------
1.1.5-beta - 30-Mar-2013
---------------------------------
* More updates for handling changes to ComicVine API and result sets
* Even better handling of non-numeric issue "numbers" ("½", "X")
---------------------------------
1.1.4-beta - 27-Mar-2013
---------------------------------
* Updated to match the changes to the ComicVine API and result sets
* Better handling of weird issue numbers ("0.1", "6au")
---------------------------------
1.1.3-beta - 25-Feb-2013
---------------------------------
Bug Fixes:
* Fixed a bug when renaming on non-English systems
* Fixed issue when saving settings on non-English systems
* Fixed a bug when comic contains non-RGB images
* Fixed a rare crash when comic image is not-RGB format
* Fixed sequence order of ComicInfo.xml items
Note:
New requirement for users of the python package: "configparser"
---------------------------------
1.1.2-beta - 14-Feb-2013
---------------------------------
Changes:
* Source is now packaged using Python distutils
* Recursive mode for CLI
* Run custom add-on scripts from CLI
* Minor UI tweaks
* Misc bug fixes
---------------------------------
1.1.0-beta - 06-Feb-2013
---------------------------------
Changes:
* Enhanced identification process to use alternative covers from ComicVine
* Post auto-tag manual matching now includes single low-confidence matches (CLI & GUI)
* Page and cover view mini-browser available throughout app. Most images can be
double-clicked for enlarged view
* Export-to-zip in CLI (very handy in scripts!)
* More rename template variables
* Misc GUI & CLI Tweaks
---------------------------------
1.0.3-beta - 31-Jan-2013
---------------------------------
Changes:
Misc bug fixes and enhancements
---------------------------------
1.0.2-beta - 25-Jan-2013
---------------------------------
Changes:
More verbose logging during auto-tag
Added %month% and %month_name% for renaming
Better parsing of volume numbers in file name
Bugs:
Better exception handling with corrupted image data
Fixed issues with RAR reading on OS X
Other minor bug fixes
---------------------------------
1.0.1-beta - 24-Jan-2013
---------------------------------
Bug Fix:
Fixed an issue where unicode strings can't be printed to OS X Console
---------------------------------
1.0.0-beta - 23-Jan-2013
---------------------------------
Version 1! New multi-file processing in GUI!
GUI Changes:
Open multiple files and/or folders via drag/drop or file dialog
File management list for easy viewing and selection
Batch tag remove
Batch export as zip
Batch rename
Batch tag copy
Batch auto-tag (automatic identification and save!)
---------------------------------
0.9.5-beta - 16-Jan-2013
---------------------------------
Changes:
Added CLI option to search by Comic Vine issue ID
Some image loading optimizations
Bug Fix: Some CBL fields that should have been ints were written as strings
---------------------------------
0.9.4-beta - 7-Jan-2013
---------------------------------
Changes:
Better handling of non-ascii characters in file names and data
Add CBL Transform to copy Web Link and Notes to comments
Minor bug fixes
---------------------------------
0.9.3-beta - 19-Dec-2012
---------------------------------
Changes:
File rename in GUI
Setting for file rename
Option to use series start year as volume
Added "CBL Transform" to handle primary credits copying data into the generic tags field
Bug Fix: unicode characters in credits caused crash
Bug Fix: bad or non-image data in file caused crash
Note:
The user should clear the cache and delete the existing settings when first running this version.
---------------------------------
0.9.2-beta - 13-Dec-2012
---------------------------------
Page List/Type editing in GUI
File globbing for windows CLI (i.e. use of wildcards like '*.cbz')
Fixed RAR writing bug on windows
Minor bug and crash fixes
---------------------------------
0.9.1-beta - 07-Dec-2012
---------------------------------
Export as ZIP Archive
Added help menu option for websites
Added Primary Credit Flag editing
Menu enhancements
CLI Enhancements:
Interactive selection of matches
Tag copy
Better output
CoMet support
Minor bug and crash fixes
---------------------------------
0.9.0-beta - 30-Nov-2012
---------------------------------
Initial beta release

View File

@ -1 +1 @@
unrar-cffi>=0.2.2
comicapi[CBR] @ git+https://github.com/lordwelch/comicapi

View File

@ -1 +1 @@
PyQt5
pyqt5

1
requirements-scripts.txt Normal file
View File

@ -0,0 +1 @@
filetype

View File

@ -1 +0,0 @@
thefuzz[speedup]>=0.19.0

View File

@ -1,12 +1,7 @@
beautifulsoup4 >= 4.1
importlib_metadata
natsort>=8.1.0
configparser
natsort
pathvalidate
pillow>=9.1.0
py7zr
pycountry
requests==2.*
text2digits
thefuzz>=0.19.0
typing_extensions
wordninja
pillow>=4.3.0
requests
comicapi @ git+https://github.com/lordwelch/comicapi

View File

@ -1,12 +1,4 @@
black>=22
build
flake8==4.*
flake8-black
flake8-encodings
flake8-isort
isort>=5.10
pyinstaller>=4.10
pytest==7.*
pyinstaller
setuptools>=42
setuptools_scm[toml]>=3.4
wheel

View File

@ -1,28 +1,28 @@
This folder contains a set of example scripts that be used to extend the
capabilities of the ComicTagger app. They can be run either directly through
the python interpreter, or via the ComicTagger app.
To run via python directly, install ComicTagger source on your system using
the setup.py file.
To run via the ComicTagger app, invoke:
$ comictagger.py -S script.py [script args]
(This will work also for binary distributions on Mac and Windows. No need for
an extra python install.)
The script must have an entry point function called "main()" to be invoked
via the app.
-----------------------------------------------------------------------------
This feature is UNSUPPORTED, and is for the convenience of development-minded
users of ComicTagger. The comictaggerlib module will remain largely
undocumented, and it will be up to the crafty script developer to look through
the code to discern APIs and such.
That said, if there are questions, please post in the forums, and hopefully we
can get your add-on scripts working!
http://comictagger.forumotion.com/
This folder contains a set of example scripts that be used to extend the
capabilities of the ComicTagger app. They can be run either directly through
the python interpreter, or via the ComicTagger app.
To run via python directly, install ComicTagger source on your system using
the setup.py file.
To run via the ComicTagger app, invoke:
$ comictagger.py -S script.py [script args]
(This will work also for binary distributions on Mac and Windows. No need for
an extra python install.)
The script must have an entry point function called "main()" to be invoked
via the app.
-----------------------------------------------------------------------------
This feature is UNSUPPORTED, and is for the convenience of development-minded
users of ComicTagger. The comictaggerlib module will remain largely
undocumented, and it will be up to the crafty script developer to look through
the code to discern APIs and such.
That said, if there are questions, please post in the forums, and hopefully we
can get your add-on scripts working!
http://comictagger.forumotion.com/

158
scripts/dupe.ui Normal file
View File

@ -0,0 +1,158 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>729</width>
<height>406</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout">
<property name="sizeConstraint">
<enum>QLayout::SetMinAndMaxSize</enum>
</property>
<item>
<widget class="QListWidget" name="dupeList">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Minimum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::ExtendedSelection</enum>
</property>
<property name="selectionBehavior">
<enum>QAbstractItemView::SelectRows</enum>
</property>
</widget>
</item>
<item>
<widget class="QSplitter" name="splitter">
<property name="enabled">
<bool>true</bool>
</property>
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="childrenCollapsible">
<bool>false</bool>
</property>
<widget class="QTableWidget" name="pageList">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="editTriggers">
<set>QAbstractItemView::NoEditTriggers</set>
</property>
<property name="showDropIndicator" stdset="0">
<bool>false</bool>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::SingleSelection</enum>
</property>
<property name="selectionBehavior">
<enum>QAbstractItemView::SelectRows</enum>
</property>
<property name="sortingEnabled">
<bool>true</bool>
</property>
<property name="columnCount">
<number>3</number>
</property>
<column>
<property name="text">
<string>name</string>
</property>
</column>
<column>
<property name="text">
<string>score</string>
</property>
</column>
<column>
<property name="text">
<string>dupe name</string>
</property>
</column>
</widget>
<widget class="QFrame" name="comicData">
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<widget class="QFrame" name="comic1Data">
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
<layout class="QVBoxLayout" name="verticalLayout" stretch="0,0">
<item>
<widget class="QWidget" name="comic1Image" native="true">
<layout class="QVBoxLayout" name="verticalLayout_3"/>
</widget>
</item>
<item>
<widget class="QPushButton" name="comic1Delete">
<property name="toolTip">
<string>Delete Comic 1</string>
</property>
<property name="text">
<string>Delete</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QFrame" name="comic2Data">
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QWidget" name="comic2Image" native="true">
<layout class="QVBoxLayout" name="verticalLayout_4"/>
</widget>
</item>
<item>
<widget class="QPushButton" name="comic2Delete">
<property name="toolTip">
<string>Delete Comic 2</string>
</property>
<property name="text">
<string>Delete</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

Some files were not shown because too many files have changed in this diff Show More