Repository: MangaManagerORG/Manga-Manager
Branch: main
Commit: df2a88b71b92
Files: 146
Total size: 25.1 MB
Directory structure:
gitextract_z3pdl1we/
├── .github/
│ ├── FUNDING.yml
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug_report.md
│ │ └── feature_request.md
│ └── workflows/
│ └── Run_Tests.yml
├── .gitignore
├── BUILD.ps1
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── DEVELOPMENT.MD
├── Dockerfile
├── LICENSE
├── MangaManager/
│ ├── Extensions/
│ │ ├── CoverDownloader/
│ │ │ ├── CoverDownloader.py
│ │ │ └── __init__.py
│ │ ├── IExtensionApp.py
│ │ ├── Template.py
│ │ ├── WebpConverter/
│ │ │ ├── WebpConverter.py
│ │ │ └── __init__.py
│ │ └── __init__.py
│ ├── ExternalSources/
│ │ ├── CoverSources/
│ │ │ ├── MangaDex/
│ │ │ │ ├── MangaDex.py
│ │ │ │ └── __init__.py
│ │ │ └── __init__.py
│ │ ├── MetadataSources/
│ │ │ ├── MetadataSourceFactory.py
│ │ │ ├── Providers/
│ │ │ │ ├── AniList.py
│ │ │ │ ├── ComicVine.py
│ │ │ │ ├── MangaUpdates.py
│ │ │ │ └── __init__.py
│ │ │ └── __init__.py
│ │ └── __init__.py
│ ├── common/
│ │ ├── __init__.py
│ │ └── models/
│ │ ├── AgeRating.py
│ │ ├── ComicInfo.py
│ │ ├── ComicInfo.xds
│ │ ├── ComicInfoTag.py
│ │ ├── ComicPageType.py
│ │ ├── Manga.py
│ │ ├── YesNo.py
│ │ └── __init__.py
│ ├── logging_setup.py
│ ├── main.py
│ ├── pyinstaller_hooks/
│ │ └── hook-tkinterdnd2.py
│ ├── res/
│ │ └── languages.json
│ ├── src/
│ │ ├── Common/
│ │ │ ├── LoadedComicInfo/
│ │ │ │ ├── ArchiveFile.py
│ │ │ │ ├── CoverActions.py
│ │ │ │ ├── ILoadedComicInfo.py
│ │ │ │ ├── LoadedComicInfo.py
│ │ │ │ ├── LoadedFileCoverData.py
│ │ │ │ ├── LoadedFileMetadata.py
│ │ │ │ └── __init__.py
│ │ │ ├── ResourceLoader.py
│ │ │ ├── __init__.py
│ │ │ ├── errors.py
│ │ │ ├── naturalsorter.py
│ │ │ ├── parser.py
│ │ │ ├── progressbar.py
│ │ │ ├── terminalcolors.py
│ │ │ └── utils.py
│ │ ├── DynamicLibController/
│ │ │ ├── __init__.py
│ │ │ ├── extension_manager.py
│ │ │ └── models/
│ │ │ ├── CoverSourceInterface.py
│ │ │ ├── ExtensionsInterface.py
│ │ │ ├── IMetadataSource.py
│ │ │ └── __init__.py
│ │ ├── MetadataManager/
│ │ │ ├── CoverManager/
│ │ │ │ ├── CoverManager.py
│ │ │ │ └── __init__.py
│ │ │ ├── GUI/
│ │ │ │ ├── ControlManager.py
│ │ │ │ ├── ExceptionWindow.py
│ │ │ │ ├── FileChooserWindow.py
│ │ │ │ ├── MessageBox.py
│ │ │ │ ├── OneTimeMessageBox.py
│ │ │ │ ├── __init__.py
│ │ │ │ ├── scrolledframe.py
│ │ │ │ ├── utils.py
│ │ │ │ ├── widgets/
│ │ │ │ │ ├── AutocompleteComboboxWidget.py
│ │ │ │ │ ├── ButtonWidget.py
│ │ │ │ │ ├── CanvasCoverWidget.py
│ │ │ │ │ ├── ComboBoxWidget.py
│ │ │ │ │ ├── FileMultiSelectWidget.py
│ │ │ │ │ ├── FormBundleWidget.py
│ │ │ │ │ ├── HyperlinkLabelWidget.py
│ │ │ │ │ ├── LongTextWidget.py
│ │ │ │ │ ├── MMWidget.py
│ │ │ │ │ ├── MessageBoxWidget.py
│ │ │ │ │ ├── OptionMenuWidget.py
│ │ │ │ │ ├── ProgressBarWidget.py
│ │ │ │ │ ├── ScrolledFrameWidget.py
│ │ │ │ │ ├── WidgetManager.py
│ │ │ │ │ └── __init__.py
│ │ │ │ └── windows/
│ │ │ │ ├── AboutWindow.py
│ │ │ │ ├── DragAndDrop.py
│ │ │ │ ├── LoadingWindow.py
│ │ │ │ ├── MainWindow.py
│ │ │ │ ├── SettingsWindow.py
│ │ │ │ └── __init__.py
│ │ │ ├── MetadataManagerCLI.py
│ │ │ ├── MetadataManagerGUI.py
│ │ │ ├── MetadataManagerLib.py
│ │ │ └── __init__.py
│ │ ├── Settings/
│ │ │ ├── SettingControl.py
│ │ │ ├── SettingControlType.py
│ │ │ ├── SettingSection.py
│ │ │ ├── Settings.py
│ │ │ ├── SettingsDefault.py
│ │ │ └── __init__.py
│ │ ├── __init__.py
│ │ └── __version__.py
│ └── tests/
│ ├── Common/
│ │ ├── __init__.py
│ │ ├── test_ComicInfo.py
│ │ └── test_utils.py
│ ├── ExtensionsTests/
│ │ ├── __init__.py
│ │ └── test_WebpConverter.py
│ ├── ExternalMetadataTests/
│ │ ├── __init__.py
│ │ └── test_AniList.py
│ ├── LoadedComicInfo/
│ │ ├── __init__.py
│ │ ├── test_Covers.py
│ │ ├── test_LoadedCInfo.py
│ │ ├── test_LoadedCInfo_backup.py
│ │ └── test_moveto.py
│ ├── MetadataManagerTests/
│ │ ├── GUI/
│ │ │ ├── __init__.py
│ │ │ ├── test_MetadataEditorGUI.py
│ │ │ ├── test_dinamic_layouts.py
│ │ │ └── test_fetch_metadata.py
│ │ ├── __init__.py
│ │ └── test_MetadataEditorCore.py
│ ├── Settings/
│ │ ├── __init__.py
│ │ └── test_Settings.py
│ ├── __init__.py
│ ├── common.py
│ ├── data/
│ │ ├── !00_SAMPLE_FILE.CBZ
│ │ └── test.py
│ └── test_comicinfo.py
├── README.md
├── docker-compose.yml
├── docker-root/
│ ├── config/
│ │ ├── .config/
│ │ │ └── xfce4/
│ │ │ ├── panel/
│ │ │ │ └── launcher-7/
│ │ │ │ └── MM Launcher.desktop
│ │ │ └── xfconf/
│ │ │ └── xfce-perchannel-xml/
│ │ │ ├── xfce4-desktop.xml
│ │ │ ├── xfce4-panel.xml
│ │ │ └── xsettings.xml
│ │ ├── Desktop/
│ │ │ ├── MangaManager_23_02_02_Beta_linux_01
│ │ │ ├── covers-folder-link.desktop
│ │ │ ├── manga-folder-link.desktop
│ │ │ └── manga-manager-link.desktop
│ │ └── custom-cont-init.d/
│ │ └── prepare-app-permissions.sh
│ └── defaults/
│ ├── autostart
│ └── startwm.sh
├── requirements.txt
└── sonar-project.properties
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/FUNDING.yml
================================================
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: thepromidius
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.md
================================================
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.
================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.md
================================================
---
name: Feature request
about: Suggest an idea for this project
title: "[Feature Request]"
labels: Feature Request
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
================================================
FILE: .github/workflows/Run_Tests.yml
================================================
# This workflow file will install Python dependencies,
# create a desktop, and test the application's GUI on multiple versions of Python
name: Python tests & Build
on:
- push
- pull_request
env:
$$_ENV_DEVELOPMENT_MM_$$: true
IMAGE_NAME: "thepromidius/manga-manager"
jobs:
test_linux:
env:
DISPLAY: ":99.0"
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [ '3.11' ]
name: Python ${{ matrix.python-version }} - Linux
steps:
-
uses: actions/checkout@v3
-
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
-
run: sudo apt install xvfb
-
run: pip install -r requirements.txt
-
name: Start xvfb
run: |
Xvfb :99 -screen 0 1920x1080x24 &disown
-
name: Run the tests
run: |
cd MangaManager
python -m unittest discover -s tests -t .
# test_windows:
# env:
# DISPLAY: ":99.0"
# runs-on: windows-latest
# if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/test' }}
# strategy:
# matrix:
# python-version: [ '3.11' ]
# name: Python ${{ matrix.python-version }} - Windows
# steps:
# -
# uses: actions/checkout@v3
# -
# uses: actions/setup-python@v4
# with:
# python-version: ${{ matrix.python-version }}
# cache: 'pip'
# -
# run: pip install -r requirements.txt
# -
# name: Run the tests
# run: |
# cd MangaManager
# python -m unittest discover -s tests -t .
sonarcloud:
name: SonarCloud
runs-on: ubuntu-latest
steps:
-
uses: actions/checkout@v3
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
parse_versions:
name: Parse Versions Env
runs-on: ubuntu-latest
outputs:
latest_commit: ${{ steps.latest_commit.outputs.latest_commit }}
release_commit: ${{ steps.release_commit.outputs.release_commit }}
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set latest release commit hash (latest)
id: release_commit
run: |
release_url=$(curl -s https://api.github.com/repos/${{ github.repository }}/releases/latest)
tag_name=$(echo $release_url | jq -r '.tag_name')
tag_url=$(curl -s https://api.github.com/repos/${{ github.repository }}/git/ref/tags/$tag_name)
release_commit=$(echo $tag_url | jq -r '.object.sha')
short_release_commit=$(git rev-parse --short $release_commit)
echo "release_commit=$short_release_commit" >> $GITHUB_OUTPUT
- name: Set latest commit hash (develop)
id: latest_commit
run: |
latest_commit=$(git rev-parse --short HEAD)
echo "latest_commit=$latest_commit" >> $GITHUB_OUTPUT
docker_test:
name: Test and Build - Test Version
needs: [test_linux, sonarcloud, parse_versions]
runs-on: ubuntu-latest
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/test' }}
steps:
-
name: Checkout
uses: actions/checkout@v3
-
name: Replace "nightly" with commit hash
run: |
file_contents=$(head -n 1 MangaManager/src/__version__.py)
new_contents="${file_contents/nightly/nightly:${{ needs.parse_versions.outputs.latest_commit }}}"
echo "$new_contents" > MangaManager/src/__version__.py
-
name: Set up QEMU
uses: docker/setup-qemu-action@v2
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
-
name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Build and push
uses: docker/build-push-action@v4
with:
context: .
push: true
tags: ${{ env.IMAGE_NAME }}:test
docker_nightly:
name: Test and Build - Nightly Version
needs: [test_linux, sonarcloud, parse_versions ]
runs-on: ubuntu-latest
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }}
steps:
-
name: Checkout
uses: actions/checkout@v3
-
name: Replace "nightly" with commit hash
run: |
file_contents=$(head -n 1 MangaManager/src/__version__.py)
new_contents="${file_contents/nightly/nightly:${{ needs.parse_versions.outputs.latest_commit }}}"
echo "$new_contents" > MangaManager/src/__version__.py
-
name: Set up QEMU
uses: docker/setup-qemu-action@v2
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
-
name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Build and push
uses: docker/build-push-action@v4
with:
context: .
push: true
tags: ${{ env.IMAGE_NAME }}:nightly
docker_stable:
name: Stable Build
needs: [test_linux, sonarcloud ]
runs-on: ubuntu-latest
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
steps:
-
name: Set up QEMU
uses: docker/setup-qemu-action@v2
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
-
name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Build and push
uses: docker/build-push-action@v3
with:
push: true
tags: ${{ env.IMAGE_NAME }}:latest
================================================
FILE: .gitignore
================================================
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# AWS User-specific
.idea/**/aws.xml
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# SonarLint plugin
.idea/sonarlint
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
## Application settings
settings.ini
/.coverage
/build/**/*
/dist/**/*
/config.ini
/**/*.log*
*.pdf
/instructions_benchmarks.txt
/.idea/sonarlint/
/MangaManager/build
/MangaManager/dist
# From Tests
/MangaManager/*.cbz
/.idea
/PYTHON_MANGA_MANAGER_WORKFLOW.vsdx
/.idea/sonarlint/
================================================
FILE: BUILD.ps1
================================================
##$repoName = "Manga-Manager"
##$ownerName = "MangaManagerOrg"
### Get the latest release
##$latestRelease = Invoke-RestMethod -Uri "https://api.github.com/repos/$ownerName/$repoName/releases/latest"
### Get the branch or tag name of the commit where the latest release is tied to
##$latestReleaseBranchOrTagName = $latestRelease.target_commitish
### Get the short hash of the commit where the latest release is tied to
##$latestReleaseCommitHash = git rev-parse --short $latestReleaseBranchOrTagName
### Get the short hash of the latest commit in the develop branch
##$latestDevelopHash = git rev-parse --short develop
##
##$content = Get-Content .\MangaManager\src\__version__.py
##if ($content -match '(?<=__version__ = ")[^:"]+') {
## $newContent = $content -replace '__version__ = ".*"', "__version__ = `"$versionNumber:nightly--$latestReleaseCommitHash->$latestDevelopHash`""
## $newContent | Set-Content .\MangaManager\src\__version__.py
##}
##Write-Output $newContent
#$repoName = "Manga-Manager"
#$ownerName = "MangaManagerOrg"
## Get the latest release
#$latestRelease = Invoke-RestMethod -Uri "https://api.github.com/repos/$ownerName/$repoName/releases/latest"
## Get the branch or tag name of the commit where the latest release is tied to
#$latestReleaseBranchOrTagName = $latestRelease.target_commitish
## Get the short hash of the commit where the latest release is tied to
#$latestReleaseCommitHash = git rev-parse --short $latestReleaseBranchOrTagName
## Get the short hash of the latest commit in the develop branch
#$latestDevelopHash = git rev-parse --short develop
#
#$content = Get-Content .\MangaManager\src\__version__.py
#$versionRegex = '(?<=__version__ = ")[^:"]+'
#if ($content -match $versionRegex) {
# $versionNumber = $matches[0]
# $newContent = $content -replace '__version__ = ".*"', "__version__ = `"$versionNumber:nightly--$latestReleaseCommitHash->$latestDevelopHash`""
# $newContent | Set-Content .\MangaManager\src\__version__.py
#}
#Write-Output $newContent
$repoName = "Manga-Manager"
$ownerName = "MangaManagerOrg"
# Get the latest release
$latestRelease = Invoke-RestMethod -Uri "https://api.github.com/repos/$ownerName/$repoName/releases/latest"
# Get the branch or tag name of the commit where the latest release is tied to
$latestReleaseBranchOrTagName = $latestRelease.target_commitish
# Get the short hash of the commit where the latest release is tied to
$latestReleaseCommitHash = git rev-parse --short $latestReleaseBranchOrTagName
# Get the short hash of the latest commit in the develop branch
$latestDevelopHash = git rev-parse --short develop
$content = Get-Content .\MangaManager\src\__version__.py
$versionFile = ".\MangaManager\src\__version__.py"
# Read the current contents of the version file
$content = Get-Content $versionFile
# Update the commit hashes in the version file
$content | ForEach-Object {
if ($_ -match "^__version__ = '.*:stable$'") {
$_ -replace "(?<=[^-])-?[0-9a-f]{7,}?(?=-|->)", $latestReleaseCommitHash
} elseif ($_ -match "^__version__ = '.*:nightly$'") {
$_ -replace "(?<=[^-])-?[0-9a-f]{7,}?(?=-|->)", $latestDevelopHash
} else {
$_
}
} | Set-Content $versionFile
Write-Output $content
================================================
FILE: CHANGELOG.md
================================================
## 1.0.0-beta.1
### Features
* Select multiple files and preview the covers of each file.
* Select a folder and open files recursively
* Choose the files you want to change metadata (without needing to open again) Just click and edit!
* Bulk edit metadata for all files at once
* Changes are kept in one sesion. You can edit a single file, edit a different one and edit again without needing to save/write the files each time.
* Apply all changes (save to file) all at once when you are done editing.
* Edit cover or backcover from the metadata view itself. Append, replace, or delete.
* Cover manager to batch edit covers (the old cover manager but improved significantly)
* Online Metadata scraping
* Webp converter
* Errors and warnings log inside the UI itself!
## 0.4.6
### Features
* Added settings button in main menu
* Added .bmp support to webp converter (previously it skipped it)
### Fixed
* A bug in docker that wouldn't select files
* Handled exception when no cover is selected and process is clicked
* Reencode to UTF-8 if file encoding is wrong reading ComicInfo.xml
* Keep original file if recompressed one is bigger. Closes #106
* Skip image if recompress fails. Closes #115
================================================
FILE: CODE_OF_CONDUCT.md
================================================
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
thepromidiusyt@gmail.com.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.
================================================
FILE: CONTRIBUTING.md
================================================
# Contributing guidelines
We welcome any kind of contribution to our software, from simple comment or question to a full fledged [pull request](https://help.github.com/articles/about-pull-requests/). Please read and follow our [Code of Conduct](CODE_OF_CONDUCT.md).
A contribution can be one of the following cases:
1. you have a question;
2. you think you may have found a bug (including unexpected behavior);
3. you want to make some kind of change to the code base (e.g. to fix a bug, to add a new feature, to update documentation).
The sections below outline the steps in each case.
## You have a question
1. use the search functionality [here](https://github.com/MangaManagerORG/Manga-Manager/issues) to see if someone already filed the same issue;
2. if your issue search did not yield any relevant results, make a new issue;
3. apply the "Question" label; apply other labels when relevant.
## You think you may have found a bug
1. use the search functionality [here](https://github.com/MangaManagerORG/Manga-Manager/issues) to see if someone already filed the same issue;
2. if your issue search did not yield any relevant results, make a new issue, making sure to provide enough information to the rest of the community to understand the cause and context of the problem. Depending on the issue, you may want to include:
- the [SHA hashcode](https://help.github.com/articles/autolinked-references-and-urls/#commit-shas) of the commit that is causing your problem;
- some identifying information (name and version number) for dependencies you're using;
- information about the operating system;
3. apply relevant labels to the newly created issue.
## You want to make some kind of change to the code base
1. (**important**) announce your plan to the rest of the community _before you start working_. This announcement should be in the form of a (new) issue;
2. (**important**) wait until some kind of consensus is reached about your idea being a good idea;
3. if needed, fork the repository to your own GitHub profile and create your own feature branch off of the latest main commit. While working on your feature branch, make sure to stay up to date with the main branch by pulling in changes, possibly from the 'upstream' repository (follow the instructions [here](https://help.github.com/articles/configuring-a-remote-for-a-fork/) and [here](https://help.github.com/articles/syncing-a-fork/));
4. Install dependencies with `pip3 install -r requirements.txt`;
5. make sure the existing tests still work by running ``pytest``. If project tests fails use ``pytest --keep-baked-projects`` to keep generated project in /tmp/pytest-* and investigate;
6. add your own tests (if necessary);
7. update or expand the documentation;
8. push your feature branch to (your fork of) the Python Template repository on GitHub;
9. create the pull request, e.g. following the instructions [here](https://help.github.com/articles/creating-a-pull-request/).
In case you feel like you've made a valuable contribution, but you don't know how to write or run tests for it, or how to generate the documentation: don't let this discourage you from making the pull request; we can help you! Just go ahead and submit the pull request, but keep in mind that you might be asked to append additional commits to your pull request.
================================================
FILE: DEVELOPMENT.MD
================================================
## Versioning and building
When a build is to be made, copy the short hash of last commit and update it in `src/__version__.py`. After that make commit and push the version bump.
After that you can now create the build with the command below
## How to build:
`python -m PyInstaller .\MangaManager.spec --clean`
## Errors building with pyinstaller
If you can not run the build make sure all requirements are installed.
Pyinstaller does not use the virtual env requirements. So make sure the base python has them installed
Some of the requirements that gave me issues are:
- `chardet`
================================================
FILE: Dockerfile
================================================
FROM ghcr.io/linuxserver/baseimage-rdesktop-web:jammy
ENV DEBIAN_FRONTEND=noninteractive
ENV UID=1000
ENV GID=1000
ENV NO_UPDATE_NOTIFIER=true
ENV GUIAUTOSTART=true
WORKDIR /tmp
COPY requirements.txt /tmp/
# Copy App
COPY --chown=$UID:$GID [ "/MangaManager", "/app" ]
# Setup Dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends \
# Desktop Environment
mousepad \
xfce4-terminal \
xfce4 \
xubuntu-default-settings \
xubuntu-icon-theme \
unrar\
# Python \
idle-python3.11 \
python3-tk \
python3-pip && \
# Manga Manager Dependencies
python3.11 -m pip install -r requirements.txt && \
# Cleanup
apt-get autoclean && \
rm -rf \
/var/lib/apt/lists/* \
/var/tmp/* \
/tmp/* && \
# Try making python3 callable by just running "python" on Ubuntu :) (optional)
ln -s /usr/bin/python3.11 /usr/bin/python || true && \
chmod -R +x /app
# Setup environment & branding/customization
COPY /docker-root /
RUN \
chmod -R +x /config/Desktop && \
chmod -R +x /config/.config/xfce4/panel
WORKDIR /app
EXPOSE 3000
VOLUME /manga
VOLUME /covers
================================================
FILE: LICENSE
================================================
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc.
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
Copyright (C)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
Copyright (C)
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
.
================================================
FILE: MangaManager/Extensions/CoverDownloader/CoverDownloader.py
================================================
from tkinter import Label, Frame, Entry
def get_cover_from_source_dummy() -> list:
...
class CoverDownloader():#IExtensionApp):
name = "Cover Downloader"
def serve_gui(self):
if not self.master:
return Exception("Tried to initialize ui with no master window")
frame = Frame(self.master)
frame.pack()
Label(frame, text="Manga identifier").pack()
Entry(frame).pack()
# Combobox(frame, state="readonly",values=sources_factory["CoverSources"]).pack()
covers = get_cover_from_source_dummy()
================================================
FILE: MangaManager/Extensions/CoverDownloader/__init__.py
================================================
================================================
FILE: MangaManager/Extensions/IExtensionApp.py
================================================
import abc
import tkinter
from typing import final
class IExtensionApp(tkinter.Toplevel, metaclass=abc.ABCMeta):
"""
"""
name = None
embedded_ui = False
master_frame = None
master = None
_super = None
@final
def __init__(self, master, super_=None, **kwargs):
"""
Initializes the toplevel window but hides the window.
"""
if self.name is None: # Check if the "name" attribute has been set
raise ValueError(f"Error initializing the {self.__class__.__name__} Extension. The 'name' attribute must be set in the ExtensionApp class.")
# if self.embedded_ui:
super().__init__(master=master,**kwargs)
self.title(self.__class__.name)
self.master = self.master_frame = self
if super_ is not None:
self._super = super_
# else:
# frame = tkinter.Frame()
# self.master_frame = frame
self.serve_gui()
# self.withdraw() # Hide the window
def _initialize(self):
...
# """
# Sets the new master window and displays the Toplevel window
# :param master:
# :return:
# """
# self.master = master
# self.deiconify()
# self.serve_gui()
@abc.abstractmethod
def serve_gui(self):
...
================================================
FILE: MangaManager/Extensions/Template.py
================================================
import logging
from Extensions.IExtensionApp import IExtensionApp
logger = logging.getLogger()
class ExtensionTemplate(IExtensionApp):
name = "Webp Converter"
def serve_gui(self):
if not self.master:
return Exception("Tried to initialize ui with no master window")
================================================
FILE: MangaManager/Extensions/WebpConverter/WebpConverter.py
================================================
from __future__ import annotations
import glob
import logging
import os
import pathlib
import threading
import tkinter
import tkinter.ttk as ttk
from tkinter import filedialog
from Extensions.IExtensionApp import IExtensionApp
from src.Common.LoadedComicInfo.LoadedComicInfo import LoadedComicInfo
from src.Common.utils import ShowPathTreeAsDict
from src.MetadataManager.GUI.widgets import ScrolledFrameWidget, ProgressBarWidget
from src.Settings.Settings import Settings
logger = logging.getLogger()
def start_processing(_selected_files,_progress_bar):
processing_thread = threading.Thread(target=_run_process, args=(_selected_files,_progress_bar))
processing_thread.start()
def _run_process(list_of_files,progress_bar:ProgressBarWidget):
for file in list_of_files:
logger.info(f"[Extension][WebpConvert] Processing file",
extra={"processed_filename":file})
try:
# time.sleep(20)
LoadedComicInfo(file, load_default_metadata=False).convert_to_webp()
progress_bar.increase_processed()
except Exception:
logger.exception(f"Failed to convert to webp '{file}'")
progress_bar.increase_failed()
progress_bar.running = False
class WebpConverter(IExtensionApp):
name = "Webp Converter"
embedded_ui = True
base_path: str = ""
glob: str = "**/*.cbz"
_selected_files: list[str | pathlib.Path] = []
treeview_frame: ScrolledFrameWidget = None
nodes: dict
_progress_bar: ProgressBarWidget
def pb_update(self):
if self._progress_bar.running:
self._progress_bar._update()
self.after(20, self.pb_update)
@property
def selected_files(self):
self._set_input()
return self._selected_files
def process(self):
if not self._selected_files:
return
self._progress_bar.start(len(self._selected_files))
self._progress_bar.running = True
self.pb_update()
self.after(0, self.pb_update)
start_processing(self._selected_files,self._progress_bar)
def select_base(self):
self.base_path = filedialog.askdirectory(parent=self) # select directory
self.selected_base_path.set(str(self.base_path))
def _on_file(self, parent, file):
self.tree.insert(self.nodes.get(str(parent.get("current"))), 'end', text=file, open=True)
def _on_folder(self, parent_dic, folder):
parent_path = str(pathlib.Path(parent_dic.get("current")))
node = self.tree.insert(self.nodes[parent_path], 'end', text=folder, open=True)
self.nodes[str(pathlib.Path(parent_path, folder))] = node
def _clear(self):
self.tree.delete(*self.tree.get_children())
self.nodes = dict()
def _set_input(self):
self.glob = self.path_glob.get() or "*.cbz"
os.chdir(self.base_path)
logger.debug(f"Looking up files for glob: '{self.glob}'")
self._selected_files = [
pathlib.Path(self.base_path, globbed_file) for globbed_file in glob.glob(self.glob, recursive=True)]
logger.debug(f"Found {len(self._selected_files)} files")
def preview(self):
if not self.base_path:
return
self._clear()
self._set_input()
abspath = os.path.abspath(self.base_path)
node = self.tree.insert("", 'end', abspath, text=self.base_path, open=True)
self.nodes[abspath] = node
treeview = ShowPathTreeAsDict
treeview.on_file = self._on_file
treeview.on_subfolder = self._on_folder
self.treeview_files = treeview(base_path=self.base_path, paths=self.selected_files).get()
def serve_gui(self):
self.geometry("300x400")
frame = tkinter.Frame(self.master)
frame.pack(fill="both", expand=True, padx=20, pady=20)
default_base_setting = Settings().get('Webp Converter', 'default_base_path')
self.selected_base_path = tkinter.StringVar(None, value=default_base_setting)
self.base_path = default_base_setting
tkinter.Button(frame, text="Select Base Directory", command=self.select_base, width=50).pack()
self.base_path_entry = tkinter.Entry(frame, state="readonly", textvariable=self.selected_base_path, width=50)
self.base_path_entry.pack()
tkinter.Label(frame, text="Glob to apply:", width=50).pack(side="top")
self.path_glob = tkinter.Entry(frame, width=50)
self.path_glob.pack()
#
tkinter.Button(frame, text="Preview selected files", pady=6, command=self.preview, width=50).pack(side="top", pady=10)
tkinter.Button(frame, text="Process", command=self.process, pady=6, width=50).pack(side="top", pady=10)
pb_frame = tkinter.Frame(frame, pady=10, width=60)
pb_frame.pack()
self._progress_bar = ProgressBarWidget(pb_frame)
self.tree = ttk.Treeview(frame)
self.tree.heading('#0', text='Project tree', anchor='n')
self.tree.pack(expand=True, fill="both", side="top")
================================================
FILE: MangaManager/Extensions/WebpConverter/__init__.py
================================================
================================================
FILE: MangaManager/Extensions/__init__.py
================================================
================================================
FILE: MangaManager/ExternalSources/CoverSources/MangaDex/MangaDex.py
================================================
import logging
import os
import urllib
from pathlib import Path
import requests
from src.Common.utils import clean_filename
from src.DynamicLibController.models.CoverSourceInterface import ICoverSource, Cover
from src.Settings import SettingHeading
from src.Settings.Settings import Settings
logger = logging.getLogger()
class MangaDex(ICoverSource):
name = "MangaDex"
@staticmethod
def parse_identifier(identifier) -> str:
...
@classmethod
def get_covers(cls, identifier: str) -> list[Cover]:
"""
Downloads the covers from manga_id from mangadex.
If the cover is already downloaded it won't re-download
:param identifier: The manga identifier only.
"""
manga_id = cls.parse_identifier(identifier)
data = {"manga[]": [manga_id], "includes[]": ["manga"], "limit": 50}
# Request the list of covers in the provided manga
r = requests.get(f"https://api.mangadex.org/cover", params=data)
if r.status_code == 400:
logger.error("MangaDex api returned 400 for ")
raise Exception("status code 400")
r_json = r.json()
cover_attributes = r_json.get("data")[0].get("relationships")[0].get("attributes")
ja_title = list(filter(lambda p: p.get("ja-ro"),
cover_attributes.get("altTitles")))
if ja_title:
ja_title = ja_title[0].get("ja-ro")
normalized_manga_name = (ja_title or cover_attributes.get("title").get("en"))
destination_dirpath = Path(Settings().get(SettingHeading.Main, 'covers_folder_path'), clean_filename(
normalized_manga_name)) # The covers get stored in their own series folder inside the covers directory
total = len(r_json.get("data"))
# Todo: Implement progress bar
for i, cover_data in enumerate(r_json.get("data")):
try:
cover_filename = cover_data.get("attributes").get("fileName")
filename, file_extension = os.path.splitext(cover_filename)
cover_volume = cover_data.get("attributes").get("volume")
cover_loc = cover_data.get("attributes").get("locale")
destination_filename = f"Cover_Vol.{str(cover_volume).zfill(2)}_{cover_loc}{file_extension}"
destination_filepath = Path(destination_dirpath, destination_filename)
if (not destination_filepath.exists() or force_overwrite) and not test_run:
image_url = f"https://mangadex.org/covers/{manga_id}/{cover_filename}"
urllib.request.urlretrieve(image_url, destination_filepath)
logger.debug(f"Downloaded {destination_filename}")
elif test_run:
image_url = f"https://mangadex.org/covers/{manga_id}/{cover_filename}"
print(f"Asserting if valid url: '{image_url}' ")
return check_url_isImage(image_url)
else:
logger.info(f"Skipped 'https://mangadex.org/covers/{manga_id}/{cover_filename}' -> Already exists")
except Exception as e:
logger.error(e)
logger.info(f"Files saved to: '{destination_dirpath}'")
@classmethod
def parse_input(cls, value) -> str:
"""
Accepts a mangadex id or URL
:param value:
:return: mangadex managa id
"""
if "https://mangadex.org/title/" in value:
value = value.replace("https://mangadex.org/title/", "").split("/")[0]
return value
================================================
FILE: MangaManager/ExternalSources/CoverSources/MangaDex/__init__.py
================================================
================================================
FILE: MangaManager/ExternalSources/CoverSources/__init__.py
================================================
================================================
FILE: MangaManager/ExternalSources/MetadataSources/MetadataSourceFactory.py
================================================
import logging
# Import all the scrapers here to ensure globals() has the key in it for dynamic instantiation
from .Providers.AniList import AniList
from .Providers.ComicVine import ComicVine
from .Providers.MangaUpdates import MangaUpdates
logger = logging.getLogger()
# Avoid IDE cleaning imports
MangaUpdates.__dont_clean = ""
AniList.__dont_clean = ""
ComicVine.__dont_clean = ""
# NOTE: This is a stopgap solution until dynamic loader is implemented
class ScraperFactory:
""" Singleton Factory of metadata providers. Pass in the name defined in the provider .name() and an instance will be returned. """
__instance = None
providers = {}
def __new__(cls):
if ScraperFactory.__instance is None:
ScraperFactory.__instance = object.__new__(cls)
return ScraperFactory.__instance
def __init__(self):
pass
def get_scraper(self, setting_name):
if setting_name not in self.providers:
try:
cls = globals()[setting_name]
except KeyError:
logger.exception(f"Failed to load setting name '{setting_name}'")
return
self.providers[setting_name] = cls()
return self.providers[setting_name]
================================================
FILE: MangaManager/ExternalSources/MetadataSources/Providers/AniList.py
================================================
from __future__ import annotations
import logging
import re
import requests
from enum import StrEnum
from typing import Optional
from common import get_invalid_person_tag
from common.models import ComicInfo
from src.Common.errors import MangaNotFoundError
from src.DynamicLibController.models.IMetadataSource import IMetadataSource
from src.Settings.SettingControl import SettingControl
from src.Settings.SettingControlType import SettingControlType
from src.Settings.SettingSection import SettingSection
from src.Settings.Settings import Settings
pattern = r"anilist.com/manga/(\d+)"
class AniListPerson(StrEnum):
OriginalStory = "original_story", # Original Story
CharacterDesign = "character_design", # Character Design
Story = "story", # Story
Art = "art", # Art
Assistant = "assistant", # Assistant
class AniListSetting(StrEnum):
SeriesTitleLanguage = "series_title_language",
class AniList(IMetadataSource):
name = "AniList"
_log = logging.getLogger()
# Map the Role from API to the ComicInfo tags to write
person_mapper = {}
_HOW_METADATA_MAPS_TOOLTIP = "How metadata field will map to ComicInfo fields"
romaji_as_series = True
def init_settings(self):
self.settings = [
SettingSection(self.name, self.name, [
SettingControl(key=AniListSetting.SeriesTitleLanguage, name="Prefer Romaji Series Title Language",
control_type=SettingControlType.Bool, value=True,
tooltip="How metadata field will map to Series and LocalizedSeries fields\n"
"true: Romaji->Series, English->LocalizedSeries\n"
"false: English->Series, Romaji->LocalizedSeries\n"
"Always Romaji->Series when no English"),
SettingControl(key=AniListPerson.OriginalStory, name="Original Story",
control_type=SettingControlType.Text, value="Writer",
tooltip=self._HOW_METADATA_MAPS_TOOLTIP,
validate=self.is_valid_person_tag, format_value=self.trim),
SettingControl(key=AniListPerson.CharacterDesign, name="Character Design",
control_type=SettingControlType.Text, value="Penciller",
tooltip=self._HOW_METADATA_MAPS_TOOLTIP,
validate=self.is_valid_person_tag, format_value=self.trim),
SettingControl(key=AniListPerson.Story, name="Story",
control_type=SettingControlType.Text, value="Writer",
tooltip=self._HOW_METADATA_MAPS_TOOLTIP,
validate=self.is_valid_person_tag, format_value=self.trim),
SettingControl(key=AniListPerson.Art, name="Art",
control_type=SettingControlType.Text, value="Penciller, Inker, CoverArtist",
tooltip=self._HOW_METADATA_MAPS_TOOLTIP,
validate=self.is_valid_person_tag, format_value=self.trim),
SettingControl(key=AniListPerson.Assistant, name="Assistant",
control_type=SettingControlType.Text, value="",
tooltip=self._HOW_METADATA_MAPS_TOOLTIP,
validate=self.is_valid_person_tag, format_value=self.trim),
])
]
super().init_settings()
def save_settings(self):
self.romaji_as_series = Settings().get(self.name, AniListSetting.SeriesTitleLanguage)
self.person_mapper["Original Story"] = Settings().get(self.name, AniListPerson.OriginalStory).split(',')
self.person_mapper["Original Creator"] = Settings().get(self.name, AniListPerson.OriginalStory).split(',')
self.person_mapper["Character Design"] = Settings().get(self.name, AniListPerson.CharacterDesign).split(',')
self.person_mapper["Story"] = Settings().get(self.name, AniListPerson.Story).split(',')
self.person_mapper["Art"] = Settings().get(self.name, AniListPerson.Art).split(',')
self.person_mapper["Story & Art"] = Settings().get(self.name, AniListPerson.Story).split(',') + Settings().get(
self.name, AniListPerson.Art).split(',')
self.person_mapper["Assistant"] = Settings().get(self.name, AniListPerson.Assistant).split(',')
@staticmethod
def is_valid_person_tag(key, value):
invalid_people = get_invalid_person_tag(value)
if len(invalid_people) == 0:
return ""
return ", ".join(invalid_people) + " are not a valid tags"
@staticmethod
def get_manga_id_from_url(url):
pattern = r"https:\/\/anilist\.co\/manga\/(\d+)"
match = re.search(pattern, url)
if match:
return match.group(1)
return None
@classmethod
def _get_id_from_series(cls, cinfo: ComicInfo) -> Optional[int]:
manga_id = cls.get_manga_id_from_url(cinfo.web)
if manga_id is not None:
return manga_id
try:
content = cls._search_for_manga_title_by_manga_title(cinfo.series, "MANGA", {})
except MangaNotFoundError:
content = cls.search_for_manga_title_by_manga_title_with_adult(cinfo.series, "MANGA", {})
if content is None:
return None
return content.get("id")
@classmethod
def get_cinfo(cls, comic_info_from_ui: ComicInfo) -> ComicInfo | None:
comicinfo = ComicInfo()
serie_id = cls._get_id_from_series(comic_info_from_ui)
if serie_id is None:
return None
data = cls._search_details_by_series_id(serie_id, "MANGA", {})
startdate = data.get("startDate")
comicinfo.day = startdate.get("day")
comicinfo.month = startdate.get("month")
comicinfo.year = startdate.get("year")
comicinfo.genre = ", ".join(data.get("genres")).strip()
comicinfo.web = data.get("siteUrl").strip()
if data.get("volumes"):
comicinfo.count = data.get("volumes")
# Title (Series & LocalizedSeries)
title = data.get("title")
cls._log.info("[AniList] Fetch Data found title " + str(title) + " for " + comic_info_from_ui.series)
title_english = (data.get("title").get("english") or "").strip()
title_romaji = (data.get("title").get("romaji") or "").strip()
if cls.romaji_as_series:
comicinfo.series = title_romaji
if title_romaji != title_english:
comicinfo.localized_series = title_english
else:
comicinfo.series = title_english
if title_romaji != title_english:
comicinfo.localized_series = title_romaji
# Summary
comicinfo.summary = IMetadataSource.clean_description(data.get("description"), remove_source=True)
# People
cls.update_people_from_mapping(data["staff"]["edges"], cls.person_mapper, comicinfo,
lambda item: item["node"]["name"]["full"],
lambda item: item["role"])
return comicinfo
@classmethod
def _post(cls, query, variables, logging_info):
try:
response = requests.post('https://graphql.anilist.co', json={'query': query, 'variables': variables})
if response.status_code == 429: # Anilist rate-limit code
raise AniListRateLimit()
except AniListRateLimit:
cls._log.exception("Hitted anilist ratelimit")
return None
except Exception:
cls._log.exception("Unhandled exception making the request to anilist")
return None
cls._log.debug(f'Query: {query}')
cls._log.debug(f'Variables: {variables}')
# self.logger.debug(f'Response JSON: {response.json()}')
try:
return response.json()['data']['Media']
except TypeError:
cls._log.exception("Wrong data format recieved when parsing response json")
return None
@classmethod
def _search_for_manga_title_by_id(cls, manga_id, logging_info):
query = '''
query search_for_manga_title_by_id ($manga_id: Int) {
Media (id: $manga_id, type: MANGA) {
id
title {
romaji
english
native
}
synonyms
}
}
'''
variables = {
'manga_id': manga_id,
}
return cls._post(query, variables, logging_info)
@classmethod
def _search_for_manga_title_by_manga_title(cls, manga_title, format_, logging_info):
query = '''
query search_manga_by_manga_title ($manga_title: String, $format: MediaFormat) {
Media (search: $manga_title, type: MANGA, format: $format, isAdult: false) {
id
title {
romaji
english
native
}
synonyms
}
}
'''
variables = {
'manga_title': manga_title,
'format': format_
}
ret = cls._post(query, variables, logging_info)
if ret is None:
raise MangaNotFoundError("AniList", manga_title)
return ret
@classmethod
def search_for_manga_title_by_manga_title_with_adult(cls, manga_title, format_, logging_info):
query = '''
query search_manga_by_manga_title ($manga_title: String, $format: MediaFormat) {
Media (search: $manga_title, type: MANGA, format: $format) {
id
title {
romaji
english
native
}
synonyms
}
}
'''
variables = {
'manga_title': manga_title,
'format': format_
}
return cls._post(query, variables, logging_info)
@classmethod
def _search_details_by_series_id(cls, series_id, format_, logging_info):
query = '''
query search_details_by_series_id ($series_id: Int, $format: MediaFormat) {
Media (id: $series_id, type: MANGA, format: $format) {
id
status
volumes
siteUrl
title {
romaji
english
native
}
type
genres
synonyms
startDate {
day
month
year
}
coverImage {
extraLarge
}
staff {
edges {
node{
name {
first
last
full
alternative
}
siteUrl
}
role
}
}
description
}
}
'''
variables = {
'series_id': series_id,
'format': format_
}
return cls._post(query, variables, logging_info)
class AniListRateLimit(Exception):
"""
Exception raised when AniList rate-limit is breached.
"""
================================================
FILE: MangaManager/ExternalSources/MetadataSources/Providers/ComicVine.py
================================================
import logging
from abc import ABC
import requests
from common.models import ComicInfo
from src.Common.errors import MangaNotFoundError
from src.DynamicLibController.models.IMetadataSource import IMetadataSource
from src.Settings import SettingSection, SettingControl, SettingControlType, Settings
class ComicVine(IMetadataSource, ABC):
name = 'ComicVine'
_log = logging.getLogger()
def __init__(self):
self.settings = [
SettingSection(self.name, self.name, [
SettingControl('api_key', "API Key", SettingControlType.Text, "",
"API Key to communicate with ComicVine. This is required for the source"),
])
]
super(ComicVine, self).__init__()
self._log = logging.getLogger(f'{self.__module__}.{self.name}')
def save_settings(self):
pass
def get_cinfo(self, comic_info_from_ui: ComicInfo) -> ComicInfo | None:
comicinfo = ComicInfo()
try:
content = self._search_by_title(comic_info_from_ui.series)
except MangaNotFoundError:
content = self._search_by_issue(comic_info_from_ui.series)
if content is None:
return None
# content = content.get("id")
# data = self._search_details_by_series_id(content, "MANGA", {})
#
# startdate = data.get("startDate")
# comicinfo.summary = data.get("description").strip()
# comicinfo.day = startdate.get("day")
# comicinfo.month = startdate.get("month")
# comicinfo.year = startdate.get("year")
# comicinfo.series = data.get("title").get("romaji").strip()
# comicinfo.genre = ", ".join(data.get("genres")).strip()
# comicinfo.web = data.get("siteUrl").strip()
# People
# self.update_people_from_mapping(data["staff"]["edges"], self.person_mapper, comicinfo,
# lambda item: item["node"]["name"]["full"],
# lambda item: item["role"])
return comicinfo
def _search_by_title(self, series_name, publish_year=""):
url = f"{self._build_url_base('series')}&name={series_name}"
try:
response = requests.get(url)
# if response.status_code == 429: # Anilist rate-limit code
# raise AniListRateLimit()
except Exception as e:
self._log.warning('Manga Manager is unfamiliar with this error. Please log an issue for investigation.', e)
return None
self._log.debug(f'Query: {url}')
try:
return response.json()['results']
except TypeError:
return None
pass
def _search_by_issue(self, series_name, issue_number):
pass
def _build_url_base(self, entity):
return f"http://api.comicvine.com/{entity}/?api_key={Settings().get(self.name, 'API Key')}&format=json"
================================================
FILE: MangaManager/ExternalSources/MetadataSources/Providers/MangaUpdates.py
================================================
import logging
from enum import StrEnum
import requests
from common import get_invalid_person_tag
from common.models import ComicInfo
from src.Common.errors import MangaNotFoundError
from src.DynamicLibController.models.IMetadataSource import IMetadataSource
from src.Settings.SettingControl import SettingControl
from src.Settings.SettingControlType import SettingControlType
from src.Settings.SettingSection import SettingSection
from src.Settings.Settings import Settings
class MangaUpdatesPerson(StrEnum):
Author = "author",
Artist = "artist",
class MangaUpdates(IMetadataSource):
name = "MangaUpdates"
_log = logging.getLogger()
person_mapper = {
"Author": [
"Writer"
],
"Artist": [
"Penciller",
"Inker",
"CoverArtist"
]
}
def init_settings(self):
self.settings = [
SettingSection(self.name, self.name, [
SettingControl(MangaUpdatesPerson.Author, "Author", SettingControlType.Text, "Writer",
"How metadata field will map to ComicInfo fields", self.is_valid_person_tag, self.trim),
SettingControl(MangaUpdatesPerson.Artist, "Artist", SettingControlType.Text,
"Penciller, Inker, CoverArtist", "How metadata field will map to ComicInfo fields",
self.is_valid_person_tag, self.trim),
])
]
def save_settings(self):
# Update person_mapper when this is called as it indicates the settings for the provider might have changed
self.person_mapper[MangaUpdatesPerson.Author] = Settings().get(self.name, MangaUpdatesPerson.Author).split(',')
self.person_mapper[MangaUpdatesPerson.Artist] = Settings().get(self.name, MangaUpdatesPerson.Artist).split(',')
@staticmethod
def is_valid_person_tag(key, value):
invalid_people = get_invalid_person_tag(value)
if len(invalid_people) == 0:
return ""
return ", ".join(invalid_people) + " are not a valid tags"
@classmethod
def get_cinfo(cls, comic_info_from_ui) -> ComicInfo | None:
# We need to take what's already in the UI and allow fetching to merge the data in
comicinfo = ComicInfo()
data = cls._get_series_details(comic_info_from_ui.series, {})
# Basic Info
comicinfo.series = data["title"].strip()
comicinfo.summary = IMetadataSource.clean_description(data.get("description"), remove_source=True)
comicinfo.genre = ", ".join([i["genre"] for i in data["genres"]]).strip()
comicinfo.tags = ", ".join([i["category"] for i in data["categories"]])
comicinfo.web = data["url"].strip()
comicinfo.manga = "Yes" if data["type"] == "Manga" else "No"
comicinfo.year = data["year"]
# People Info
cls.update_people_from_mapping(cls, data["authors"], cls.person_mapper, comicinfo,
lambda item: item["name"],
lambda item: item["type"])
comicinfo.publisher = (", ".join([ i["publisher_name"] for i in data["publishers"] ]))
# Extended
comicinfo.community_rating = round(data["bayesian_rating"]/2, 1)
return comicinfo
@classmethod
def _get_series_id(cls, search_params, logging_info):
try:
response = requests.post('https://api.mangaupdates.com/v1/series/search', json=search_params)
except Exception as e:
cls._log.exception(e, extra=logging_info)
cls._log.warning('Manga Manager is unfamiliar with this error. Please log an issue for investigation.',
extra=logging_info)
return None
cls._log.debug(f'Search Params: {search_params}')
# cls.logger.debug(f'Response JSON: {response.json()}')
if len(response.json()['results']) == 0:
raise MangaNotFoundError("MangaUpdates",search_params['search'])
try:
return response.json()['results'][0]['record']['series_id']
except TypeError:
return None
@classmethod
def _get_series_details(cls, manga_title, logging_info):
search_params = {
"search": manga_title,
"page": 1,
"per_page": 1
}
try:
series_details = requests.get('https://api.mangaupdates.com/v1/series/' + str(cls._get_series_id(search_params, {})))
except Exception as e:
cls._log.exception(e, extra=logging_info)
cls._log.warning('Manga Manager is unfamiliar with this error. Please log an issue for investigation.',
extra=logging_info)
return None
return series_details.json()
================================================
FILE: MangaManager/ExternalSources/MetadataSources/Providers/__init__.py
================================================
================================================
FILE: MangaManager/ExternalSources/MetadataSources/__init__.py
================================================
from .MetadataSourceFactory import ScraperFactory
from .Providers.AniList import AniList
from .Providers.MangaUpdates import MangaUpdates
print("MetadataSources module loaded")
================================================
FILE: MangaManager/ExternalSources/__init__.py
================================================
================================================
FILE: MangaManager/common/__init__.py
================================================
from .models import PeopleTags
def get_invalid_person_tag(people: str):
""" Validates that a common separated list or single person is a valid Person tag"""
invalid_people = []
for person in [p.strip() for p in people.split(",") if p != ""]:
if person not in list(PeopleTags):
invalid_people.append(person)
return invalid_people
================================================
FILE: MangaManager/common/models/AgeRating.py
================================================
from enum import Enum
# Keep this ordered in terms of progressing ratings, rather than alphabetical
class AgeRating(str, Enum):
UNKNOWN = 'Unknown'
RATING_PENDING = 'Rating Pending'
EARLY_CHILDHOOD = 'Early Childhood'
EVERYONE = 'Everyone'
G = 'G'
EVERYONE_10 = 'Everyone 10+'
PG = 'PG'
KIDSTO_ADULTS = 'Kids to Adults'
TEEN = 'Teen'
MA_15 = 'MA15+'
MATURE_17 = 'Mature 17+'
M = 'M'
R_18 = 'R18+'
ADULTS_ONLY_18 = 'Adults Only 18+'
X_18 = 'X18+'
@classmethod
def list(cls):
return list(map(lambda c: c.value, cls))
================================================
FILE: MangaManager/common/models/ComicInfo.py
================================================
from io import BytesIO
from xml.etree import ElementTree as ET
comic_info_tag_map = {
"series": "Series",
"localized_series": "LocalizedSeries",
"series_sort": "SeriesSort",
"count": "Count",
"writer": "Writer",
"penciller": "Penciller",
"inker": "Inker",
"colorist": "Colorist",
"letterer": "Letterer",
"cover_artist": "CoverArtist",
"editor": "Editor",
"translator": "Translator",
"publisher": "Publisher",
"imprint": "Imprint",
"characters": "Characters",
"teams": "Teams",
"locations": "Locations",
"main_character_or_team": "MainCharacterOrTeam",
"other": "Other",
"genre": "Genre",
"age_rating": "AgeRating",
"series_group": "SeriesGroup",
"alternate_series": "AlternateSeries",
"story_arc": "StoryArc",
"story_arc_number": "StoryArcNumber",
"alternate_count": "AlternateCount",
"alternate_number": "AlternateNumber",
"title": "Title",
"summary": "Summary",
"review": "Review",
"tags": "Tags",
"web": "Web",
"number": "Number",
"volume": "Volume",
"format": "Format",
"manga": "Manga",
"year": "Year",
"month": "Month",
"day": "Day",
"language_iso": "LanguageISO",
"notes": "Notes",
"community_rating": "CommunityRating",
"black_and_white": "BlackAndWhite",
"page_count": "PageCount",
"scan_information": "ScanInformation",
"gtin": "GTIN"
}
class ComicInfo:
series = ""
localized_series = ""
count = ""
writer = ""
penciller = ""
inker = ""
colorist = ""
letterer = ""
cover_artist = ""
editor = ""
translator = ""
publisher = ""
imprint = ""
characters = ""
teams = ""
locations = ""
main_character_or_team = ""
genre = ""
age_rating = ""
series_sort = ""
series_group = ""
alternate_series = ""
story_arc = ""
story_arc_number = ""
alternate_count = ""
alternate_number = ""
title = ""
summary = ""
review = ""
tags = ""
web = ""
number = ""
volume = ""
format = ""
manga = ""
year = ""
month = ""
day = ""
language_iso = ""
notes = ""
community_rating = ""
black_and_white = ""
page_count = ""
scan_information = ""
other = ""
gtin = ""
def __init__(self):
pass
def set_by_tag_name(self, tag, value):
for key, v in comic_info_tag_map.items():
if tag == v:
if value is None:
value = ""
self.__setattr__(key, value)
def get_by_tag_name(self, name) -> str:
for key, value in comic_info_tag_map.items():
if name == value:
ret = getattr(self, key)
if ret is None:
return ""
return ret
return ""
@classmethod
def from_xml(cls, xml_string):
root = ET.ElementTree(ET.fromstring(xml_string.encode("utf-8"), parser=ET.XMLParser(encoding='utf-8')))
comic_info = cls()
for prop in [a for a in dir(comic_info) if not a.startswith('__') and not callable(getattr(comic_info, a))]:
comic_info.__setattr__(prop, root.findtext(comic_info_tag_map[prop]))
return comic_info
def to_xml(self):
root = ET.Element("ComicInfo")
for key, mapped_key in comic_info_tag_map.items():
value = str(self.get_by_tag_name(mapped_key))
if value:
ET.SubElement(root, mapped_key).text = value
# prevent creation of self-closing tags
for node in root.iter():
if node.text is None:
node.text = ""
f = BytesIO()
et = ET.ElementTree(root)
ET.indent(et)
et.write(f, encoding='utf-8', xml_declaration=True)
ret_xml = f.getvalue()
return str(ret_xml, encoding="utf-8")
# print(f.getvalue()) # your XML file, encoded as UTF-8
# output_xml = ET.tostring(root, encoding="UTF-8", xml_declaration=True, method='xml').decode("utf8")
# return output_xml
"""Returns TRUE if it has changes"""
def has_changes(self, other):
for key in comic_info_tag_map.keys():
if getattr(self, key) != getattr(other, key):
return True
return False
================================================
FILE: MangaManager/common/models/ComicInfo.xds
================================================
================================================
FILE: MangaManager/common/models/ComicInfoTag.py
================================================
================================================
FILE: MangaManager/common/models/ComicPageType.py
================================================
from enum import Enum
class ComicPageType(str, Enum):
FRONT_COVER = 'FrontCover'
INNER_COVER = 'InnerCover'
ROUNDUP = 'Roundup'
STORY = 'Story'
ADVERTISMENT = 'Advertisment'
EDITORIAL = 'Editorial'
LETTERS = 'Letters'
PREVIEW = 'Preview'
BACK_COVER = 'BackCover'
OTHER = 'Other'
DELETED = 'Deleted'
@classmethod
def list(cls): # pragma: no cover
return list(map(lambda c: c.value, cls))
================================================
FILE: MangaManager/common/models/Manga.py
================================================
from enum import Enum
class Manga(str, Enum):
UNKNOWN = 'Unknown'
NO = 'No'
YES = 'Yes'
YES_AND_RIGHT_TO_LEFT = 'YesAndRightToLeft'
@classmethod
def list(cls): # pragma: no cover
return list(map(lambda c: c.value, cls))
================================================
FILE: MangaManager/common/models/YesNo.py
================================================
from enum import Enum
class YesNo(str, Enum):
UNKNOWN = 'Unknown'
NO = 'No'
YES = 'Yes'
@classmethod
def list(cls): # pragma: no cover
return list(map(lambda c: c.value, cls))
================================================
FILE: MangaManager/common/models/__init__.py
================================================
from .AgeRating import AgeRating
from .ComicPageType import ComicPageType
from .Manga import Manga
from .YesNo import YesNo
from .ComicInfo import ComicInfo
Formats = (
"", "Special", "Reference", "Director's Cut", "Box Set", "Annual", "Anthology", "Epilogue", "One-Shot", "Prologue",
"TPB", "Trade Paper Back", "Omnibus", "Compendium", "Absolute", "Graphic Novel", "GN", "FCB"
)
PeopleTags = (
"Writer", "Penciller", "Inker", "Letterer", "CoverArtist", "Editor", "Translator",
"Publisher", "Imprint", "Other",
)
================================================
FILE: MangaManager/logging_setup.py
================================================
import logging
import sys
from logging.handlers import RotatingFileHandler
def trace(self, message, *args, **kws):
# Yes, logger takes its '*args' as 'args'.
self._log(logging.TRACE, message, args, **kws)
def add_trace_level():
logging.TRACE = 9
logging.addLevelName(logging.TRACE, "TRACE")
logging.Logger.trace = trace
class UmpumpedLogHandler(logging.Handler):
def emit(self, record):
logging.umpumped_events.append(record)
ei = record.exc_info
def setup_logging(LOGFILE_PATH,level=logging.DEBUG):
# Create our own implementation to have trace logging
# Setup Logger
logging.umpumped_events = []
umpumped_handler = logging.umpumped_handler = UmpumpedLogHandler(logging.INFO)
logging.getLogger('PIL').setLevel(logging.WARNING)
rotating_file_handler = RotatingFileHandler(LOGFILE_PATH, maxBytes=10_000_000,
backupCount=2)
rotating_file_handler.setLevel(level)
stream_handler = logging.StreamHandler(sys.stdout)
stream_handler.setLevel(level)
logging.basicConfig(level=logging.DEBUG,
format='%(asctime)s - %(name)20s - %(levelname)8s - %(message)s',
handlers=[stream_handler, rotating_file_handler, umpumped_handler]
# filename='/tmp/myapp.log'
)
logger = logging.getLogger()
logger.debug('DEBUG LEVEL - MAIN MODULE')
logger.info('INFO LEVEL - MAIN MODULE')
logger.trace('TRACE LEVEL - MAIN MODULE')
================================================
FILE: MangaManager/main.py
================================================
import argparse
import enum
import glob
import logging
from pathlib import Path
from logging_setup import add_trace_level, setup_logging
add_trace_level()
parser = argparse.ArgumentParser()
############
# Logging arguments
###########
parser.add_argument(
'--debug',
help="Print lots of debugging statements",
action="store_const", dest="loglevel", const=logging.DEBUG,
default=logging.INFO)
parser.add_argument(
'--trace',
help="Prints INSANE ammount of debug statements",
action="store_const", dest="loglevel", const=logging.TRACE,
)
parser.add_argument('-d', help="Debug Level", action="store", dest="selected_files_cli",
metavar="--cli ", required=False, default=False)
parser.add_argument('--cli', help="Metadata Editor in CLI mode", action="store", dest="selected_files_cli",
metavar="--cli ", required=False, default=False)
parser.add_argument('--webp', help="Webp converter in CLI mode", action="store", dest="selected_files_webp",
metavar="--webp ", required=False, default=False)
args = parser.parse_args()
# Setup logger
mm_path = Path(Path.home(), "MangaManager")
mm_path.mkdir(exist_ok=True, parents=True)
LOGS_PATH = Path(f"{mm_path}/logs/")
LOGS_PATH.mkdir(parents=True, exist_ok=True)
LOGFILE_PATH = Path(LOGS_PATH, "MangaManager.log")
setup_logging(LOGFILE_PATH, args.loglevel)
logger = logging.getLogger()
from src.Settings.Settings import Settings
# Create initial ini with defaults else load existing
Settings().load()
from src.Common.errors import NoFilesSelected
from src.MetadataManager.MetadataManagerCLI import App as CLIMetadataApp
from src.__version__ import __version__ as version
#
class ToolS(enum.Enum):
NONE = 0
METADATA = 1
WEBP = 5
@classmethod
def list(cls):
return list(map(lambda c: c.name, cls))
def get_selected_files(glob_path) -> list[str]:
file_paths = glob.glob(glob_path)
if not file_paths:
raise NoFilesSelected()
return file_paths
if __name__ == '__main__':
if args.selected_files_cli:
logger.info(f"Starting: CLI Metadata app")
selected_files = get_selected_files(args.selected_files_cli)
app = CLIMetadataApp(selected_files)
elif args.selected_files_webp:
logger.info(f"Starting: CLI Webp converter app")
# app = glob.glob(args.selected_files))
selected_files = get_selected_files(args.selected_files_cli)
else:
logger.info(f"Starting: GUI Manga Manager v{version}. Welcome")
from src.MetadataManager import execute_gui
execute_gui()
================================================
FILE: MangaManager/pyinstaller_hooks/hook-tkinterdnd2.py
================================================
from PyInstaller.utils.hooks import collect_data_files
datas = collect_data_files('tkinterdnd2')
================================================
FILE: MangaManager/res/languages.json
================================================
[{
"isoCode": "aa",
"title": "Afar"
}, {
"isoCode": "aa-DJ",
"title": "Afar (Djibouti)"
}, {
"isoCode": "aa-ER",
"title": "Afar (Eritrea)"
}, {
"isoCode": "aa-ET",
"title": "Afar (Ethiopia)"
}, {
"isoCode": "af",
"title": "Afrikaans"
}, {
"isoCode": "af-NA",
"title": "Afrikaans (Namibia)"
}, {
"isoCode": "af-ZA",
"title": "Afrikaans (South Africa)"
}, {
"isoCode": "agq",
"title": "Aghem"
}, {
"isoCode": "agq-CM",
"title": "Aghem (Cameroon)"
}, {
"isoCode": "ak",
"title": "Akan"
}, {
"isoCode": "ak-GH",
"title": "Akan (Ghana)"
}, {
"isoCode": "am",
"title": "Amharic"
}, {
"isoCode": "am-ET",
"title": "Amharic (Ethiopia)"
}, {
"isoCode": "ar",
"title": "Arabic"
}, {
"isoCode": "ar-001",
"title": "Arabic (World)"
}, {
"isoCode": "ar-AE",
"title": "Arabic (United Arab Emirates)"
}, {
"isoCode": "ar-BH",
"title": "Arabic (Bahrain)"
}, {
"isoCode": "ar-DJ",
"title": "Arabic (Djibouti)"
}, {
"isoCode": "ar-DZ",
"title": "Arabic (Algeria)"
}, {
"isoCode": "ar-EG",
"title": "Arabic (Egypt)"
}, {
"isoCode": "ar-ER",
"title": "Arabic (Eritrea)"
}, {
"isoCode": "ar-IL",
"title": "Arabic (Israel)"
}, {
"isoCode": "ar-IQ",
"title": "Arabic (Iraq)"
}, {
"isoCode": "ar-JO",
"title": "Arabic (Jordan)"
}, {
"isoCode": "ar-KM",
"title": "Arabic (Comoros)"
}, {
"isoCode": "ar-KW",
"title": "Arabic (Kuwait)"
}, {
"isoCode": "ar-LB",
"title": "Arabic (Lebanon)"
}, {
"isoCode": "ar-LY",
"title": "Arabic (Libya)"
}, {
"isoCode": "ar-MA",
"title": "Arabic (Morocco)"
}, {
"isoCode": "ar-MR",
"title": "Arabic (Mauritania)"
}, {
"isoCode": "ar-OM",
"title": "Arabic (Oman)"
}, {
"isoCode": "ar-PS",
"title": "Arabic (Palestinian Authority)"
}, {
"isoCode": "ar-QA",
"title": "Arabic (Qatar)"
}, {
"isoCode": "ar-SA",
"title": "Arabic (Saudi Arabia)"
}, {
"isoCode": "ar-SD",
"title": "Arabic (Sudan)"
}, {
"isoCode": "ar-SO",
"title": "Arabic (Somalia)"
}, {
"isoCode": "ar-SS",
"title": "Arabic (South Sudan)"
}, {
"isoCode": "ar-SY",
"title": "Arabic (Syria)"
}, {
"isoCode": "ar-TD",
"title": "Arabic (Chad)"
}, {
"isoCode": "ar-TN",
"title": "Arabic (Tunisia)"
}, {
"isoCode": "ar-YE",
"title": "Arabic (Yemen)"
}, {
"isoCode": "arn",
"title": "Mapuche"
}, {
"isoCode": "arn-CL",
"title": "Mapuche (Chile)"
}, {
"isoCode": "as",
"title": "Assamese"
}, {
"isoCode": "as-IN",
"title": "Assamese (India)"
}, {
"isoCode": "asa",
"title": "Asu"
}, {
"isoCode": "asa-TZ",
"title": "Asu (Tanzania)"
}, {
"isoCode": "ast",
"title": "Asturian"
}, {
"isoCode": "ast-ES",
"title": "Asturian (Spain)"
}, {
"isoCode": "az",
"title": "Azerbaijani"
}, {
"isoCode": "az-Cyrl",
"title": "Azerbaijani (Cyrillic)"
}, {
"isoCode": "az-Cyrl-AZ",
"title": "Azerbaijani (Cyrillic, Azerbaijan)"
}, {
"isoCode": "az-Latn",
"title": "Azerbaijani (Latin)"
}, {
"isoCode": "az-Latn-AZ",
"title": "Azerbaijani (Latin, Azerbaijan)"
}, {
"isoCode": "ba",
"title": "Bashkir"
}, {
"isoCode": "ba-RU",
"title": "Bashkir (Russia)"
}, {
"isoCode": "bas",
"title": "Basaa"
}, {
"isoCode": "bas-CM",
"title": "Basaa (Cameroon)"
}, {
"isoCode": "be",
"title": "Belarusian"
}, {
"isoCode": "be-BY",
"title": "Belarusian (Belarus)"
}, {
"isoCode": "bem",
"title": "Bemba"
}, {
"isoCode": "bem-ZM",
"title": "Bemba (Zambia)"
}, {
"isoCode": "bez",
"title": "Bena"
}, {
"isoCode": "bez-TZ",
"title": "Bena (Tanzania)"
}, {
"isoCode": "bg",
"title": "Bulgarian"
}, {
"isoCode": "bg-BG",
"title": "Bulgarian (Bulgaria)"
}, {
"isoCode": "bm",
"title": "Bamanankan"
}, {
"isoCode": "bm-ML",
"title": "Bamanankan (Mali)"
}, {
"isoCode": "bn",
"title": "Bangla"
}, {
"isoCode": "bn-BD",
"title": "Bangla (Bangladesh)"
}, {
"isoCode": "bn-IN",
"title": "Bangla (India)"
}, {
"isoCode": "bo",
"title": "Tibetan"
}, {
"isoCode": "bo-CN",
"title": "Tibetan (China)"
}, {
"isoCode": "bo-IN",
"title": "Tibetan (India)"
}, {
"isoCode": "br",
"title": "Breton"
}, {
"isoCode": "br-FR",
"title": "Breton (France)"
}, {
"isoCode": "brx",
"title": "Bodo"
}, {
"isoCode": "brx-IN",
"title": "Bodo (India)"
}, {
"isoCode": "bs",
"title": "Bosnian"
}, {
"isoCode": "bs-Cyrl",
"title": "Bosnian (Cyrillic)"
}, {
"isoCode": "bs-Cyrl-BA",
"title": "Bosnian (Cyrillic, Bosnia & Herzegovina)"
}, {
"isoCode": "bs-Latn",
"title": "Bosnian (Latin)"
}, {
"isoCode": "bs-Latn-BA",
"title": "Bosnian (Latin, Bosnia & Herzegovina)"
}, {
"isoCode": "byn",
"title": "Blin"
}, {
"isoCode": "byn-ER",
"title": "Blin (Eritrea)"
}, {
"isoCode": "ca",
"title": "Catalan"
}, {
"isoCode": "ca-AD",
"title": "Catalan (Andorra)"
}, {
"isoCode": "ca-ES",
"title": "Catalan (Spain)"
}, {
"isoCode": "ca-ES-VALENCIA",
"title": "Catalan (Spain, Valencian)"
}, {
"isoCode": "ca-FR",
"title": "Catalan (France)"
}, {
"isoCode": "ca-IT",
"title": "Catalan (Italy)"
}, {
"isoCode": "ccp",
"title": "Chakma"
}, {
"isoCode": "ccp-BD",
"title": "Chakma (Bangladesh)"
}, {
"isoCode": "ccp-IN",
"title": "Chakma (India)"
}, {
"isoCode": "ce",
"title": "Chechen"
}, {
"isoCode": "ce-RU",
"title": "Chechen (Russia)"
}, {
"isoCode": "ceb",
"title": "Cebuano"
}, {
"isoCode": "ceb-PH",
"title": "Cebuano (Philippines)"
}, {
"isoCode": "cgg",
"title": "Chiga"
}, {
"isoCode": "cgg-UG",
"title": "Chiga (Uganda)"
}, {
"isoCode": "chr",
"title": "Cherokee"
}, {
"isoCode": "chr-US",
"title": "Cherokee (United States)"
}, {
"isoCode": "ckb",
"title": "Central Kurdish"
}, {
"isoCode": "ckb-IQ",
"title": "Central Kurdish (Iraq)"
}, {
"isoCode": "ckb-IR",
"title": "Central Kurdish (Iran)"
}, {
"isoCode": "co",
"title": "Corsican"
}, {
"isoCode": "co-FR",
"title": "Corsican (France)"
}, {
"isoCode": "cs",
"title": "Czech"
}, {
"isoCode": "cs-CZ",
"title": "Czech (Czechia)"
}, {
"isoCode": "cu",
"title": "Church Slavic"
}, {
"isoCode": "cu-RU",
"title": "Church Slavic (Russia)"
}, {
"isoCode": "cy",
"title": "Welsh"
}, {
"isoCode": "cy-GB",
"title": "Welsh (United Kingdom)"
}, {
"isoCode": "da",
"title": "Danish"
}, {
"isoCode": "da-DK",
"title": "Danish (Denmark)"
}, {
"isoCode": "da-GL",
"title": "Danish (Greenland)"
}, {
"isoCode": "dav",
"title": "Taita"
}, {
"isoCode": "dav-KE",
"title": "Taita (Kenya)"
}, {
"isoCode": "de",
"title": "German"
}, {
"isoCode": "de-AT",
"title": "German (Austria)"
}, {
"isoCode": "de-BE",
"title": "German (Belgium)"
}, {
"isoCode": "de-CH",
"title": "German (Switzerland)"
}, {
"isoCode": "de-DE",
"title": "German (Germany)"
}, {
"isoCode": "de-IT",
"title": "German (Italy)"
}, {
"isoCode": "de-LI",
"title": "German (Liechtenstein)"
}, {
"isoCode": "de-LU",
"title": "German (Luxembourg)"
}, {
"isoCode": "dje",
"title": "Zarma"
}, {
"isoCode": "dje-NE",
"title": "Zarma (Niger)"
}, {
"isoCode": "dsb",
"title": "Lower Sorbian"
}, {
"isoCode": "dsb-DE",
"title": "Lower Sorbian (Germany)"
}, {
"isoCode": "dua",
"title": "Duala"
}, {
"isoCode": "dua-CM",
"title": "Duala (Cameroon)"
}, {
"isoCode": "dv",
"title": "Divehi"
}, {
"isoCode": "dv-MV",
"title": "Divehi (Maldives)"
}, {
"isoCode": "dyo",
"title": "Jola-Fonyi"
}, {
"isoCode": "dyo-SN",
"title": "Jola-Fonyi (Senegal)"
}, {
"isoCode": "dz",
"title": "Dzongkha"
}, {
"isoCode": "dz-BT",
"title": "Dzongkha (Bhutan)"
}, {
"isoCode": "ebu",
"title": "Embu"
}, {
"isoCode": "ebu-KE",
"title": "Embu (Kenya)"
}, {
"isoCode": "ee",
"title": "Ewe"
}, {
"isoCode": "ee-GH",
"title": "Ewe (Ghana)"
}, {
"isoCode": "ee-TG",
"title": "Ewe (Togo)"
}, {
"isoCode": "el",
"title": "Greek"
}, {
"isoCode": "el-CY",
"title": "Greek (Cyprus)"
}, {
"isoCode": "el-GR",
"title": "Greek (Greece)"
}, {
"isoCode": "en",
"title": "English"
}, {
"isoCode": "en-001",
"title": "English (World)"
}, {
"isoCode": "en-150",
"title": "English (Europe)"
}, {
"isoCode": "en-AE",
"title": "English (United Arab Emirates)"
}, {
"isoCode": "en-AG",
"title": "English (Antigua & Barbuda)"
}, {
"isoCode": "en-AI",
"title": "English (Anguilla)"
}, {
"isoCode": "en-AS",
"title": "English (American Samoa)"
}, {
"isoCode": "en-AT",
"title": "English (Austria)"
}, {
"isoCode": "en-AU",
"title": "English (Australia)"
}, {
"isoCode": "en-BB",
"title": "English (Barbados)"
}, {
"isoCode": "en-BE",
"title": "English (Belgium)"
}, {
"isoCode": "en-BI",
"title": "English (Burundi)"
}, {
"isoCode": "en-BM",
"title": "English (Bermuda)"
}, {
"isoCode": "en-BS",
"title": "English (Bahamas)"
}, {
"isoCode": "en-BW",
"title": "English (Botswana)"
}, {
"isoCode": "en-BZ",
"title": "English (Belize)"
}, {
"isoCode": "en-CA",
"title": "English (Canada)"
}, {
"isoCode": "en-CC",
"title": "English (Cocos [Keeling] Islands)"
}, {
"isoCode": "en-CH",
"title": "English (Switzerland)"
}, {
"isoCode": "en-CK",
"title": "English (Cook Islands)"
}, {
"isoCode": "en-CM",
"title": "English (Cameroon)"
}, {
"isoCode": "en-CX",
"title": "English (Christmas Island)"
}, {
"isoCode": "en-CY",
"title": "English (Cyprus)"
}, {
"isoCode": "en-DE",
"title": "English (Germany)"
}, {
"isoCode": "en-DK",
"title": "English (Denmark)"
}, {
"isoCode": "en-DM",
"title": "English (Dominica)"
}, {
"isoCode": "en-ER",
"title": "English (Eritrea)"
}, {
"isoCode": "en-FI",
"title": "English (Finland)"
}, {
"isoCode": "en-FJ",
"title": "English (Fiji)"
}, {
"isoCode": "en-FK",
"title": "English (Falkland Islands)"
}, {
"isoCode": "en-FM",
"title": "English (Micronesia)"
}, {
"isoCode": "en-GB",
"title": "English (United Kingdom)"
}, {
"isoCode": "en-GD",
"title": "English (Grenada)"
}, {
"isoCode": "en-GG",
"title": "English (Guernsey)"
}, {
"isoCode": "en-GH",
"title": "English (Ghana)"
}, {
"isoCode": "en-GI",
"title": "English (Gibraltar)"
}, {
"isoCode": "en-GM",
"title": "English (Gambia)"
}, {
"isoCode": "en-GU",
"title": "English (Guam)"
}, {
"isoCode": "en-GY",
"title": "English (Guyana)"
}, {
"isoCode": "en-HK",
"title": "English (Hong Kong SAR)"
}, {
"isoCode": "en-IE",
"title": "English (Ireland)"
}, {
"isoCode": "en-IL",
"title": "English (Israel)"
}, {
"isoCode": "en-IM",
"title": "English (Isle of Man)"
}, {
"isoCode": "en-IN",
"title": "English (India)"
}, {
"isoCode": "en-IO",
"title": "English (British Indian Ocean Territory)"
}, {
"isoCode": "en-JE",
"title": "English (Jersey)"
}, {
"isoCode": "en-JM",
"title": "English (Jamaica)"
}, {
"isoCode": "en-KE",
"title": "English (Kenya)"
}, {
"isoCode": "en-KI",
"title": "English (Kiribati)"
}, {
"isoCode": "en-KN",
"title": "English (St. Kitts & Nevis)"
}, {
"isoCode": "en-KY",
"title": "English (Cayman Islands)"
}, {
"isoCode": "en-LC",
"title": "English (St. Lucia)"
}, {
"isoCode": "en-LR",
"title": "English (Liberia)"
}, {
"isoCode": "en-LS",
"title": "English (Lesotho)"
}, {
"isoCode": "en-MG",
"title": "English (Madagascar)"
}, {
"isoCode": "en-MH",
"title": "English (Marshall Islands)"
}, {
"isoCode": "en-MO",
"title": "English (Macao SAR)"
}, {
"isoCode": "en-MP",
"title": "English (Northern Mariana Islands)"
}, {
"isoCode": "en-MS",
"title": "English (Montserrat)"
}, {
"isoCode": "en-MT",
"title": "English (Malta)"
}, {
"isoCode": "en-MU",
"title": "English (Mauritius)"
}, {
"isoCode": "en-MW",
"title": "English (Malawi)"
}, {
"isoCode": "en-MY",
"title": "English (Malaysia)"
}, {
"isoCode": "en-NA",
"title": "English (Namibia)"
}, {
"isoCode": "en-NF",
"title": "English (Norfolk Island)"
}, {
"isoCode": "en-NG",
"title": "English (Nigeria)"
}, {
"isoCode": "en-NL",
"title": "English (Netherlands)"
}, {
"isoCode": "en-NR",
"title": "English (Nauru)"
}, {
"isoCode": "en-NU",
"title": "English (Niue)"
}, {
"isoCode": "en-NZ",
"title": "English (New Zealand)"
}, {
"isoCode": "en-PG",
"title": "English (Papua New Guinea)"
}, {
"isoCode": "en-PH",
"title": "English (Philippines)"
}, {
"isoCode": "en-PK",
"title": "English (Pakistan)"
}, {
"isoCode": "en-PN",
"title": "English (Pitcairn Islands)"
}, {
"isoCode": "en-PR",
"title": "English (Puerto Rico)"
}, {
"isoCode": "en-PW",
"title": "English (Palau)"
}, {
"isoCode": "en-RW",
"title": "English (Rwanda)"
}, {
"isoCode": "en-SB",
"title": "English (Solomon Islands)"
}, {
"isoCode": "en-SC",
"title": "English (Seychelles)"
}, {
"isoCode": "en-SD",
"title": "English (Sudan)"
}, {
"isoCode": "en-SE",
"title": "English (Sweden)"
}, {
"isoCode": "en-SG",
"title": "English (Singapore)"
}, {
"isoCode": "en-SH",
"title": "English (St Helena, Ascension, Tristan da Cunha)"
}, {
"isoCode": "en-SI",
"title": "English (Slovenia)"
}, {
"isoCode": "en-SL",
"title": "English (Sierra Leone)"
}, {
"isoCode": "en-SS",
"title": "English (South Sudan)"
}, {
"isoCode": "en-SX",
"title": "English (Sint Maarten)"
}, {
"isoCode": "en-SZ",
"title": "English (Eswatini)"
}, {
"isoCode": "en-TC",
"title": "English (Turks & Caicos Islands)"
}, {
"isoCode": "en-TK",
"title": "English (Tokelau)"
}, {
"isoCode": "en-TO",
"title": "English (Tonga)"
}, {
"isoCode": "en-TT",
"title": "English (Trinidad & Tobago)"
}, {
"isoCode": "en-TV",
"title": "English (Tuvalu)"
}, {
"isoCode": "en-TZ",
"title": "English (Tanzania)"
}, {
"isoCode": "en-UG",
"title": "English (Uganda)"
}, {
"isoCode": "en-UM",
"title": "English (U.S. Outlying Islands)"
}, {
"isoCode": "en-US",
"title": "English (United States)"
}, {
"isoCode": "en-US-POSIX",
"title": "English (United States, Computer)"
}, {
"isoCode": "en-VC",
"title": "English (St. Vincent & Grenadines)"
}, {
"isoCode": "en-VG",
"title": "English (British Virgin Islands)"
}, {
"isoCode": "en-VI",
"title": "English (U.S. Virgin Islands)"
}, {
"isoCode": "en-VU",
"title": "English (Vanuatu)"
}, {
"isoCode": "en-WS",
"title": "English (Samoa)"
}, {
"isoCode": "en-ZA",
"title": "English (South Africa)"
}, {
"isoCode": "en-ZM",
"title": "English (Zambia)"
}, {
"isoCode": "en-ZW",
"title": "English (Zimbabwe)"
}, {
"isoCode": "eo",
"title": "Esperanto"
}, {
"isoCode": "eo-001",
"title": "Esperanto (World)"
}, {
"isoCode": "es",
"title": "Spanish"
}, {
"isoCode": "es-419",
"title": "Spanish (Latin America)"
}, {
"isoCode": "es-AR",
"title": "Spanish (Argentina)"
}, {
"isoCode": "es-BO",
"title": "Spanish (Bolivia)"
}, {
"isoCode": "es-BR",
"title": "Spanish (Brazil)"
}, {
"isoCode": "es-BZ",
"title": "Spanish (Belize)"
}, {
"isoCode": "es-CL",
"title": "Spanish (Chile)"
}, {
"isoCode": "es-CO",
"title": "Spanish (Colombia)"
}, {
"isoCode": "es-CR",
"title": "Spanish (Costa Rica)"
}, {
"isoCode": "es-CU",
"title": "Spanish (Cuba)"
}, {
"isoCode": "es-DO",
"title": "Spanish (Dominican Republic)"
}, {
"isoCode": "es-EC",
"title": "Spanish (Ecuador)"
}, {
"isoCode": "es-ES",
"title": "Spanish (Spain)"
}, {
"isoCode": "es-GQ",
"title": "Spanish (Equatorial Guinea)"
}, {
"isoCode": "es-GT",
"title": "Spanish (Guatemala)"
}, {
"isoCode": "es-HN",
"title": "Spanish (Honduras)"
}, {
"isoCode": "es-MX",
"title": "Spanish (Mexico)"
}, {
"isoCode": "es-NI",
"title": "Spanish (Nicaragua)"
}, {
"isoCode": "es-PA",
"title": "Spanish (Panama)"
}, {
"isoCode": "es-PE",
"title": "Spanish (Peru)"
}, {
"isoCode": "es-PH",
"title": "Spanish (Philippines)"
}, {
"isoCode": "es-PR",
"title": "Spanish (Puerto Rico)"
}, {
"isoCode": "es-PY",
"title": "Spanish (Paraguay)"
}, {
"isoCode": "es-SV",
"title": "Spanish (El Salvador)"
}, {
"isoCode": "es-US",
"title": "Spanish (United States)"
}, {
"isoCode": "es-UY",
"title": "Spanish (Uruguay)"
}, {
"isoCode": "es-VE",
"title": "Spanish (Venezuela)"
}, {
"isoCode": "et",
"title": "Estonian"
}, {
"isoCode": "et-EE",
"title": "Estonian (Estonia)"
}, {
"isoCode": "eu",
"title": "Basque"
}, {
"isoCode": "eu-ES",
"title": "Basque (Spain)"
}, {
"isoCode": "ewo",
"title": "Ewondo"
}, {
"isoCode": "ewo-CM",
"title": "Ewondo (Cameroon)"
}, {
"isoCode": "fa",
"title": "Persian"
}, {
"isoCode": "fa-AF",
"title": "Persian (Afghanistan)"
}, {
"isoCode": "fa-IR",
"title": "Persian (Iran)"
}, {
"isoCode": "ff",
"title": "Fulah"
}, {
"isoCode": "ff-Latn",
"title": "Fulah (Latin)"
}, {
"isoCode": "ff-Latn-BF",
"title": "Fulah (Latin, Burkina Faso)"
}, {
"isoCode": "ff-Latn-CM",
"title": "Fulah (Latin, Cameroon)"
}, {
"isoCode": "ff-Latn-GH",
"title": "Fulah (Latin, Ghana)"
}, {
"isoCode": "ff-Latn-GM",
"title": "Fulah (Latin, Gambia)"
}, {
"isoCode": "ff-Latn-GN",
"title": "Fulah (Latin, Guinea)"
}, {
"isoCode": "ff-Latn-GW",
"title": "Fulah (Latin, Guinea-Bissau)"
}, {
"isoCode": "ff-Latn-LR",
"title": "Fulah (Latin, Liberia)"
}, {
"isoCode": "ff-Latn-MR",
"title": "Fulah (Latin, Mauritania)"
}, {
"isoCode": "ff-Latn-NE",
"title": "Fulah (Latin, Niger)"
}, {
"isoCode": "ff-Latn-NG",
"title": "Fulah (Latin, Nigeria)"
}, {
"isoCode": "ff-Latn-SL",
"title": "Fulah (Latin, Sierra Leone)"
}, {
"isoCode": "ff-Latn-SN",
"title": "Fulah (Latin, Senegal)"
}, {
"isoCode": "fi",
"title": "Finnish"
}, {
"isoCode": "fi-FI",
"title": "Finnish (Finland)"
}, {
"isoCode": "fil",
"title": "Filipino"
}, {
"isoCode": "fil-PH",
"title": "Filipino (Philippines)"
}, {
"isoCode": "fo",
"title": "Faroese"
}, {
"isoCode": "fo-DK",
"title": "Faroese (Denmark)"
}, {
"isoCode": "fo-FO",
"title": "Faroese (Faroe Islands)"
}, {
"isoCode": "fr",
"title": "French"
}, {
"isoCode": "fr-BE",
"title": "French (Belgium)"
}, {
"isoCode": "fr-BF",
"title": "French (Burkina Faso)"
}, {
"isoCode": "fr-BI",
"title": "French (Burundi)"
}, {
"isoCode": "fr-BJ",
"title": "French (Benin)"
}, {
"isoCode": "fr-BL",
"title": "French (St. Barthélemy)"
}, {
"isoCode": "fr-CA",
"title": "French (Canada)"
}, {
"isoCode": "fr-CD",
"title": "French (Congo [DRC])"
}, {
"isoCode": "fr-CF",
"title": "French (Central African Republic)"
}, {
"isoCode": "fr-CG",
"title": "French (Congo)"
}, {
"isoCode": "fr-CH",
"title": "French (Switzerland)"
}, {
"isoCode": "fr-CI",
"title": "French (Côte d’Ivoire)"
}, {
"isoCode": "fr-CM",
"title": "French (Cameroon)"
}, {
"isoCode": "fr-DJ",
"title": "French (Djibouti)"
}, {
"isoCode": "fr-DZ",
"title": "French (Algeria)"
}, {
"isoCode": "fr-FR",
"title": "French (France)"
}, {
"isoCode": "fr-GA",
"title": "French (Gabon)"
}, {
"isoCode": "fr-GF",
"title": "French (French Guiana)"
}, {
"isoCode": "fr-GN",
"title": "French (Guinea)"
}, {
"isoCode": "fr-GP",
"title": "French (Guadeloupe)"
}, {
"isoCode": "fr-GQ",
"title": "French (Equatorial Guinea)"
}, {
"isoCode": "fr-HT",
"title": "French (Haiti)"
}, {
"isoCode": "fr-KM",
"title": "French (Comoros)"
}, {
"isoCode": "fr-LU",
"title": "French (Luxembourg)"
}, {
"isoCode": "fr-MA",
"title": "French (Morocco)"
}, {
"isoCode": "fr-MC",
"title": "French (Monaco)"
}, {
"isoCode": "fr-MF",
"title": "French (St. Martin)"
}, {
"isoCode": "fr-MG",
"title": "French (Madagascar)"
}, {
"isoCode": "fr-ML",
"title": "French (Mali)"
}, {
"isoCode": "fr-MQ",
"title": "French (Martinique)"
}, {
"isoCode": "fr-MR",
"title": "French (Mauritania)"
}, {
"isoCode": "fr-MU",
"title": "French (Mauritius)"
}, {
"isoCode": "fr-NC",
"title": "French (New Caledonia)"
}, {
"isoCode": "fr-NE",
"title": "French (Niger)"
}, {
"isoCode": "fr-PF",
"title": "French (French Polynesia)"
}, {
"isoCode": "fr-PM",
"title": "French (St. Pierre & Miquelon)"
}, {
"isoCode": "fr-RE",
"title": "French (Réunion)"
}, {
"isoCode": "fr-RW",
"title": "French (Rwanda)"
}, {
"isoCode": "fr-SC",
"title": "French (Seychelles)"
}, {
"isoCode": "fr-SN",
"title": "French (Senegal)"
}, {
"isoCode": "fr-SY",
"title": "French (Syria)"
}, {
"isoCode": "fr-TD",
"title": "French (Chad)"
}, {
"isoCode": "fr-TG",
"title": "French (Togo)"
}, {
"isoCode": "fr-TN",
"title": "French (Tunisia)"
}, {
"isoCode": "fr-VU",
"title": "French (Vanuatu)"
}, {
"isoCode": "fr-WF",
"title": "French (Wallis & Futuna)"
}, {
"isoCode": "fr-YT",
"title": "French (Mayotte)"
}, {
"isoCode": "fur",
"title": "Friulian"
}, {
"isoCode": "fur-IT",
"title": "Friulian (Italy)"
}, {
"isoCode": "fy",
"title": "Western Frisian"
}, {
"isoCode": "fy-NL",
"title": "Western Frisian (Netherlands)"
}, {
"isoCode": "ga",
"title": "Irish"
}, {
"isoCode": "ga-IE",
"title": "Irish (Ireland)"
}, {
"isoCode": "gd",
"title": "Scottish Gaelic"
}, {
"isoCode": "gd-GB",
"title": "Scottish Gaelic (United Kingdom)"
}, {
"isoCode": "gl",
"title": "Galician"
}, {
"isoCode": "gl-ES",
"title": "Galician (Spain)"
}, {
"isoCode": "gn",
"title": "Guarani"
}, {
"isoCode": "gn-PY",
"title": "Guarani (Paraguay)"
}, {
"isoCode": "gsw",
"title": "Swiss German"
}, {
"isoCode": "gsw-CH",
"title": "Swiss German (Switzerland)"
}, {
"isoCode": "gsw-FR",
"title": "Swiss German (France)"
}, {
"isoCode": "gsw-LI",
"title": "Swiss German (Liechtenstein)"
}, {
"isoCode": "gu",
"title": "Gujarati"
}, {
"isoCode": "gu-IN",
"title": "Gujarati (India)"
}, {
"isoCode": "guz",
"title": "Gusii"
}, {
"isoCode": "guz-KE",
"title": "Gusii (Kenya)"
}, {
"isoCode": "gv",
"title": "Manx"
}, {
"isoCode": "gv-IM",
"title": "Manx (Isle of Man)"
}, {
"isoCode": "ha",
"title": "Hausa"
}, {
"isoCode": "ha-GH",
"title": "Hausa (Ghana)"
}, {
"isoCode": "ha-NE",
"title": "Hausa (Niger)"
}, {
"isoCode": "ha-NG",
"title": "Hausa (Nigeria)"
}, {
"isoCode": "haw",
"title": "Hawaiian"
}, {
"isoCode": "haw-US",
"title": "Hawaiian (United States)"
}, {
"isoCode": "he",
"title": "Hebrew"
}, {
"isoCode": "he-IL",
"title": "Hebrew (Israel)"
}, {
"isoCode": "hi",
"title": "Hindi"
}, {
"isoCode": "hi-IN",
"title": "Hindi (India)"
}, {
"isoCode": "hr",
"title": "Croatian"
}, {
"isoCode": "hr-BA",
"title": "Croatian (Bosnia & Herzegovina)"
}, {
"isoCode": "hr-HR",
"title": "Croatian (Croatia)"
}, {
"isoCode": "hsb",
"title": "Upper Sorbian"
}, {
"isoCode": "hsb-DE",
"title": "Upper Sorbian (Germany)"
}, {
"isoCode": "hu",
"title": "Hungarian"
}, {
"isoCode": "hu-HU",
"title": "Hungarian (Hungary)"
}, {
"isoCode": "hy",
"title": "Armenian"
}, {
"isoCode": "hy-AM",
"title": "Armenian (Armenia)"
}, {
"isoCode": "ia",
"title": "Interlingua"
}, {
"isoCode": "ia-001",
"title": "Interlingua (World)"
}, {
"isoCode": "id",
"title": "Indonesian"
}, {
"isoCode": "id-ID",
"title": "Indonesian (Indonesia)"
}, {
"isoCode": "ig",
"title": "Igbo"
}, {
"isoCode": "ig-NG",
"title": "Igbo (Nigeria)"
}, {
"isoCode": "ii",
"title": "Yi"
}, {
"isoCode": "ii-CN",
"title": "Yi (China)"
}, {
"isoCode": "is",
"title": "Icelandic"
}, {
"isoCode": "is-IS",
"title": "Icelandic (Iceland)"
}, {
"isoCode": "it",
"title": "Italian"
}, {
"isoCode": "it-CH",
"title": "Italian (Switzerland)"
}, {
"isoCode": "it-IT",
"title": "Italian (Italy)"
}, {
"isoCode": "it-SM",
"title": "Italian (San Marino)"
}, {
"isoCode": "it-VA",
"title": "Italian (Vatican City)"
}, {
"isoCode": "iu",
"title": "Inuktitut"
}, {
"isoCode": "iu-CA",
"title": "Inuktitut (Canada)"
}, {
"isoCode": "iu-Latn",
"title": "Inuktitut (Latin)"
}, {
"isoCode": "iu-Latn-CA",
"title": "Inuktitut (Latin, Canada)"
}, {
"isoCode": "ja",
"title": "Japanese"
}, {
"isoCode": "ja-JP",
"title": "Japanese (Japan)"
}, {
"isoCode": "jgo",
"title": "Ngomba"
}, {
"isoCode": "jgo-CM",
"title": "Ngomba (Cameroon)"
}, {
"isoCode": "jmc",
"title": "Machame"
}, {
"isoCode": "jmc-TZ",
"title": "Machame (Tanzania)"
}, {
"isoCode": "jv",
"title": "Javanese"
}, {
"isoCode": "jv-ID",
"title": "Javanese (Indonesia)"
}, {
"isoCode": "ka",
"title": "Georgian"
}, {
"isoCode": "ka-GE",
"title": "Georgian (Georgia)"
}, {
"isoCode": "kab",
"title": "Kabyle"
}, {
"isoCode": "kab-DZ",
"title": "Kabyle (Algeria)"
}, {
"isoCode": "kam",
"title": "Kamba"
}, {
"isoCode": "kam-KE",
"title": "Kamba (Kenya)"
}, {
"isoCode": "kde",
"title": "Makonde"
}, {
"isoCode": "kde-TZ",
"title": "Makonde (Tanzania)"
}, {
"isoCode": "kea",
"title": "Kabuverdianu"
}, {
"isoCode": "kea-CV",
"title": "Kabuverdianu (Cabo Verde)"
}, {
"isoCode": "khq",
"title": "Koyra Chiini"
}, {
"isoCode": "khq-ML",
"title": "Koyra Chiini (Mali)"
}, {
"isoCode": "ki",
"title": "Kikuyu"
}, {
"isoCode": "ki-KE",
"title": "Kikuyu (Kenya)"
}, {
"isoCode": "kk",
"title": "Kazakh"
}, {
"isoCode": "kk-KZ",
"title": "Kazakh (Kazakhstan)"
}, {
"isoCode": "kkj",
"title": "Kako"
}, {
"isoCode": "kkj-CM",
"title": "Kako (Cameroon)"
}, {
"isoCode": "kl",
"title": "Kalaallisut"
}, {
"isoCode": "kl-GL",
"title": "Kalaallisut (Greenland)"
}, {
"isoCode": "kln",
"title": "Kalenjin"
}, {
"isoCode": "kln-KE",
"title": "Kalenjin (Kenya)"
}, {
"isoCode": "km",
"title": "Khmer"
}, {
"isoCode": "km-KH",
"title": "Khmer (Cambodia)"
}, {
"isoCode": "kn",
"title": "Kannada"
}, {
"isoCode": "kn-IN",
"title": "Kannada (India)"
}, {
"isoCode": "ko",
"title": "Korean"
}, {
"isoCode": "ko-KP",
"title": "Korean (North Korea)"
}, {
"isoCode": "ko-KR",
"title": "Korean (Korea)"
}, {
"isoCode": "kok",
"title": "Konkani"
}, {
"isoCode": "kok-IN",
"title": "Konkani (India)"
}, {
"isoCode": "ks",
"title": "Kashmiri"
}, {
"isoCode": "ks-IN",
"title": "Kashmiri (India)"
}, {
"isoCode": "ksb",
"title": "Shambala"
}, {
"isoCode": "ksb-TZ",
"title": "Shambala (Tanzania)"
}, {
"isoCode": "ksf",
"title": "Bafia"
}, {
"isoCode": "ksf-CM",
"title": "Bafia (Cameroon)"
}, {
"isoCode": "ksh",
"title": "Colognian"
}, {
"isoCode": "ksh-DE",
"title": "Colognian (Germany)"
}, {
"isoCode": "kw",
"title": "Cornish"
}, {
"isoCode": "kw-GB",
"title": "Cornish (United Kingdom)"
}, {
"isoCode": "ky",
"title": "Kyrgyz"
}, {
"isoCode": "ky-KG",
"title": "Kyrgyz (Kyrgyzstan)"
}, {
"isoCode": "lag",
"title": "Langi"
}, {
"isoCode": "lag-TZ",
"title": "Langi (Tanzania)"
}, {
"isoCode": "lb",
"title": "Luxembourgish"
}, {
"isoCode": "lb-LU",
"title": "Luxembourgish (Luxembourg)"
}, {
"isoCode": "lg",
"title": "Ganda"
}, {
"isoCode": "lg-UG",
"title": "Ganda (Uganda)"
}, {
"isoCode": "lkt",
"title": "Lakota"
}, {
"isoCode": "lkt-US",
"title": "Lakota (United States)"
}, {
"isoCode": "ln",
"title": "Lingala"
}, {
"isoCode": "ln-AO",
"title": "Lingala (Angola)"
}, {
"isoCode": "ln-CD",
"title": "Lingala (Congo [DRC])"
}, {
"isoCode": "ln-CF",
"title": "Lingala (Central African Republic)"
}, {
"isoCode": "ln-CG",
"title": "Lingala (Congo)"
}, {
"isoCode": "lo",
"title": "Lao"
}, {
"isoCode": "lo-LA",
"title": "Lao (Laos)"
}, {
"isoCode": "lrc",
"title": "Northern Luri"
}, {
"isoCode": "lrc-IQ",
"title": "Northern Luri (Iraq)"
}, {
"isoCode": "lrc-IR",
"title": "Northern Luri (Iran)"
}, {
"isoCode": "lt",
"title": "Lithuanian"
}, {
"isoCode": "lt-LT",
"title": "Lithuanian (Lithuania)"
}, {
"isoCode": "lu",
"title": "Luba-Katanga"
}, {
"isoCode": "lu-CD",
"title": "Luba-Katanga (Congo [DRC])"
}, {
"isoCode": "luo",
"title": "Luo"
}, {
"isoCode": "luo-KE",
"title": "Luo (Kenya)"
}, {
"isoCode": "luy",
"title": "Luyia"
}, {
"isoCode": "luy-KE",
"title": "Luyia (Kenya)"
}, {
"isoCode": "lv",
"title": "Latvian"
}, {
"isoCode": "lv-LV",
"title": "Latvian (Latvia)"
}, {
"isoCode": "mas",
"title": "Masai"
}, {
"isoCode": "mas-KE",
"title": "Masai (Kenya)"
}, {
"isoCode": "mas-TZ",
"title": "Masai (Tanzania)"
}, {
"isoCode": "mer",
"title": "Meru"
}, {
"isoCode": "mer-KE",
"title": "Meru (Kenya)"
}, {
"isoCode": "mfe",
"title": "Morisyen"
}, {
"isoCode": "mfe-MU",
"title": "Morisyen (Mauritius)"
}, {
"isoCode": "mg",
"title": "Malagasy"
}, {
"isoCode": "mg-MG",
"title": "Malagasy (Madagascar)"
}, {
"isoCode": "mgh",
"title": "Makhuwa-Meetto"
}, {
"isoCode": "mgh-MZ",
"title": "Makhuwa-Meetto (Mozambique)"
}, {
"isoCode": "mgo",
"title": "Metaʼ"
}, {
"isoCode": "mgo-CM",
"title": "Metaʼ (Cameroon)"
}, {
"isoCode": "mi",
"title": "Maori"
}, {
"isoCode": "mi-NZ",
"title": "Maori (New Zealand)"
}, {
"isoCode": "mk",
"title": "Macedonian"
}, {
"isoCode": "mk-MK",
"title": "Macedonian (North Macedonia)"
}, {
"isoCode": "ml",
"title": "Malayalam"
}, {
"isoCode": "ml-IN",
"title": "Malayalam (India)"
}, {
"isoCode": "mn",
"title": "Mongolian"
}, {
"isoCode": "mn-MN",
"title": "Mongolian (Mongolia)"
}, {
"isoCode": "mn-Mong",
"title": "Mongolian (Mongolian)"
}, {
"isoCode": "mn-Mong-CN",
"title": "Mongolian (Mongolian, China)"
}, {
"isoCode": "mn-Mong-MN",
"title": "Mongolian (Mongolian, Mongolia)"
}, {
"isoCode": "moh",
"title": "Mohawk"
}, {
"isoCode": "moh-CA",
"title": "Mohawk (Canada)"
}, {
"isoCode": "mr",
"title": "Marathi"
}, {
"isoCode": "mr-IN",
"title": "Marathi (India)"
}, {
"isoCode": "ms",
"title": "Malay"
}, {
"isoCode": "ms-BN",
"title": "Malay (Brunei)"
}, {
"isoCode": "ms-MY",
"title": "Malay (Malaysia)"
}, {
"isoCode": "ms-SG",
"title": "Malay (Singapore)"
}, {
"isoCode": "mt",
"title": "Maltese"
}, {
"isoCode": "mt-MT",
"title": "Maltese (Malta)"
}, {
"isoCode": "mua",
"title": "Mundang"
}, {
"isoCode": "mua-CM",
"title": "Mundang (Cameroon)"
}, {
"isoCode": "my",
"title": "Burmese"
}, {
"isoCode": "my-MM",
"title": "Burmese (Myanmar)"
}, {
"isoCode": "mzn",
"title": "Mazanderani"
}, {
"isoCode": "mzn-IR",
"title": "Mazanderani (Iran)"
}, {
"isoCode": "naq",
"title": "Nama"
}, {
"isoCode": "naq-NA",
"title": "Nama (Namibia)"
}, {
"isoCode": "nb",
"title": "Norwegian Bokmål"
}, {
"isoCode": "nb-NO",
"title": "Norwegian Bokmål (Norway)"
}, {
"isoCode": "nb-SJ",
"title": "Norwegian Bokmål (Svalbard & Jan Mayen)"
}, {
"isoCode": "nd",
"title": "North Ndebele"
}, {
"isoCode": "nd-ZW",
"title": "North Ndebele (Zimbabwe)"
}, {
"isoCode": "nds",
"title": "Low German"
}, {
"isoCode": "nds-DE",
"title": "Low German (Germany)"
}, {
"isoCode": "nds-NL",
"title": "Low German (Netherlands)"
}, {
"isoCode": "ne",
"title": "Nepali"
}, {
"isoCode": "ne-IN",
"title": "Nepali (India)"
}, {
"isoCode": "ne-NP",
"title": "Nepali (Nepal)"
}, {
"isoCode": "nl",
"title": "Dutch"
}, {
"isoCode": "nl-AW",
"title": "Dutch (Aruba)"
}, {
"isoCode": "nl-BE",
"title": "Dutch (Belgium)"
}, {
"isoCode": "nl-BQ",
"title": "Dutch (Bonaire, Sint Eustatius and Saba)"
}, {
"isoCode": "nl-CW",
"title": "Dutch (Curaçao)"
}, {
"isoCode": "nl-NL",
"title": "Dutch (Netherlands)"
}, {
"isoCode": "nl-SR",
"title": "Dutch (Suriname)"
}, {
"isoCode": "nl-SX",
"title": "Dutch (Sint Maarten)"
}, {
"isoCode": "nmg",
"title": "Kwasio"
}, {
"isoCode": "nmg-CM",
"title": "Kwasio (Cameroon)"
}, {
"isoCode": "nn",
"title": "Norwegian Nynorsk"
}, {
"isoCode": "nn-NO",
"title": "Norwegian Nynorsk (Norway)"
}, {
"isoCode": "nnh",
"title": "Ngiemboon"
}, {
"isoCode": "nnh-CM",
"title": "Ngiemboon (Cameroon)"
}, {
"isoCode": "nqo",
"title": "N’Ko"
}, {
"isoCode": "nqo-GN",
"title": "N’Ko (Guinea)"
}, {
"isoCode": "nr",
"title": "South Ndebele"
}, {
"isoCode": "nr-ZA",
"title": "South Ndebele (South Africa)"
}, {
"isoCode": "nso",
"title": "Sesotho sa Leboa"
}, {
"isoCode": "nso-ZA",
"title": "Sesotho sa Leboa (South Africa)"
}, {
"isoCode": "nus",
"title": "Nuer"
}, {
"isoCode": "nus-SS",
"title": "Nuer (South Sudan)"
}, {
"isoCode": "nyn",
"title": "Nyankole"
}, {
"isoCode": "nyn-UG",
"title": "Nyankole (Uganda)"
}, {
"isoCode": "oc",
"title": "Occitan"
}, {
"isoCode": "oc-FR",
"title": "Occitan (France)"
}, {
"isoCode": "om",
"title": "Oromo"
}, {
"isoCode": "om-ET",
"title": "Oromo (Ethiopia)"
}, {
"isoCode": "om-KE",
"title": "Oromo (Kenya)"
}, {
"isoCode": "or",
"title": "Odia"
}, {
"isoCode": "or-IN",
"title": "Odia (India)"
}, {
"isoCode": "os",
"title": "Ossetic"
}, {
"isoCode": "os-GE",
"title": "Ossetic (Georgia)"
}, {
"isoCode": "os-RU",
"title": "Ossetic (Russia)"
}, {
"isoCode": "pa",
"title": "Punjabi"
}, {
"isoCode": "pa-Arab",
"title": "Punjabi (Arabic)"
}, {
"isoCode": "pa-Arab-PK",
"title": "Punjabi (Arabic, Pakistan)"
}, {
"isoCode": "pa-Guru",
"title": "Punjabi (Gurmukhi)"
}, {
"isoCode": "pa-Guru-IN",
"title": "Punjabi (Gurmukhi, India)"
}, {
"isoCode": "pl",
"title": "Polish"
}, {
"isoCode": "pl-PL",
"title": "Polish (Poland)"
}, {
"isoCode": "prg",
"title": "Prussian"
}, {
"isoCode": "prg-001",
"title": "Prussian (World)"
}, {
"isoCode": "ps",
"title": "Pashto"
}, {
"isoCode": "ps-AF",
"title": "Pashto (Afghanistan)"
}, {
"isoCode": "ps-PK",
"title": "Pashto (Pakistan)"
}, {
"isoCode": "pt",
"title": "Portuguese"
}, {
"isoCode": "pt-AO",
"title": "Portuguese (Angola)"
}, {
"isoCode": "pt-BR",
"title": "Portuguese (Brazil)"
}, {
"isoCode": "pt-CH",
"title": "Portuguese (Switzerland)"
}, {
"isoCode": "pt-CV",
"title": "Portuguese (Cabo Verde)"
}, {
"isoCode": "pt-GQ",
"title": "Portuguese (Equatorial Guinea)"
}, {
"isoCode": "pt-GW",
"title": "Portuguese (Guinea-Bissau)"
}, {
"isoCode": "pt-LU",
"title": "Portuguese (Luxembourg)"
}, {
"isoCode": "pt-MO",
"title": "Portuguese (Macao SAR)"
}, {
"isoCode": "pt-MZ",
"title": "Portuguese (Mozambique)"
}, {
"isoCode": "pt-PT",
"title": "Portuguese (Portugal)"
}, {
"isoCode": "pt-ST",
"title": "Portuguese (São Tomé & Príncipe)"
}, {
"isoCode": "pt-TL",
"title": "Portuguese (Timor-Leste)"
}, {
"isoCode": "qu",
"title": "Quechua"
}, {
"isoCode": "qu-BO",
"title": "Quechua (Bolivia)"
}, {
"isoCode": "qu-EC",
"title": "Quechua (Ecuador)"
}, {
"isoCode": "qu-PE",
"title": "Quechua (Peru)"
}, {
"isoCode": "quc",
"title": "Kʼicheʼ"
}, {
"isoCode": "quc-GT",
"title": "Kʼicheʼ (Guatemala)"
}, {
"isoCode": "rm",
"title": "Romansh"
}, {
"isoCode": "rm-CH",
"title": "Romansh (Switzerland)"
}, {
"isoCode": "rn",
"title": "Rundi"
}, {
"isoCode": "rn-BI",
"title": "Rundi (Burundi)"
}, {
"isoCode": "ro",
"title": "Romanian"
}, {
"isoCode": "ro-MD",
"title": "Romanian (Moldova)"
}, {
"isoCode": "ro-RO",
"title": "Romanian (Romania)"
}, {
"isoCode": "rof",
"title": "Rombo"
}, {
"isoCode": "rof-TZ",
"title": "Rombo (Tanzania)"
}, {
"isoCode": "ru",
"title": "Russian"
}, {
"isoCode": "ru-BY",
"title": "Russian (Belarus)"
}, {
"isoCode": "ru-KG",
"title": "Russian (Kyrgyzstan)"
}, {
"isoCode": "ru-KZ",
"title": "Russian (Kazakhstan)"
}, {
"isoCode": "ru-MD",
"title": "Russian (Moldova)"
}, {
"isoCode": "ru-RU",
"title": "Russian (Russia)"
}, {
"isoCode": "ru-UA",
"title": "Russian (Ukraine)"
}, {
"isoCode": "rw",
"title": "Kinyarwanda"
}, {
"isoCode": "rw-RW",
"title": "Kinyarwanda (Rwanda)"
}, {
"isoCode": "rwk",
"title": "Rwa"
}, {
"isoCode": "rwk-TZ",
"title": "Rwa (Tanzania)"
}, {
"isoCode": "sa",
"title": "Sanskrit"
}, {
"isoCode": "sa-IN",
"title": "Sanskrit (India)"
}, {
"isoCode": "sah",
"title": "Sakha"
}, {
"isoCode": "sah-RU",
"title": "Sakha (Russia)"
}, {
"isoCode": "saq",
"title": "Samburu"
}, {
"isoCode": "saq-KE",
"title": "Samburu (Kenya)"
}, {
"isoCode": "sbp",
"title": "Sangu"
}, {
"isoCode": "sbp-TZ",
"title": "Sangu (Tanzania)"
}, {
"isoCode": "sd",
"title": "Sindhi"
}, {
"isoCode": "sd-PK",
"title": "Sindhi (Pakistan)"
}, {
"isoCode": "se",
"title": "Northern Sami"
}, {
"isoCode": "se-FI",
"title": "Northern Sami (Finland)"
}, {
"isoCode": "se-NO",
"title": "Northern Sami (Norway)"
}, {
"isoCode": "se-SE",
"title": "Northern Sami (Sweden)"
}, {
"isoCode": "seh",
"title": "Sena"
}, {
"isoCode": "seh-MZ",
"title": "Sena (Mozambique)"
}, {
"isoCode": "ses",
"title": "Koyraboro Senni"
}, {
"isoCode": "ses-ML",
"title": "Koyraboro Senni (Mali)"
}, {
"isoCode": "sg",
"title": "Sango"
}, {
"isoCode": "sg-CF",
"title": "Sango (Central African Republic)"
}, {
"isoCode": "shi",
"title": "Tachelhit"
}, {
"isoCode": "shi-Latn",
"title": "Tachelhit (Latin)"
}, {
"isoCode": "shi-Latn-MA",
"title": "Tachelhit (Latin, Morocco)"
}, {
"isoCode": "shi-Tfng",
"title": "Tachelhit (Tifinagh)"
}, {
"isoCode": "shi-Tfng-MA",
"title": "Tachelhit (Tifinagh, Morocco)"
}, {
"isoCode": "si",
"title": "Sinhala"
}, {
"isoCode": "si-LK",
"title": "Sinhala (Sri Lanka)"
}, {
"isoCode": "sk",
"title": "Slovak"
}, {
"isoCode": "sk-SK",
"title": "Slovak (Slovakia)"
}, {
"isoCode": "sl",
"title": "Slovenian"
}, {
"isoCode": "sl-SI",
"title": "Slovenian (Slovenia)"
}, {
"isoCode": "sma",
"title": "Southern Sami"
}, {
"isoCode": "sma-NO",
"title": "Southern Sami (Norway)"
}, {
"isoCode": "sma-SE",
"title": "Southern Sami (Sweden)"
}, {
"isoCode": "smj",
"title": "Lule Sami"
}, {
"isoCode": "smj-NO",
"title": "Lule Sami (Norway)"
}, {
"isoCode": "smj-SE",
"title": "Lule Sami (Sweden)"
}, {
"isoCode": "smn",
"title": "Inari Sami"
}, {
"isoCode": "smn-FI",
"title": "Inari Sami (Finland)"
}, {
"isoCode": "sms",
"title": "Skolt Sami"
}, {
"isoCode": "sms-FI",
"title": "Skolt Sami (Finland)"
}, {
"isoCode": "sn",
"title": "Shona"
}, {
"isoCode": "sn-ZW",
"title": "Shona (Zimbabwe)"
}, {
"isoCode": "so",
"title": "Somali"
}, {
"isoCode": "so-DJ",
"title": "Somali (Djibouti)"
}, {
"isoCode": "so-ET",
"title": "Somali (Ethiopia)"
}, {
"isoCode": "so-KE",
"title": "Somali (Kenya)"
}, {
"isoCode": "so-SO",
"title": "Somali (Somalia)"
}, {
"isoCode": "sq",
"title": "Albanian"
}, {
"isoCode": "sq-AL",
"title": "Albanian (Albania)"
}, {
"isoCode": "sq-MK",
"title": "Albanian (North Macedonia)"
}, {
"isoCode": "sq-XK",
"title": "Albanian (Kosovo)"
}, {
"isoCode": "sr",
"title": "Serbian"
}, {
"isoCode": "sr-Cyrl",
"title": "Serbian (Cyrillic)"
}, {
"isoCode": "sr-Cyrl-BA",
"title": "Serbian (Cyrillic, Bosnia & Herzegovina)"
}, {
"isoCode": "sr-Cyrl-ME",
"title": "Serbian (Cyrillic, Montenegro)"
}, {
"isoCode": "sr-Cyrl-RS",
"title": "Serbian (Cyrillic, Serbia)"
}, {
"isoCode": "sr-Cyrl-XK",
"title": "Serbian (Cyrillic, Kosovo)"
}, {
"isoCode": "sr-Latn",
"title": "Serbian (Latin)"
}, {
"isoCode": "sr-Latn-BA",
"title": "Serbian (Latin, Bosnia & Herzegovina)"
}, {
"isoCode": "sr-Latn-ME",
"title": "Serbian (Latin, Montenegro)"
}, {
"isoCode": "sr-Latn-RS",
"title": "Serbian (Latin, Serbia)"
}, {
"isoCode": "sr-Latn-XK",
"title": "Serbian (Latin, Kosovo)"
}, {
"isoCode": "ss",
"title": "siSwati"
}, {
"isoCode": "ss-SZ",
"title": "siSwati (Eswatini)"
}, {
"isoCode": "ss-ZA",
"title": "siSwati (South Africa)"
}, {
"isoCode": "ssy",
"title": "Saho"
}, {
"isoCode": "ssy-ER",
"title": "Saho (Eritrea)"
}, {
"isoCode": "st",
"title": "Sesotho"
}, {
"isoCode": "st-LS",
"title": "Sesotho (Lesotho)"
}, {
"isoCode": "st-ZA",
"title": "Sesotho (South Africa)"
}, {
"isoCode": "sv",
"title": "Swedish"
}, {
"isoCode": "sv-AX",
"title": "Swedish (Åland Islands)"
}, {
"isoCode": "sv-FI",
"title": "Swedish (Finland)"
}, {
"isoCode": "sv-SE",
"title": "Swedish (Sweden)"
}, {
"isoCode": "sw",
"title": "Kiswahili"
}, {
"isoCode": "sw-CD",
"title": "Kiswahili (Congo [DRC])"
}, {
"isoCode": "sw-KE",
"title": "Kiswahili (Kenya)"
}, {
"isoCode": "sw-TZ",
"title": "Kiswahili (Tanzania)"
}, {
"isoCode": "sw-UG",
"title": "Kiswahili (Uganda)"
}, {
"isoCode": "syr",
"title": "Syriac"
}, {
"isoCode": "syr-SY",
"title": "Syriac (Syria)"
}, {
"isoCode": "ta",
"title": "Tamil"
}, {
"isoCode": "ta-IN",
"title": "Tamil (India)"
}, {
"isoCode": "ta-LK",
"title": "Tamil (Sri Lanka)"
}, {
"isoCode": "ta-MY",
"title": "Tamil (Malaysia)"
}, {
"isoCode": "ta-SG",
"title": "Tamil (Singapore)"
}, {
"isoCode": "te",
"title": "Telugu"
}, {
"isoCode": "te-IN",
"title": "Telugu (India)"
}, {
"isoCode": "teo",
"title": "Teso"
}, {
"isoCode": "teo-KE",
"title": "Teso (Kenya)"
}, {
"isoCode": "teo-UG",
"title": "Teso (Uganda)"
}, {
"isoCode": "tg",
"title": "Tajik"
}, {
"isoCode": "tg-TJ",
"title": "Tajik (Tajikistan)"
}, {
"isoCode": "th",
"title": "Thai"
}, {
"isoCode": "th-TH",
"title": "Thai (Thailand)"
}, {
"isoCode": "ti",
"title": "Tigrinya"
}, {
"isoCode": "ti-ER",
"title": "Tigrinya (Eritrea)"
}, {
"isoCode": "ti-ET",
"title": "Tigrinya (Ethiopia)"
}, {
"isoCode": "tig",
"title": "Tigre"
}, {
"isoCode": "tig-ER",
"title": "Tigre (Eritrea)"
}, {
"isoCode": "tk",
"title": "Turkmen"
}, {
"isoCode": "tk-TM",
"title": "Turkmen (Turkmenistan)"
}, {
"isoCode": "tn",
"title": "Setswana"
}, {
"isoCode": "tn-BW",
"title": "Setswana (Botswana)"
}, {
"isoCode": "tn-ZA",
"title": "Setswana (South Africa)"
}, {
"isoCode": "to",
"title": "Tongan"
}, {
"isoCode": "to-TO",
"title": "Tongan (Tonga)"
}, {
"isoCode": "tr",
"title": "Turkish"
}, {
"isoCode": "tr-CY",
"title": "Turkish (Cyprus)"
}, {
"isoCode": "tr-TR",
"title": "Turkish (Turkey)"
}, {
"isoCode": "ts",
"title": "Xitsonga"
}, {
"isoCode": "ts-ZA",
"title": "Xitsonga (South Africa)"
}, {
"isoCode": "tt",
"title": "Tatar"
}, {
"isoCode": "tt-RU",
"title": "Tatar (Russia)"
}, {
"isoCode": "twq",
"title": "Tasawaq"
}, {
"isoCode": "twq-NE",
"title": "Tasawaq (Niger)"
}, {
"isoCode": "tzm",
"title": "Central Atlas Tamazight"
}, {
"isoCode": "tzm-MA",
"title": "Central Atlas Tamazight (Morocco)"
}, {
"isoCode": "ug",
"title": "Uyghur"
}, {
"isoCode": "ug-CN",
"title": "Uyghur (China)"
}, {
"isoCode": "uk",
"title": "Ukrainian"
}, {
"isoCode": "uk-UA",
"title": "Ukrainian (Ukraine)"
}, {
"isoCode": "ur",
"title": "Urdu"
}, {
"isoCode": "ur-IN",
"title": "Urdu (India)"
}, {
"isoCode": "ur-PK",
"title": "Urdu (Pakistan)"
}, {
"isoCode": "uz",
"title": "Uzbek"
}, {
"isoCode": "uz-Arab",
"title": "Uzbek (Arabic)"
}, {
"isoCode": "uz-Arab-AF",
"title": "Uzbek (Arabic, Afghanistan)"
}, {
"isoCode": "uz-Cyrl",
"title": "Uzbek (Cyrillic)"
}, {
"isoCode": "uz-Cyrl-UZ",
"title": "Uzbek (Cyrillic, Uzbekistan)"
}, {
"isoCode": "uz-Latn",
"title": "Uzbek (Latin)"
}, {
"isoCode": "uz-Latn-UZ",
"title": "Uzbek (Latin, Uzbekistan)"
}, {
"isoCode": "vai",
"title": "Vai"
}, {
"isoCode": "vai-Latn",
"title": "Vai (Latin)"
}, {
"isoCode": "vai-Latn-LR",
"title": "Vai (Latin, Liberia)"
}, {
"isoCode": "vai-Vaii",
"title": "Vai (Vai)"
}, {
"isoCode": "vai-Vaii-LR",
"title": "Vai (Vai, Liberia)"
}, {
"isoCode": "ve",
"title": "Venda"
}, {
"isoCode": "ve-ZA",
"title": "Venda (South Africa)"
}, {
"isoCode": "vi",
"title": "Vietnamese"
}, {
"isoCode": "vi-VN",
"title": "Vietnamese (Vietnam)"
}, {
"isoCode": "vo",
"title": "Volapük"
}, {
"isoCode": "vo-001",
"title": "Volapük (World)"
}, {
"isoCode": "vun",
"title": "Vunjo"
}, {
"isoCode": "vun-TZ",
"title": "Vunjo (Tanzania)"
}, {
"isoCode": "wae",
"title": "Walser"
}, {
"isoCode": "wae-CH",
"title": "Walser (Switzerland)"
}, {
"isoCode": "wal",
"title": "Wolaytta"
}, {
"isoCode": "wal-ET",
"title": "Wolaytta (Ethiopia)"
}, {
"isoCode": "wo",
"title": "Wolof"
}, {
"isoCode": "wo-SN",
"title": "Wolof (Senegal)"
}, {
"isoCode": "xh",
"title": "isiXhosa"
}, {
"isoCode": "xh-ZA",
"title": "isiXhosa (South Africa)"
}, {
"isoCode": "xog",
"title": "Soga"
}, {
"isoCode": "xog-UG",
"title": "Soga (Uganda)"
}, {
"isoCode": "yav",
"title": "Yangben"
}, {
"isoCode": "yav-CM",
"title": "Yangben (Cameroon)"
}, {
"isoCode": "yi",
"title": "Yiddish"
}, {
"isoCode": "yi-001",
"title": "Yiddish (World)"
}, {
"isoCode": "yo",
"title": "Yoruba"
}, {
"isoCode": "yo-BJ",
"title": "Yoruba (Benin)"
}, {
"isoCode": "yo-NG",
"title": "Yoruba (Nigeria)"
}, {
"isoCode": "zgh",
"title": "Standard Moroccan Tamazight"
}, {
"isoCode": "zgh-MA",
"title": "Standard Moroccan Tamazight (Morocco)"
}, {
"isoCode": "zh",
"title": "Chinese"
}, {
"isoCode": "zh-Hans",
"title": "Chinese (Simplified)"
}, {
"isoCode": "zh-Hans-CN",
"title": "Chinese (Simplified, China)"
}, {
"isoCode": "zh-Hans-HK",
"title": "Chinese (Simplified, Hong Kong SAR)"
}, {
"isoCode": "zh-Hans-MO",
"title": "Chinese (Simplified, Macao SAR)"
}, {
"isoCode": "zh-Hans-SG",
"title": "Chinese (Simplified, Singapore)"
}, {
"isoCode": "zh-Hant",
"title": "Chinese (Traditional)"
}, {
"isoCode": "zh-Hant-HK",
"title": "Chinese (Traditional, Hong Kong SAR)"
}, {
"isoCode": "zh-Hant-MO",
"title": "Chinese (Traditional, Macao SAR)"
}, {
"isoCode": "zh-Hant-TW",
"title": "Chinese (Traditional, Taiwan)"
}, {
"isoCode": "zu",
"title": "isiZulu"
}, {
"isoCode": "zu-ZA",
"title": "isiZulu (South Africa)"
}]
================================================
FILE: MangaManager/src/Common/LoadedComicInfo/ArchiveFile.py
================================================
import os
import zipfile
import rarfile
class ArchiveFile:
"""
A class that provides a unified interface to read and write archive files.
It automatically chooses between ZipFile and RarFile based on the
file extension.
"""
is_cbr = False
def __init__(self, filename, mode='r', password=None):
self.filename = filename
self.mode = mode
self.password = password
self.archive = None
ext = os.path.splitext(filename)[1].lower()
if ext in ('.cbz', '.zip'):
self.archive = zipfile.ZipFile(filename, mode)
elif ext in ('.cbr', '.rar'):
self.is_cbr = True
self.archive = rarfile.RarFile(filename, mode)
if password:
self.archive.setpassword(password)
else:
raise ValueError('Unsupported file type: %s' % ext)
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
if self.archive is not None:
self.archive.close()
def namelist(self):
return self.archive.namelist()
def infolist(self):
return self.archive.infolist()
def getinfo(self, name):
return self.archive.getinfo(name)
def read(self, name):
return self.archive.read(name)
def open(self, name):
# if self.is_cbr:
# return self.archive.read(name)
return self.archive.open(name)
def extract(self, member, path=None, password=None):
if password:
self.archive.setpassword(password)
self.archive.extract(member, path)
def extractall(self, path=None, members=None, password=None):
if password:
self.archive.setpassword(password)
self.archive.extractall(path, members)
================================================
FILE: MangaManager/src/Common/LoadedComicInfo/CoverActions.py
================================================
import enum
class CoverActions(enum.Enum):
RESET = 0 # Cancel current selected action
REPLACE = 1
DELETE = 2
APPEND = 3
================================================
FILE: MangaManager/src/Common/LoadedComicInfo/ILoadedComicInfo.py
================================================
class ILoadedComicInfo:
"""
Helper class that loads the info that is required by the tools
file_path : str
Path of the file
cinfo_object : ComicInfo
The class where the metadata is stored
cover_filename : str
The filename of the image that gets parsed as series cover
has_metadata : bool
If false, we only need to append metadata.
No need to back up ComicInfo.xml because it doesn't exist
volume : int
The volume from the metadata. If not set then it tries to parse from filename
chapter : str
The volume from the metadata. If not set then it tries to parse from filename
"""
file_path: str
file_name: str
has_metadata: bool = False
is_cinfo_at_root: bool = False
has_changes = False
changed_tags = []
================================================
FILE: MangaManager/src/Common/LoadedComicInfo/LoadedComicInfo.py
================================================
from __future__ import annotations
import copy
import io
import logging
import os
import tempfile
import zipfile
from typing import IO
from common.models import ComicInfo
from src.Common.errors import BadZipFile
from src.Common.utils import IS_IMAGE_PATTERN, get_new_webp_name, convert_to_webp
from .ArchiveFile import ArchiveFile
from .CoverActions import CoverActions
from .ILoadedComicInfo import ILoadedComicInfo
from .LoadedFileCoverData import LoadedFileCoverData
from .LoadedFileMetadata import LoadedFileMetadata
from ...Settings import Settings, SettingHeading
logger = logging.getLogger("LoadedCInfo")
COMICINFO_FILE = 'ComicInfo.xml'
COMICINFO_FILE_BACKUP = 'Old_ComicInfo.xml.bak'
COVER_NAME = "!0000_Cover"
BACKCOVER_NAME = "9999_Back"
_LOG_TAG_WEBP = "Convert Webp"
_LOG_TAG_WRITE_META = 'Write Meta'
_LOG_TAG_RECOMPRESSING = "Recompressing"
move_to_value = ""
class LoadedComicInfo(LoadedFileMetadata, LoadedFileCoverData, ILoadedComicInfo):
@property
def _logging_extra(self):
return {"processed_filename": self.file_name}
def __init__(self, path, comicinfo: ComicInfo = None, load_default_metadata=True):
"""
:param path:
:param comicinfo: The data class to be applied
:raises BadZipFile: The file can't be read or is not a valid zip file
"""
self.file_path = path or None
self.file_name = None if path is None else os.path.basename(path)
logger.debug(f"[{'Loading File':13s}] '{self.file_name}'")
self.cinfo_object = comicinfo
if load_default_metadata:
self.load_metadata()
def get_template_values(self) -> dict:
"""
Returns a dict with predefined values to fill in string templates
:return:
"""
return {
"filename": self.file_name,
"series": self.cinfo_object.series or "",
"series_sort": self.cinfo_object.series_sort or "",
"title": self.cinfo_object.title or "",
"chapter": self.cinfo_object.number or "",
"volume": self.cinfo_object.volume or "",
"publisher": self.cinfo_object.publisher or ""
}
def get_template_filename(self, input_template: str) -> str|None:
"""
Fills the provided template with the available values in the comicinfo
:param input_template: A string representing the input template ("{series} - {chapter}")
:return: None if there's a missing key in the template
"""
try:
return input_template.format_map(self.get_template_values()).replace(" ", " ")
except KeyError as e:
logger.error(f"Could not get {list(e.args)} keys when filling template values")
return None
###############################
# LOADING METHODS
###############################
def load_all(self):
try:
# Fixme: skip folders
# Update: 05-01-23 At this point i don't remember why the fix me. I'm leaving it there.
self.load_cover_info()
with ArchiveFile(self.file_path, 'r') as self.archive:
if not self.cinfo_object:
self._load_metadata()
except zipfile.BadZipFile:
logger.error(f"[{'Loading File':13s}] Failed to read file. File is not a zip file or is broken.",
exc_info=False)
raise BadZipFile()
return self
def load_metadata(self):
try:
with ArchiveFile(self.file_path, 'r') as self.archive:
if not self.cinfo_object:
self._load_metadata()
except zipfile.BadZipFile:
logger.error(f"[{'Loading File':13s}] Failed to read file. File is not a zip file or is broken.",
exc_info=False)
raise BadZipFile()
return self
###############################
# PROCESSING METHODS
###############################
# INTERFACE METHODS
def write_metadata(self, auto_unmark_changes=False):
# print(self.cinfo_object.__dict__)
self.has_changes = self.cinfo_object.has_changes(self.original_cinfo_object)
logger.debug(f"[{'BEGIN WRITE':13s}] Writing metadata to file '{self.file_path}'")
try:
self._process(write_metadata=self.has_changes)
finally:
if auto_unmark_changes:
self.has_changes = False
def convert_to_webp(self):
logger.debug(f"[{'BEGIN CONVERT':13s}] Converting to webp: '{self.file_path}'")
self._process(do_convert_to_webp=True)
def _export_metadata(self) -> str:
return str(self.cinfo_object.to_xml())
# ACTUAL LOGIC
def _process(self, write_metadata=False, do_convert_to_webp=False, **_):
logger.info(f"[{'PROCESSING':13s}] Processing file '{self.file_path}'")
if write_metadata and not do_convert_to_webp and not self.has_metadata:
with zipfile.ZipFile(self.file_path, mode='a', compression=zipfile.ZIP_STORED) as zf:
# We finally append our new ComicInfo file
zf.writestr(COMICINFO_FILE, self._export_metadata())
logger.debug(f"[{_LOG_TAG_WRITE_META:13s}] New ComicInfo.xml appended to the file",
extra=self._logging_extra)
self.has_metadata = True
# Creates a tempfile in the directory the original file is at
tmpfd, tmpname = tempfile.mkstemp(dir=os.path.dirname(self.file_path))
os.close(tmpfd)
has_cover_action = self.cover_action not in (CoverActions.RESET, None) or self.backcover_action not in (
CoverActions.RESET, None)
original_size = os.path.getsize(self.file_path)
with ArchiveFile(self.file_path, 'r') as zin:
initial_file_count = len(zin.namelist())
for s in zin.infolist():
if s.file_size != 0:
orig_comp_type = s.compress_type
break
with zipfile.ZipFile(tmpname, "w",compression=orig_comp_type) as zout: # The temp file where changes will be saved to
self._recompress(zin, zout, write_metadata=write_metadata, do_convert_webp=do_convert_to_webp)
newfile_size = os.path.getsize(tmpname)
# If the new file is smaller than the original file, we process again with no webp conversion.
# Some source files have better png compression than webp
if original_size < newfile_size and do_convert_to_webp:
logger.warning(f"[{'Processing':13s}] New converted file is bigger than original file",
extra=self._logging_extra)
os.remove(tmpname)
if not has_cover_action and not write_metadata:
logger.warning(f"[{'Processing':13s}] ⤷ Keeping original files. No additional processing left")
return
logger.warning(f"[{'Processing':13s}] ⤷ Cover action or new metadata detected. Processing new covers without converting source to webp")
with zipfile.ZipFile(tmpname, "w") as zout: # The temp file where changes will be saved to
self._recompress(zin, zout, write_metadata=write_metadata, do_convert_webp=False)
# Reset cover flags
self.cover_action = CoverActions.RESET
self.backcover_action = CoverActions.RESET
logger.debug(f"[{'Processing':13s}] Data from old file copied to new file",
extra=self._logging_extra)
# Delete old file and rename new file to old name
try:
with ArchiveFile(self.file_path, 'r') as zin:
assert initial_file_count == len(zin.namelist())
os.remove(self.file_path)
os.rename(tmpname, self.file_path)
logger.debug(f"[{'Processing':13s}] Successfully deleted old file and named tempfile as the old file",
extra=self._logging_extra)
# If we fail to delete original file we delete temp file effecively aborting the metadata update
except PermissionError:
logger.exception(f"[{'Processing':13s}] Permission error. Aborting and clearing temp files",
extra=self._logging_extra)
os.remove(
tmpname) # Could be moved to a 'finally'? Don't want to risk it not clearing temp files properly
raise
except FileNotFoundError:
try:
logger.exception(f"[{'Processing':13s}] File not found. Aborting and clearing temp files",
extra=self._logging_extra)
os.remove(tmpname)
except FileNotFoundError:
pass
except Exception:
logger.exception(f"[{'Processing':13s}] Unhandled exception. Create an issue so this gets investigated."
f" Aborting and clearing temp files",
extra=self._logging_extra)
os.remove(tmpname)
raise
self.original_cinfo_object = copy.copy(self.cinfo_object)
logger.info(f"[{'Processing':13s}] Successfully recompressed file",
extra=self._logging_extra)
if (self.cover_cache or self.backcover_cache) and has_cover_action:
logger.info("[{'Processing':13s}] Updating covers")
self.load_cover_info()
def _recompress(self, zin, zout, write_metadata, do_convert_webp):
"""
Given 2 input and output zipfiles copy content of one zipfile to the new one.
Files that matches certain criteria gets skipped and not copied over, hence deleted.
:param zin: The zipfile object of the zip that's going to be read
:param zout: The ZipFile object of the new zip to copy stuff to
:param write_metadata: Should update metadata
:param do_convert_webp: Should convert images before adding to new zipfile
:return:
"""
is_metadata_backed = False
# Write the new metadata once
if write_metadata:
zout.writestr(COMICINFO_FILE, self._export_metadata())
logger.debug(f"[{_LOG_TAG_WRITE_META:13s}] New ComicInfo.xml appended to the file")
# Directly backup the metadata if it's at root.
if self.is_cinfo_at_root:
if Settings().get(SettingHeading.Main, "create_backup_comicinfo") == 'True' and self.had_metadata_on_open:
zout.writestr(f"Old_{COMICINFO_FILE}.bak", zin.read(COMICINFO_FILE))
logger.debug(f"[{_LOG_TAG_WRITE_META:13s}] Backup for comicinfo.xml created")
is_metadata_backed = True
self.has_metadata = True
# Append the cover if the action is append
if self.cover_action == CoverActions.APPEND:
self._append_image(zout, self.new_cover_path, False, do_convert_webp,
current_backcover_filename=self.backcover_filename)
if self.backcover_action == CoverActions.APPEND:
self._append_image(zout, self.new_backcover_path, True, do_convert_webp,
current_backcover_filename=self.backcover_filename)
# Start iterating files.
total = len(zin.namelist())
for i, item in enumerate(zin.infolist()):
counter = f"{i}/{total}"
if write_metadata:
# Discard old backup
if item.filename.endswith(
COMICINFO_FILE_BACKUP): # Skip file, effectively deleting old backup
logger.debug(f"[{_LOG_TAG_WRITE_META:13s}] Skipped old backup file")
continue
if item.filename.endswith(COMICINFO_FILE):
# A root-level comicinfo was backed up already. This one is likely not where it should
if is_metadata_backed:
logger.info(f"[{_LOG_TAG_WRITE_META:13s}] Skipping non compliant ComicInfo.xml")
continue
# If filename is comicinfo save as old_comicinfo.xml
if Settings().get(SettingHeading.Main, "create_backup_comicinfo") == 'True' and self.had_metadata_on_open:
zout.writestr(f"Old_{item.filename}.bak", zin.read(item.filename))
logger.debug(f"[{_LOG_TAG_WRITE_META:13s}] Backup for comicinfo.xml created")
# Stop accepting more comicinfo files.
is_metadata_backed = True
continue
# Handle Cover Changes:
if item.filename == self.cover_filename:
match self.cover_action:
case None:
self._move_image(zin, zout=zout, item_=item, do_convert_to_webp=do_convert_webp)
case CoverActions.DELETE:
logger.trace(
f"[{_LOG_TAG_RECOMPRESSING:13}] Skipping cover to effectively delete it. File: '{item.filename}'")
case CoverActions.REPLACE:
with open(self.new_cover_path, "rb") as opened_image:
opened_image_io = io.BytesIO(opened_image.read())
self._move_image(zin, zout=zout, item_=item, do_convert_to_webp=do_convert_webp,
image=opened_image_io)
case _:
self._move_image(zin, zout=zout, item_=item, do_convert_to_webp=do_convert_webp)
continue
# Handle BackCover Change
elif item.filename == self.backcover_filename:
match self.backcover_action:
case None:
self._move_image(zin, zout=zout, item_=item, do_convert_to_webp=do_convert_webp)
case CoverActions.DELETE:
logger.trace(
f"[{_LOG_TAG_RECOMPRESSING:13}] Skipping back cover to efectively delete it. File: '{item.filename}'")
case CoverActions.REPLACE:
with open(self.new_backcover_path, "rb") as opened_image:
opened_image_io = io.BytesIO(opened_image.read())
self._move_image(zin, zout=zout, item_=item, do_convert_to_webp=do_convert_webp,
image=opened_image_io)
case _:
self._move_image(zin, zout=zout, item_=item, do_convert_to_webp=do_convert_webp)
continue
# Copy the rest of the images as they are
self._move_image(zin, zout=zout, item_=item, do_convert_to_webp=do_convert_webp)
# Recompressing methods
@staticmethod
def _move_image(zin: zipfile.ZipFile, zout: zipfile.ZipFile, item_: zipfile.ZipInfo,
do_convert_to_webp: bool,
new_filename=None, image: IO[bytes] = None):
"""
Given an input and output ZipFile copy the passed item to the new zipfile. Also converts image to webp if set to true
:param zin: The input zipfile object
:param zout: The output zipfile where the bytes will be copied over
:param item_: The zipfile 'item' object
:param do_convert_to_webp: Should the bytes be converted to webp formate
:param new_filename: If a new filename is desired this should be set. Else it will use original filename
:param image: Bytes of the image if the data wants to be overwritten
:return:
"""
# Convert to webp if option enabled and file is image
if do_convert_to_webp and IS_IMAGE_PATTERN.match(item_.filename):
with zin.open(item_) as opened_image:
new_filename = get_new_webp_name(new_filename if new_filename is not None else item_.filename)
zout.writestr(new_filename, convert_to_webp(opened_image if image is None else image))
logger.trace(f"[{_LOG_TAG_RECOMPRESSING:13s}] Adding converted file '{new_filename}' to new tempfile"
f" back to the new tempfile")
# Keep the rest of the files.
else:
zout.writestr(item_.filename if new_filename is None else new_filename,
zin.read(item_.filename) if image is None else image.read())
logger.trace(f"[{_LOG_TAG_RECOMPRESSING:13s}] Adding '{item_.filename}' back to the new tempfile")
@staticmethod
def _append_image(zout, cover_path, is_backcover=False, do_convert_to_webp=False, current_backcover_filename=''):
"""
Given a zipfile object, append (Add image and make it be the first one when natural sorting. Make it last if is_backcover is true) the image in the provided path
:param zout: The zipfile object where the image is going to be added to
:param cover_path: The path to the image file
:param is_backcover: Whether we are "appending" a cover or backcover
:param do_convert_to_webp: Whether the provided image should be converted to webp
:return:
"""
file_name, ext = os.path.splitext(os.path.basename(cover_path))
new_filename = f"{os.path.join(os.path.dirname(current_backcover_filename), '~') if is_backcover else ''}{BACKCOVER_NAME if is_backcover else COVER_NAME}{ext}"
logger.trace(
f"[{_LOG_TAG_RECOMPRESSING:13}] Apending cover to efectively delete it. Loading '{cover_path}'")
if do_convert_to_webp:
with open(cover_path, "rb") as opened_image:
opened_image_io = io.BytesIO(opened_image.read())
new_filename = get_new_webp_name(new_filename)
zout.writestr(new_filename, convert_to_webp(opened_image_io))
logger.trace(
f"[{_LOG_TAG_RECOMPRESSING:13s}] Adding converted file '{new_filename}' to new tempfile")
else:
zout.write(cover_path, new_filename)
logger.trace(
f"[{_LOG_TAG_RECOMPRESSING:13s}] Adding file '{new_filename}' to new tempfile")
================================================
FILE: MangaManager/src/Common/LoadedComicInfo/LoadedFileCoverData.py
================================================
from __future__ import annotations
import io
import logging
import zipfile
from typing import IO
from PIL import ImageTk, Image
from src.Common.errors import BadZipFile
from src.Common.utils import obtain_cover_filename
from .ArchiveFile import ArchiveFile
from .CoverActions import CoverActions
from .ILoadedComicInfo import ILoadedComicInfo
from ...Settings import Settings, SettingHeading
logger = logging.getLogger("LoadedCInfo")
COMICINFO_FILE = 'ComicInfo.xml'
COVER_NAME = "!0000_Cover"
BACKCOVER_NAME = "9999_Back"
_LOG_TAG_WEBP = "Convert Webp"
_LOG_TAG_WRITE_META = 'Write Meta'
_LOG_TAG_RECOMPRESSING = "Recompressing"
move_to_value = ""
class LoadedFileCoverData(ILoadedComicInfo):
cover_filename: str | None = None
cover_cache: ImageTk.PhotoImage = None
backcover_filename: str | None = None
backcover_cache: ImageTk.PhotoImage = None
_cover_action: CoverActions | None = None
# Path to the new cover selected by the user
_new_cover_path: str | None = None
new_cover_cache: ImageTk.PhotoImage | None = None
# Path to the new backcover selected by the user
_backcover_action: CoverActions | None = None
_new_backcover_path: str | None = None
new_backcover_cache: ImageTk.PhotoImage | None = None
def get_cover_cache(self, is_backcover=False) -> ImageTk.PhotoImage | None:
if self._cover_action is None:
return self.backcover_cache if is_backcover else self.cover_cache
else:
return self.new_backcover_cache if is_backcover else self.new_cover_cache
@property
def cover_action(self):
return self._cover_action
@cover_action.setter
def cover_action(self, value: CoverActions):
if value == CoverActions.RESET:
self._new_cover_path = None
self.new_cover_cache = None
self._cover_action = None
else:
self._cover_action = value
self.has_changes = True
@property
def backcover_action(self):
return self._backcover_action
@backcover_action.setter
def backcover_action(self, value: CoverActions):
if value == CoverActions.RESET:
self._new_backcover_path = None
self.new_backcover_cache = None
self._backcover_action = None
else:
self._backcover_action = value
self.has_changes = True
@property
def new_cover_path(self):
return self._new_cover_path
@new_cover_path.setter
def new_cover_path(self, path):
if path is None:
self._new_cover_path = None
self.new_cover_cache = None
return
image = Image.open(path)
image = image.resize((190, 260), Image.NEAREST)
try:
self.new_cover_cache = ImageTk.PhotoImage(image)
except RuntimeError:
self.new_cover_cache = None
self._new_cover_path = path
@property
def new_backcover_path(self):
return self._new_backcover_path
@new_backcover_path.setter
def new_backcover_path(self, path):
if path is None:
self._new_backcover_path = None
self.new_backcover_cache = None
return
image = Image.open(path)
image = image.resize((190, 260), Image.NEAREST)
try:
self.new_backcover_cache = ImageTk.PhotoImage(image)
except RuntimeError:
self.new_cover_cache = None
self._new_backcover_path = path
def load_cover_info(self, load_images=True):
try:
with ArchiveFile(self.file_path,'r') as self.archive:
cover_info = obtain_cover_filename(self.archive.namelist())
if not cover_info:
return
self.cover_filename, self.backcover_filename = cover_info
if not self.cover_filename:
logger.warning(f"[{'CoverParsing':13s}] Couldn't parse any cover")
else:
logger.info(f"[{'CoverParsing':13s}] Cover parsed as '{self.cover_filename}'")
if bool(Settings().get(SettingHeading.Main, 'cache_cover_images')):
self.get_cover_image_bytes()
if not self.backcover_filename:
logger.warning(f"[{'CoverParsing':13s}] Couldn't parse any back cover")
else:
logger.info(f"[{'CoverParsing':13s}] Back Cover parsed as '{self.backcover_filename}'")
if load_images:
self.get_cover_image_bytes(back_cover=True)
except zipfile.BadZipFile:
logger.error(f"[{'Loading File':13s}] Failed to read file. File is not a zip file or is broken.",
exc_info=False)
raise BadZipFile()
except Exception:
logger.exception(f"Unhandled error loading cover info for file: '{self.file_name}'")
return self
def get_cover_image_bytes(self, resized=False, back_cover=False) -> IO[bytes] | None:
"""
Opens the cbz and returns the bytes for the parsed cover image
:return:
"""
if not self.file_path or not self.cover_filename:
return None
if back_cover and not self.backcover_filename:
return None
try:
with ArchiveFile(self.file_path,'r') as zin:
img_bytes = zin.open(self.cover_filename if not back_cover else self.backcover_filename)
image = Image.open(img_bytes)
image = image.resize((190, 260), Image.NEAREST)
try:
if not back_cover:
self.cover_cache = ImageTk.PhotoImage(image)
else:
self.backcover_cache = ImageTk.PhotoImage(image)
except RuntimeError as e:
print(e) # Random patch for some error when running tests
...
if resized:
return io.BytesIO(image.tobytes())
return img_bytes
except Exception:
logger.exception(f"Error getting cover bytes. BackCover = {'True' if back_cover else 'False'} File: {self.file_name}")
================================================
FILE: MangaManager/src/Common/LoadedComicInfo/LoadedFileMetadata.py
================================================
import copy
import logging
import rarfile
from lxml.etree import XMLSyntaxError
from common.models import ComicInfo
from src.Common.LoadedComicInfo.ILoadedComicInfo import ILoadedComicInfo
from src.Common.errors import MissingRarTool
logger = logging.getLogger("LoadedCInfo")
COMICINFO_FILE = 'ComicInfo.xml'
COVER_NAME = "!0000_Cover"
BACKCOVER_NAME = "9999_Back"
_LOG_TAG_WEBP = "Convert Webp"
_LOG_TAG_WRITE_META = 'Write Meta'
_LOG_TAG_RECOMPRESSING = "Recompressing"
move_to_value = ""
class LoadedFileMetadata(ILoadedComicInfo):
_cinfo_object: ComicInfo
original_cinfo_object: ComicInfo
# Used to keep original state after being loaded for the first time. Useful to undo sesion changes
original_cinfo_object_before_session: ComicInfo | None = None
had_metadata_on_open = False
@property
def cinfo_object(self):
return self._cinfo_object
@cinfo_object.setter
def cinfo_object(self, value: ComicInfo):
self._cinfo_object = value
@property
def volume(self):
if self.cinfo_object:
return self.cinfo_object.volume
@property
def chapter(self):
if self.cinfo_object:
return self.cinfo_object.number
@volume.setter
def volume(self, value):
self.cinfo_object.volume = value
@chapter.setter
def chapter(self, value):
self.cinfo_object.number = value
def _load_metadata(self):
"""
Reads the metadata from the ComicInfo.xml at root level
:raises CorruptedComicInfo If the metadata file exists but can't be parsed
:return:
"""
LOG_TAG = f"[{'Reading Meta':13s}] "
try:
# If Comicinfo is not at root try to grab any ComicInfo.xml in the file
if COMICINFO_FILE not in self.archive.namelist():
cinfo_file = [filename.endswith(COMICINFO_FILE) for filename in self.archive.namelist()][
0] or COMICINFO_FILE
else:
cinfo_file = COMICINFO_FILE
self.is_cinfo_at_root = True
xml_string = self.archive.read(cinfo_file).decode('utf-8')
self.has_metadata = True
self.had_metadata_on_open = True
except KeyError:
xml_string = ""
except rarfile.RarCannotExec:
xml_string = ""
raise MissingRarTool
except Exception:
xml_string = ""
if xml_string:
try:
self.cinfo_object = ComicInfo.from_xml(xml_string)
except XMLSyntaxError as e:
logger.warning(LOG_TAG + f"Failed to parse XML due to a syntax error:\n{e}")
except Exception:
logger.exception(f"[{'Reading Meta':13s}] Unhandled error reading metadata."
f" Please create an issue for further investigation")
raise
logger.debug(LOG_TAG + "Successful")
self.original_cinfo_object_before_session = copy.copy(self.cinfo_object)
else:
self.cinfo_object = ComicInfo()
logger.info(LOG_TAG + "No metadata file was found. A new file will be created")
self.original_cinfo_object = copy.copy(self.cinfo_object)
self.original_cinfo_object_before_session = copy.copy(self.cinfo_object)
def reset_metadata(self):
"""
Returns the metadata to the first state of loaded cinfo
"""
self.cinfo_object = self.original_cinfo_object
================================================
FILE: MangaManager/src/Common/LoadedComicInfo/__init__.py
================================================
================================================
FILE: MangaManager/src/Common/ResourceLoader.py
================================================
import os
from os.path import abspath
from pkg_resources import resource_filename
res_path = abspath(resource_filename(__name__, '../../res/'))
class ResourceLoader:
"""
ResourceLoader loads resources from res/ folder for the application
"""
@staticmethod
def get(filename):
return os.path.join(res_path, filename)
================================================
FILE: MangaManager/src/Common/__init__.py
================================================
from .ResourceLoader import ResourceLoader
================================================
FILE: MangaManager/src/Common/errors.py
================================================
class NoMetadataFileFound(Exception):
"""
Exception raised when not enough data is given to create a Metadata object.
"""
def __init__(self, cbz_path):
super().__init__(f"ComicInfo.xml not found inside '{cbz_path}'")
class MangaNotFoundError(Exception):
"""
Exception raised when the manga cannot be found in the results from the provided source.
"""
def __init__(self, source, manga_title):
super().__init__(f'{source} did not return any results for series name "{manga_title}"'
f'This may be due to a difference in manga series titles')
class EditedCinfoNotSet(RuntimeError):
def __init__(self, message=None):
super(EditedCinfoNotSet, self).__init__(message)
class CorruptedComicInfo(Exception):
"""
Exception raised when the attempt to recover comicinfo file fails..
"""
def __init__(self, cbz_path):
super().__init__(f'Failed to recover ComicInfo.xml data in {cbz_path}')
class CancelComicInfoLoad(Exception):
"""
Exception raised when the users want to stop loading comicInfo.
Triggered when an exception is found.
"""
def __init__(self):
super().__init__(f'Loading cancelled')
class CancelComicInfoSave(Exception):
"""
Exception raised when the users cancel parsing.
Triggered when the user wants to cancel.
"""
def __init__(self):
super().__init__(f'Saving cancelled')
class NoFilesSelected(Exception):
"""
Exception raised when a method that requires selected files is called and no selected files.
"""
def __init__(self):
super().__init__(f'No Files Selected')
class BadZipFile(Exception):
"""
Exception raise when the file is broken or is not a zip file
"""
def __init__(self):
super().__init__(f'File is broken or not a valid zip file')
class NoComicInfoLoaded(Exception):
"""
Exception raised when the list of LoadedComicInfo is empty.
"""
def __init__(self, info=""):
super().__init__(f'No ComicInfo Loaded' + info)
class NoModifiedCinfo(Exception):
"""
Exception raised when a processing is attempted but there are no loaded_cinfo with it's comicinfo modified
"""
def __init__(self):
super().__init__(f'No loaded_cinfo to process')
class FailedBackup(RuntimeError):
"""
Exception raised when a file fails to create a backup
"""
def __init__(self):
super(FailedBackup, self).__init__()
class MissingRarTool(Exception):
"""Exception raised when there is no installed tool"""
def __init__(self):
super(MissingRarTool, self).__init__()
================================================
FILE: MangaManager/src/Common/naturalsorter.py
================================================
from __future__ import annotations
import pathlib
from natsort import natsort_key
def decompose_path_into_components(x):
path_split = list(pathlib.Path(x).parts)
# Remove the final filename component from the path.
final_component = pathlib.Path(path_split.pop())
# Split off all the extensions.
suffixes = final_component.suffixes
stem = final_component.name.replace(''.join(suffixes), '')
# Remove the '.' prefix of each extension, and make that
# final component a list of the stem and each suffix.
final_component = [stem] + [x[1:] for x in suffixes]
# Replace the split final filename component.
path_split.extend(final_component)
return path_split
def natsort_key_with_path_support(x):
return tuple(natsort_key(s) for s in decompose_path_into_components(x))
================================================
FILE: MangaManager/src/Common/parser.py
================================================
import re
"""
Regex Patterns adapted from Kavita: https://github.com/Kareadita/Kavita
"""
Number = "\d+(\.\d)?"
NumberRange = Number + "(-" + Number + ")?"
volume_patterns = [
# Dance in the Vampire Bund v16-17
re.compile(r"(?P.*)(\b|_|\s)v(?P\d+-?\d+)", re.IGNORECASE),
# NEEDLESS_Vol.4_-Simeon_6_v2[SugoiSugoi].rar
re.compile(r"(?P.*)(\b|_|\s)(?!\[)(vol\.?)(?P\d+(-\d+)?)(?!\])", re.IGNORECASE),
# Historys Strongest Disciple Kenichi_v11_c90-98.zip or Dance in the Vampire Bund v16-17
re.compile(r"(?P.*)(\b|_|\s)(?!\[)v(?P" + NumberRange + ")(?!\])", re.IGNORECASE),
# Kodomo no Jikan vol. 10, [dmntsf.net] One Piece - Digital Colored Comics Vol. 20.5-21.5 Ch. 177
re.compile(r"(?P.*)(\b|_|\s)(vol\.? ?)(?P\d+(\.\d)?(-\d+)?(\.\d)?)", re.IGNORECASE),
# Killing Bites Vol. 0001 Ch. 0001 - Galactica Scanlations (gb)
re.compile(r"(vol\.? ?)(?P\d+(\.\d)?)", re.IGNORECASE),
# Tonikaku Cawaii [Volume 11].cbz
re.compile(r"(volume )(?P\d+(\.\d)?)", re.IGNORECASE),
# Tower Of God S01 014 (CBT) (digital).cbz
re.compile(r"(?P.*)(\b|_||\s)(S(?P\d+))", re.IGNORECASE),
# vol_001-1.cbz for MangaPy default naming convention
re.compile(r"(vol_)(?P\d+(\.\d)?)", re.IGNORECASE)
]
series_patterns = [
# Grand Blue Dreaming - SP02
re.compile(r'(?P.*)(\b|_|-|\s)(?:sp)\d', re.IGNORECASE),
# [SugoiSugoi]_NEEDLESS_Vol.2_-_Disk_The_Informant_5_[ENG].rar, Yuusha Ga Shinda! - Vol.tbd Chapter 27.001 V2 Infection ①.cbz
re.compile(r'^(?P.*)( |_)Vol\.?(\d+|tbd)', re.IGNORECASE),
# Mad Chimera World - Volume 005 - Chapter 026.cbz, The Duke of Death and His Black Maid - Vol. 04 Ch. 054.5 - V4 Omake
re.compile(r'(?P.+?)(\s|_|-)+(?:Vol(ume|\.)?(\s|_|-)+\d+)(\s|_|-)+(?:(Ch|Chapter|Ch)\.?)(\s|_|-)+(?P\d+)', re.IGNORECASE),
# Ichiban_Ushiro_no_Daimaou_v04_ch34_[VISCANS].zip, VanDread-v01-c01.zip
re.compile(r'(?P.*)(\b|_)v(?P\d+-?\d*)(\s|_|-)', re.IGNORECASE),
# Gokukoku no Brynhildr - c001-008 (v01) [TrinityBAKumA], Black Bullet - v4 c17 [batoto]
re.compile(r'(?P.*)( - )(?:v|vo|c|chapters)\d', re.IGNORECASE),
# Kedouin Makoto - Corpse Party Musume, Chapter 19 [Dametrans].zip
re.compile(r'(?P.*)(?:, Chapter )(?P\d+)', re.IGNORECASE),
# Please Go Home, Akutsu-San! - Chapter 038.5 - Volume Announcement.cbz, My Charms Are Wasted on Kuroiwa Medaka - Ch. 37.5 - Volume Extras
re.compile(r'(?P.+?)(\s|_|-)(?!Vol)(\s|_|-)((?:Chapter)|(?:Ch\.))(\s|_|-)(?P\d+)', re.IGNORECASE),
# [dmntsf.net] One Piece - Digital Colored Comics Vol. 20 Ch. 177 - 30 Million vs 81 Million.cbz
re.compile(r'(?P.+?):? (\b|_|-)(vol)\.?(\s|-|_)?\d+', re.IGNORECASE),
# [xPearse] Kyochuu Rettou Chapter 001 Volume 1 [English] [Manga] [Volume Scans]
re.compile(r'(?P.+?):?(\s|\b|_|-)Chapter(\s|\b|_|-)\d+(\s|\b|_|-)(vol)(ume)', re.IGNORECASE),
# [xPearse] Kyochuu Rettou Volume 1 [English] [Manga] [Volume Scans]
re.compile(r'(?P.+?):? (\b|_|-)(vol)(ume)', re.IGNORECASE),
]
chapter_patterns = [
# Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus)
re.compile(r'^(?P.+?)(?: |_)v(?P\d+)(?: |_)(c? ?)(?P(\d+(\.\d)?)-?(\d+(\.\d)?)?)(c? ?)', re.IGNORECASE),
# Batman & Robin the Teen Wonder #0
re.compile(r'^(?P.+?)(?:\s|_)#(?P\d+)', re.IGNORECASE),
# Batman 2016 - Chapter 01, Batman 2016 - Issue 01, Batman 2016 - Issue #01
re.compile(r'^(?P.+?)((c(hapter)?)|issue)(_|\s)#?(?P(\d+(\.\d)?)-?(\d+(\.\d)?)?)', re.IGNORECASE),
# Batgirl Vol.2000 #57 (December, 2004)
re.compile(r'^(?P.+?)(?:vol\.?\d+)\s#(?P\d+)', re.IGNORECASE),
# Saga 001 (2012) (Digital) (Empire-Zone)
re.compile(r'(?P.+?)(?: |_)(c? ?)(?P(\d+(\.\d)?)-?(\d+(\.\d)?)?)\s\(\d{4}', re.IGNORECASE),
# Historys Strongest Disciple Kenichi_v11_c90-98.zip, ...c90.5-100.5
re.compile(r'(\b|_)(c|ch)(\.?\s?)(?P(\d+(\.\d)?)-?(\d+(\.\d)?)?)', re.IGNORECASE),
# Green Worldz - Chapter 027, Kimi no Koto ga Daidaidaidaidaisuki na 100-nin no Kanojo Chapter 11-10
re.compile(r'^(?!Vol)(?P.*)\s?(?\d+(?:\.?[\d-]+)?)', re.IGNORECASE),
# Hinowa ga CRUSH! 018 (2019) (Digital) (LuCaZ).cbz, Hinowa ga CRUSH! 018.5 (2019) (Digital) (LuCaZ).cbz
re.compile(r'^(?!Vol)(?P.+?)(?\d+(?:\.\d+|-\d+)?)(?:\s\(\d{4}\))?(\b|_|-)', re.IGNORECASE),
# Tower Of God S01 014 (CBT) (digital).cbz
re.compile(r'(?P.*)\sS(?P\d+)\s(?P\d+(?:.\d+|-\d+)?)', re.IGNORECASE),
# Vol 1 Chapter 2
re.compile(r'(?P((vol|volume|v))?(\s|_)?\.?\d+)(\s|_)(Chp|Chapter)\.?(\s|_)?(?P\d+)', re.IGNORECASE),
]
def _parse(patterns, group, filename):
for pattern in patterns:
match = re.search(pattern, filename)
if match:
volume_number = match.group(group)
return volume_number
return ""
def parse_volume(filename: str) -> str:
"""Attempts to parse the Volume from a filename"""
return _parse(volume_patterns, "Volume", filename)
def parse_series(filename: str) -> str:
"""Attempts to parse the Series from a filename"""
return _parse(series_patterns, "Series", filename)
def parse_number(filename: str) -> str:
"""Attempts to parse the Number from a filename"""
return _parse(chapter_patterns, "Chapter", filename)
================================================
FILE: MangaManager/src/Common/progressbar.py
================================================
import abc
import logging
import time
from string import Template
from threading import Timer
from src.Common.utils import get_elapsed_time, get_estimated_time
logger = logging.getLogger()
class RepeatedTimer(object):
def __init__(self, interval = 1):
"""
:param interval:
:param total:
"""
self._timer = None
self.interval = interval
self.is_running = False
self.total = -1
# self.start()
self.update_hook: set[callable] = set()
def register_callable(self, function: callable):
"""
Registers a function that will be called in the defined interval
:param function: The callable
:return:
"""
self.update_hook.add(function)
def unregister_callable(self, function: callable):
self.update_hook.remove(function)
def _run(self):
self.is_running = False
self.start()
self._call_hooks()
def _call_hooks(self):
for function in self.update_hook:
try:
function()
except Exception:
logger.exception("Exception calling the update hook")
def start(self):
if not self.is_running:
self._timer = Timer(self.interval, self._run)
self._timer.start()
self.is_running = True
def stop(self):
if self._timer is not None:
self._timer.cancel()
self.is_running = False
self._timer = None
class ProgressBar(abc.ABC):
running = False
PROCESSED_TAG = "$processed"
TOTAL_TAG = "$total"
ERRORS_TAG = "$errors"
ELAPSED_TIME_TAG = "$elapsed_time"
ESTIMATED_TIME_TAG = "$estimated_time"
def __init__(self):
self.timer = RepeatedTimer()
self.timer.register_callable(self._update)
self.start_time = -1
self.processed = 0
self.processed_errors = 0
self.total = -1
self.PROCESSED_TAG = "$processed"
self.TOTAL_TAG = "$total"
self.ERRORS_TAG = "$errors"
self.ELAPSED_TIME_TAG = "$elapsed_time"
self.ESTIMATED_TIME_TAG = "$estimated_time"
self.template = Template(f"""Processed: {self.PROCESSED_TAG}/{self.TOTAL_TAG} files - {self.ERRORS_TAG} errors
Elapsed time : {self.ELAPSED_TIME_TAG}
Estimated time: {self.ESTIMATED_TIME_TAG}""")
def set_template(self,new_value:str):
self.template = Template(new_value)
self.update_progress_label()
@property
def label_text(self):
return self.template.safe_substitute(
processed=self.processed,
total=self.total,
errors=self.processed_errors,
elapsed_time=get_elapsed_time(self.start_time),
estimated_time=get_estimated_time(self.start_time, self.processed, self.total)
)
@property
def percentage(self):
return (self.processed / self.total) * 100
@abc.abstractmethod
def update_progress_label(self):
...
@abc.abstractmethod
def _update(self):
...
def start(self,total):
self.total = total
if self.total == -1:
raise ValueError("Configure the progressbar items first")
self.start_time = time.time()
self.processed = 0
self.processed_errors = 0
# self.timer.start()
def stop(self):
self.timer.stop()
self._update()
def increase_processed(self):
if self.processed >= self.total:
return
self.processed += 1
self._update()
def increase_failed(self):
self.processed_errors += 1
self.increase_processed()
def reset(self):
self.total = self.timer.total = -1
self.processed = 0 # Processed items. Whether they fail or not
self.processed_errors = 0 # Items that failed
self._update()
================================================
FILE: MangaManager/src/Common/terminalcolors.py
================================================
class TerminalColors:
RESET = "\x1b[0m"
BOLD = "\x1b[1m"
CURSIVE = "\x1b[3m"
UNDERLINED = "\x1b[4m"
REVERSED_COLOR = "\x1b[7" # Reversed colors
REVERSED_COLOR_NORMAL = "\x1b[27"
BLACK = "\x1b[30m"
GREY = "\x1b[30m;1"
RED = "\x1b[31m"
GREEN = "\x1b[32m"
YELLOW = "\x1b[33m"
BLUE = "\x1b[34m"
PURPLE = "\x1b[35m"
CYAN = "\x1b[36m"
WHITE = "\x1b[97m"
LIGHT_BLACK = "\x1b[90m"
LIGHT_GREY = "\x1b[37"
LIGHT_RED = "\x1b[91m"
LIGHT_GREEN = "\x1b[92m"
LIGHT_YELLOW = "\x1b[93m"
LIGHT_BLUE = "\x1b[94m"
LIGHT_PURPLE = "\x1b[95m"
LIGHT_CYAN = "\x1b[96m"
LIGHT_WHITE = "\x1b[97m"
BG_BLACK = "\x1b[4"
# BG_GREY = "\x1b[4
BG_RED = "\x1b[41m"
BG_GREEN = "\x1b[42m"
BG_YELLOW = "\x1b[43m"
BG_BLUE = "\x1b[44m"
BG_PURPLE = "\x1b[45m"
BG_CYAN = "\x1b[46m"
BG_WHITE = "\x1b[107m"
BG_LIGHT_BLACK = "\x1b[4"
BG_GREY = "\x1b[100"
BG_LIGHT_RED = "\x1b[101m"
BG_LIGHT_GREEN = "\x1b[102m"
BG_LIGHT_YELLOW = "\x1b[103m"
BG_LIGHT_BLUE = "\x1b[104m"
BG_LIGHT_PURPLE = "\x1b[105m"
BG_LIGHT_CYAN = "\x1b[106m"
BG_LIGHT_WHITE = "\x1b[107m"
if __name__ == '__main__':
for color in TerminalColors.__dict__:
if not color.startswith("_"):
print(TerminalColors.RESET + f"{color:15s}" + TerminalColors.__dict__[color] + "Addsadsadasdas")
================================================
FILE: MangaManager/src/Common/utils.py
================================================
import logging
import os
import re
import subprocess
import sys
import time
import urllib.request
from io import BytesIO
from pathlib import Path
from typing import IO
from src.Common.naturalsorter import natsort_key_with_path_support
# Patterns for picking cover
IMAGE_EXTENSIONS = ('png', 'jpg', 'jpeg', 'tiff', 'bmp', 'gif', 'webp')
covers_patterns = ['^!*0+.[a-z]+$', '.*cover.*.[a-z]+$']
COVER_PATTERN = re.compile(f"(?i)({'|'.join(covers_patterns)})")
cover_r3_alt = '^!*0+1\\.[a-z]+$'
ALT_COVER_PATTERN = re.compile(f"(?i)({'|'.join([cover_r3_alt])})")
IS_IMAGE_PATTERN = re.compile(rf"(?i).*.(?:{'|'.join(IMAGE_EXTENSIONS)})$")
logger = logging.getLogger()
from PIL import Image
try:
from anytree import Node, RenderTree
except ImportError:
logger.exception("Failed to import anytree. Some cli functionality might break. Make sure all requirements are installed")
def remove_text_inside_brackets(text, brackets="()[]"):
count = [0] * (len(brackets) // 2) # count open/close brackets
saved_chars = []
for character in text:
for i, b in enumerate(brackets):
if character == b: # found bracket
kind, is_close = divmod(i, 2)
count[kind] += (-1) ** is_close # `+1`: open, `-1`: close
if count[kind] < 0: # unbalanced bracket
count[kind] = 0 # keep it
else: # found bracket to remove
break
else: # character is not a [balanced] bracket
if not any(count): # outside brackets
saved_chars.append(character)
return (''.join(saved_chars)).strip()
import unicodedata
def normalize_filename(filename):
# Normalize the filename using the NFC form
normalized_filename = unicodedata.normalize('NFC', filename)
# Replace all non-ASCII characters with their ASCII equivalents
ascii_filename = normalized_filename.encode('ascii', 'ignore').decode('ascii')
return ascii_filename
def clean_filename(sourcestring, removestring=" %:/,.\\[]<>*?\""):
"""Clean a string by removing selected characters.
Creates a legal and 'clean' source string from a string by removing some
clutter and characters not allowed in filenames.
A default set is given but the user can override the default string.
Args:
| sourcestring (string): the string to be cleaned.
| removestring (string): remove all these characters from the string (optional).
Returns:
| (string): A cleaned-up string.
Raises:
| No exception is raised.
"""
# remove the undesireable characters
return ''.join([c for c in sourcestring if c not in removestring])
def find_chapter(text):
r = r"(?i)(?:chapter|ch)(?:\s|\.)?(?:\s|\.)?(\d+)"
match = re.findall(r, text)
if match:
return match[0]
return match
def fetch_chapter(text):
r = r"(?i)(?:chapter|ch|#)(?:\s|\.)?(?:\s|\.)?(\d+)"
return re.findall(r, text)
def fetch_volume(text):
r = r"(?i)(?:volume|vol|v)(?:\s|\.)?(?:\s|\.)?(\d+)"
return re.findall(r, text)
def obtain_cover_filename(file_list) -> (str, str):
"""
Helper function to find a cover file based on a list of filenames
:param file_list:
:return:
"""
list_image_files = [filename for filename in file_list if IS_IMAGE_PATTERN.findall(filename)]
latest_cover = sorted(list_image_files, key=natsort_key_with_path_support, reverse=True)
if latest_cover:
latest_cover = latest_cover[0]
else:
latest_cover = None
# Cover stuff
possible_covers = [filename for filename in file_list
if IS_IMAGE_PATTERN.findall(filename) and COVER_PATTERN.findall(filename)]
if possible_covers:
cover = possible_covers[0]
return cover, latest_cover
# Try to get 0001
possible_covers = [filename for filename in file_list if ALT_COVER_PATTERN.findall(filename)]
if possible_covers:
cover = possible_covers[0]
return cover, latest_cover
# Resource back to first filename available that is a cover
# list_image_files = (filename for filename in file_list if IS_IMAGE_PATTERN.findall(filename))
cover = sorted(list_image_files, key=natsort_key_with_path_support, reverse=False)
if cover:
cover = cover[0]
return cover, latest_cover
webp_supported_formats = (".png", ".jpeg", ".jpg")
def get_new_webp_name(currentName: str) -> str:
filename, file_format = os.path.splitext(currentName)
if filename.endswith("."):
filename = filename.strip(".")
return filename + ".webp"
def convert_to_webp(image_bytes_to_convert: IO[bytes]) -> bytes:
"""
Converts the provided image to webp and returns the converted image bytes
:param image_bytes_to_convert: The image that has to be converted
:return:
"""
# TODO: Bulletproof image passed not image
image = Image.open(image_bytes_to_convert).convert()
# print(image.size, image.mode, len(image.getdata()))
converted_image = BytesIO()
image.save(converted_image, format="webp")
image.close()
return converted_image.getvalue()
def get_platform():
platforms = {
'linux1': 'Linux',
'linux2': 'Linux',
'darwin': 'OS X',
'win32': 'Windows'
}
if sys.platform not in platforms:
return sys.platform
return platforms[sys.platform]
class ShowPathTreeAsDict:
"""Builds a tree like structure out of a list of paths"""
def display_tree(self) -> int:
"""
:return: Number of lines printed
"""
root = Node("Root")
self._build_tree(root, self.new_path_dict)
_counter = 0
for pre, fill, node in RenderTree(root):
print("%s%s" % (pre, node.name))
_counter+=1
return _counter
def __init__(self,paths: list, base_path = None):
if not base_path:
base_path = os.path.commonprefix(paths)
new_path_dict = {"subfolders": [],
"files": [],
"current": Path(base_path)}
self.new_path_dict = new_path_dict
for path in paths:
self._recurse(new_path_dict, Path(path).parts)
...
def _recurse(self, parent_dic: dict, breaked_subpath):
if len(breaked_subpath) == 0:
return
if len(breaked_subpath) == 1:
parent_dic["files"].append(breaked_subpath[0])
self.on_file(parent_dic, breaked_subpath[0])
return
key, *new_chain = breaked_subpath
if key == "\\":
key = "root"
if key not in parent_dic:
parent_dic[key] = {"subfolders": [], "files": [], "current": Path(parent_dic.get("current"), key)}
parent_dic["subfolders"].append(key)
self.on_subfolder(parent_dic, key)
self._recurse(parent_dic[key], new_chain)
def get(self):
return self.new_path_dict
def on_file(self, parent_dict: dict, breaked_subpath):
...
def on_subfolder(self, parent_dict: dict, subfolder):
...
def _build_tree(self, parent, data):
for key, value in data.items():
if key == "subfolders":
for subfolder in value:
subfolder_node = Node(subfolder, parent=parent)
self._build_tree(subfolder_node, data[subfolder])
elif key == "files":
for file in value:
Node(file, parent=parent)
def get_elapsed_time(start_time: float) -> str:
"""
This functions returns a string of how much time has elapsed
:param start_time: The start time (time.time())
:return: "{minutes:int} minutes and {seconds:int} seconds"
"""
if start_time == -1:
return ""
current_time = time.time()
seconds = current_time - start_time
minutes, seconds = divmod(seconds, 60)
return f"{int(round(minutes, 0))} minutes and {int(round(seconds, 0))} seconds"
def get_estimated_time(start_time: float, processed_files: int, total_files: int) -> str:
"""
This functions returns a statistic of how much time is left to finish processing. (Uses elapsed time per file)
:param start_time: The start time (time.time())
:param processed_files: Number of files that have already been processed
:param total_files: Total number of files to be processed
:return: "{minutes:int} minutes and {seconds:int} seconds"
"""
if start_time == -1:
return "0"
try:
current_time = time.time()
elapsed_time = current_time - start_time
time_per_file = elapsed_time / processed_files
estimated_time = time_per_file * (total_files - processed_files)
minutes, seconds = divmod(estimated_time, 60)
return f"{int(round(minutes, 0))} minutes and {int(round(seconds, 0))} seconds"
except ZeroDivisionError:
return f"{int(round(0, 0))} minutes and {int(round(0, 0))} seconds"
def open_folder(folder_path, selected_file: str = None):
try:
if sys.platform == 'darwin':
subprocess.check_call(['open', '--', folder_path])
elif sys.platform == 'linux2':
subprocess.check_call(['xdg-open', '--', folder_path])
elif sys.platform == 'win32':
if selected_file:
subprocess.Popen(f'explorer /select, "{os.path.abspath(selected_file)}"',shell=True)
else:
subprocess.Popen(f'explorer "{os.path.abspath(folder_path)}"',shell=True)
else:
logger.error(f"Couldn't detect platform. Can't open settings_class folder. Please navigate to {folder_path}")
return
except Exception:
logger.exception(f"Exception opening '{folder_path}' folder")
def get_language_iso_list():
with urllib.request.urlopen(
'https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry.txt') as response:
registry = response.read().decode('utf-8')
# Split the registry into lines
lines = registry.split('\n')
# Initialize a list to store the language tags
tags = []
# Iterate over the lines
for line in lines:
# Check if the line starts with 'Language:'
if line.startswith('Language:'):
# Split the line into fields
fields = line.split('\t')
# Get the language tag from the second field
tag = fields[1]
# Add the tag to the list
tags.append(tag)
# Print the list of language tags
print(tags)
def extract_folder_and_module(file_path):
file_name, ext = os.path.splitext(os.path.basename(file_path))
dir_name = os.path.basename(os.path.dirname(file_path))
return dir_name, file_name
def match_pyfiles_with_foldername(file_path):
folder, file_ = extract_folder_and_module(file_path)
return folder == file_
def parse_bool(value: str) -> bool:
if isinstance(value,bool):
return value
match value.lower():
case "true" | "1" | 1:
return True
case "false" | "0" | 0:
return False
case _:
raise ValueError(f"Invalid boolean string: {value}")
================================================
FILE: MangaManager/src/DynamicLibController/__init__.py
================================================
================================================
FILE: MangaManager/src/DynamicLibController/extension_manager.py
================================================
import glob
import importlib
import logging
import os
import sys
from pathlib import Path
from Extensions.IExtensionApp import IExtensionApp
from src import sub_mm_path
logger = logging.getLogger()
# Extension loader
def extract_folder_and_module(file_path):
file_name, ext = os.path.splitext(os.path.basename(file_path))
dir_name = os.path.basename(os.path.dirname(file_path))
return dir_name, file_name
def match_pyfiles_with_foldername(file_path):
folder, file_ = extract_folder_and_module(file_path)
return folder == file_
loaded_extensions = []
def load_extensions(extensions_directory,) -> list[IExtensionApp]:
# EXTENSIONS_DIRECTORY = extensions_directory
EXTENSIONS_DIRECTORY = Path(sub_mm_path, "Extensions")
extensions_path = os.path.expanduser(EXTENSIONS_DIRECTORY)
sys.path.append(extensions_path)
# Search for Python files in the extensions directory
extension_files = [extension for extension in
glob.glob(os.path.join(EXTENSIONS_DIRECTORY, "*/**.py"), recursive=True)
if match_pyfiles_with_foldername(extension)]
if not extension_files:
EXTENSIONS_DIRECTORY = os.path.join(os.getcwd(), "Extensions")
extension_files = [extension for extension in
glob.glob(os.path.join(os.getcwd(), "Extensions", "*/**.py"), recursive=True)
if match_pyfiles_with_foldername(extension)]
print(f"Found extensions: {extension_files}")
# Load the extensions
loaded_extensions = []
for extension_file in extension_files:
if extension_file in loaded_extensions:
continue
# Import the extension module
try:
extension_module = importlib.import_module(f"Extensions.{'.'.join(extract_folder_and_module(extension_file))}",package=EXTENSIONS_DIRECTORY)
except ModuleNotFoundError:
logger.exception(f"Failed to Import Extension: {extension_file}")
continue
except Exception:
logger.exception(f"Failed to Load extension {extension_file}")
# Get the ExtensionApp subclasses from the module
extension_classes = [
cls
for cls in extension_module.__dict__.values()
if isinstance(cls, type) and issubclass(cls, IExtensionApp) and cls != IExtensionApp
]
# Instantiate the ExtensionApp subclasses and add them to the list of extensions
loaded_extensions.extend([cls for cls in extension_classes])
return loaded_extensions
================================================
FILE: MangaManager/src/DynamicLibController/models/CoverSourceInterface.py
================================================
import abc
import dataclasses
from typing import final
class ICoverSource(abc.ABC):
name = None
@classmethod
@abc.abstractmethod
def download(cls, identifier: str):
...
@final
def __init__(self, master, super_=None, **kwargs):
if self.name is None: # Check if the "name" attribute has been set
raise ValueError(
f"Error initializing the {self.__class__.__name__} Extension."
f"The 'name' attribute must be set in the CoverSource class.")
# if self.embedded_ui:
super().__init__(master=master, **kwargs)
if super_ is not None:
self._super = super_
@dataclasses.dataclass
class Cover:
series_name: str
vol: int
alternative: int
url: str
image_bytes: bytes
================================================
FILE: MangaManager/src/DynamicLibController/models/ExtensionsInterface.py
================================================
import abc
class IMMExtension(abc.ABC):
"""
The basic interface that all extensions must implement. An extension is functionality that can be added and
dynamically loaded into Manga Manager. An extension can optionally offer settings to the user to configure.
"""
"""
A set of settings which will be found in the main settings dialog of Manga Manager and used for the source
"""
settings = []
# Version of the extension
version = '0.0.0.0'
# Name of the Extension
name = ''
def save_settings(self):
"""
When a setting update occurs, this is invoked and internal state should be updated from Settings()
"""
pass
================================================
FILE: MangaManager/src/DynamicLibController/models/IMetadataSource.py
================================================
import abc
import logging
from html.parser import HTMLParser
from io import StringIO
from typing import final
from common.models import ComicInfo
from src.Settings import Settings, SettingSection, SettingControl
from .ExtensionsInterface import IMMExtension
def _merge(value1, value2):
return IMetadataSource.trim(value1 + "," + value2)
# MLStripper: https://stackoverflow.com/a/925630
class MLStripper(HTMLParser):
def __init__(self):
super().__init__()
self.reset()
self.strict = False
self.convert_charrefs = True
self.text = StringIO()
def handle_data(self, d):
self.text.write(d)
def get_data(self):
return self.text.getvalue()
class IMetadataSource(IMMExtension):
name = ''
"""
A set of settings which will be found in the main settings dialog of Manga Manager and used for the source
"""
settings = []
logger = None
@classmethod
@abc.abstractmethod
def get_cinfo(cls, comic_info_from_ui: ComicInfo) -> ComicInfo:
...
def save_settings(self):
"""
When a setting update occurs, this is invoked and internal state should be updated from Settings()
"""
pass
@staticmethod
def trim(value):
ret = value.strip()
if ret.endswith(','):
return ret[0:-1]
return ret
@staticmethod
def update_people_from_mapping(people: list[object], mapping, comicinfo: ComicInfo, name_selector,
role_selector):
if comicinfo is None:
return
for person in people:
name = name_selector(person)
role = role_selector(person)
for map_role in mapping:
if map_role == role:
for fields in mapping[map_role]:
old_name = comicinfo.get_by_tag_name(fields.strip())
if old_name and old_name.strip() != "":
comicinfo.set_by_tag_name(fields.strip(), _merge(old_name, name))
else:
comicinfo.set_by_tag_name(fields.strip(), name.strip())
logging.info(f"No mapping found for: '{name}' as '{role}'")
@staticmethod
def clean_description(summary: str, remove_source: bool) -> str:
"""
Removes HTML text like
from String
Removes "(Source ...)" from String when flag is set to True
:param summary:
:param remove_source:
:return:
"""
if summary is None:
return ""
# Remove HTML
s = MLStripper()
s.feed(summary.strip())
summary = s.get_data()
# Remove "(Source ...)"
source_index = summary.find("Source")
if remove_source and source_index != -1:
start_index = summary.find("(", 0, source_index)
end_index = summary.find(")", source_index)
if start_index != -1 and end_index != -1:
if summary[start_index - 1] == '\n':
start_index -= 1
summary = summary[:start_index] + summary[end_index + 1:]
return summary.strip()
def init_settings(self):
"""
Grabs extension settings and loads it to the base setting controller
:return:
"""
for section in self.settings:
section: SettingSection
for control in section.values:
control: SettingControl
Settings().set_default(section.key, control.key, control.value)
Settings().save()
# Load any saved settings into memory to overwrite defaults
self.save_settings()
@final
def __init__(self):
if self.name is None: # Check if the "name" attribute has been set
raise ValueError(
f"Error initializing the {self.__class__.__name__} Extension. The 'name' attribute must be set in the CoverSource class.")
self.logger = logging.getLogger(f'{self.__module__}.{self.__class__.__name__}')
# Save any default settings to ini
self.init_settings()
================================================
FILE: MangaManager/src/DynamicLibController/models/__init__.py
================================================
================================================
FILE: MangaManager/src/MetadataManager/CoverManager/CoverManager.py
================================================
import copy
import logging
import platform
import tkinter
from idlelib.tooltip import Hovertip
from tkinter import Frame, CENTER, Button, NW
from tkinter.filedialog import askopenfile
from tkinter.ttk import Treeview
import numpy as np
from PIL import Image, ImageTk
from src.Common import ResourceLoader
from src.Common.LoadedComicInfo.LoadedComicInfo import CoverActions, LoadedComicInfo
from src.MetadataManager.GUI.MessageBox import MessageBoxWidgetFactory as mb
from src.MetadataManager.GUI.scrolledframe import ScrolledFrame
from src.MetadataManager.GUI.widgets import ButtonWidget
from src.MetadataManager.GUI.widgets.CanvasCoverWidget import CoverFrame, CanvasCoverWidget
from src.MetadataManager.MetadataManagerGUI import GUIApp
from src.Settings import SettingHeading
from src.Settings.Settings import Settings
action_template = ResourceLoader.get('cover_action_template.png')
logger = logging.getLogger()
overlay_image: Image = None
class ComicFrame(CoverFrame):
def __init__(self, master, loaded_cinfo: LoadedComicInfo):
"""
Custom Implementation of the CoverFrame for cover Manager
:param master: Parent window
:param loaded_cinfo: The lcinfo to display covers from
"""
super(CoverFrame, self).__init__(master, highlightbackground="black")
self.loaded_cinfo: LoadedComicInfo = loaded_cinfo
self.configure(highlightthickness=2, highlightcolor="grey", highlightbackground="grey", padx=20, pady=10)
title = tkinter.Label(self,
text=f"{loaded_cinfo.file_name[:70]}{'...' if len(loaded_cinfo.file_name) > 70 else ''}")
Hovertip(title, loaded_cinfo.file_name, 20)
title.pack(expand=True)
# COVER
self.cover_frame = Frame(self)
self.cover_frame.pack(side="left")
self.cover_canvas = CanvasCoverWidget(self.cover_frame)
self.cover_canvas.configure(background='#878787', height='260', width='190', highlightthickness=8)
self.cover_canvas.pack(side="top", expand=False, anchor=CENTER)
self.cover_canvas.overlay_image = ImageTk.PhotoImage(overlay_image, master=self.cover_canvas)
self.cover_canvas.overlay_id = self.cover_canvas.create_image(150, 150, image=self.cover_canvas.overlay_image,
state="hidden")
self.cover_canvas.action_id = self.cover_canvas.create_text(150, 285, text="", justify="center", fill="yellow",
font=('Helvetica 15 bold'))
self.cover_canvas.no_image_warning_id = self.cover_canvas.create_text(150, 120,
text="No Cover!\nNo image\ncould be\nloaded",
justify="center", fill="red",
state="hidden",
font=('Helvetica 28 bold'))
self.cover_canvas.image_id = self.cover_canvas.create_image(0, 0, anchor=NW)
self.cover_canvas.scale("all", -1, 1, 0.63, 0.87)
self.cover_canvas.tag_lower(self.cover_canvas.image_id)
btn_frame = Frame(self.cover_frame)
btn_frame.pack(side="bottom", anchor=CENTER, fill="x")
btn = Button(btn_frame, text="✎", command=lambda:
self.cover_action(self.loaded_cinfo, action=CoverActions.REPLACE, parent=self))
btn.pack(side="left", fill="x", expand=True)
btn = Button(btn_frame, text="🗑", command=lambda:
self.cover_action(self.loaded_cinfo, action=CoverActions.DELETE))
btn.pack(side="left", fill="x", expand=True)
btn = Button(btn_frame, text="➕", command=lambda:
self.cover_action(self.loaded_cinfo, action=CoverActions.APPEND, parent=self))
btn.pack(side="left", fill="x", expand=True)
btn = Button(btn_frame, text="Reset", command=lambda:
self.cover_action(self.loaded_cinfo, action=CoverActions.RESET))
btn.pack(side="left", fill="x", expand=True)
self.cover_action(self.loaded_cinfo, auto_trigger=True, proc_update=False)
# BACK COVER
self.backcover_frame = Frame(self)
self.backcover_frame.pack(side="left")
self.backcover_canvas = CanvasCoverWidget(self.backcover_frame)
self.backcover_canvas.configure(background='#878787', height='260', width='190', highlightthickness=8)
self.backcover_canvas.pack(side="top", expand=False, anchor=CENTER)
self.backcover_canvas.overlay_image = ImageTk.PhotoImage(overlay_image, master=self.backcover_canvas)
self.backcover_canvas.overlay_id = self.backcover_canvas.create_image(150, 150,
image=self.backcover_canvas.overlay_image,
state="hidden")
self.backcover_canvas.action_id = self.backcover_canvas.create_text(150, 285, text="", justify="center",
fill="yellow",
font=('Helvetica 15 bold'))
self.backcover_canvas.no_image_warning_id = self.backcover_canvas.create_text(150, 120,
text="No Cover!\nNo image\ncould be\nloaded",
justify="center", fill="red",
state="hidden",
font=('Helvetica 28 bold'))
self.backcover_canvas.image_id = self.backcover_canvas.create_image(0, 0, anchor=NW)
self.backcover_canvas.scale("all", -1, 1, 0.63, 0.87)
self.backcover_canvas.tag_lower(self.backcover_canvas.image_id)
btn_frame = Frame(self.backcover_frame)
btn_frame.pack(side="bottom", anchor=CENTER, fill="x")
btn = Button(btn_frame, text="✎", command=lambda:
self.backcover_action(self.loaded_cinfo, action=CoverActions.REPLACE, parent=self))
btn.pack(side="left", fill="x", expand=True)
btn = Button(btn_frame, text="🗑", command=lambda:
self.backcover_action(self.loaded_cinfo, action=CoverActions.DELETE))
btn.pack(side="left", fill="x", expand=True)
btn = Button(btn_frame, text="➕", command=lambda:
self.backcover_action(self.loaded_cinfo, action=CoverActions.APPEND, parent=self))
btn.pack(side="left", fill="x", expand=True)
btn = Button(btn_frame, text="Reset", command=lambda:
self.backcover_action(self.loaded_cinfo, action=CoverActions.RESET))
btn.pack(side="left", fill="x", expand=True)
# Load backcover
self.backcover_action(self.loaded_cinfo, auto_trigger=True, proc_update=False)
class CoverManager(tkinter.Toplevel):
name = "CoverManager"
scrolled_widget: Frame
top_level: tkinter.Toplevel = tkinter.Toplevel
def __init__(self, master, super_: GUIApp = None, **kwargs):
"""
Initializes the toplevel window but hides the window.
"""
if self.name is None: # Check if the "name" attribute has been set
raise ValueError(
f"Error initializing the {self.__class__.__name__} Extension. The 'name' attribute must be set in the ExtensionApp class.")
# if self.embedded_ui:
super().__init__(master=master, **kwargs)
self.title(self.__class__.name)
if super_ is not None:
self._super = super_
global overlay_image
overlay_image = Image.open(action_template)
overlay_image = overlay_image.resize((190, 260), Image.NEAREST)
self.serve_gui()
if not self._super.loaded_cinfo_list:
mb.showwarning(self, "No files selected", "No files were selected so none will be displayed in cover manager")
# self.deiconify()
self.destroy()
return
# bind the redraw function to the event
# so that it will be called whenever the window is resized
self.bind("", self.redraw)
def redraw(self, event):
"""
Redraws the widgets in the scrolled widget based on the current size of the window.
The function is triggered by an event (e.g. window resize) and only redraws the widgets if
the window dimensions have changed since the last redraw. The widgets are laid out in a grid
with a number of columns equal to the number of widgets that fit in the current width of the
window, minus 300 pixels.
:param: event: The event that triggered to redraw (e.g. a window resize event).
"""
width = self.winfo_width()
height = self.winfo_height()
if not event:
return
if not (width != event.width or height != event.height):
return
width = self.winfo_width() - 300
if width == self.prev_width:
return
childrens = self.scrolled_widget.winfo_children()
for child in childrens:
child.grid_forget()
if not self.scrolled_widget.winfo_children():
return
num_widgets = width // 414
try:
logger.trace(f"Number of widgets per row: {num_widgets}")
logger.trace(f"Number of rows: {len(self.scrolled_widget.winfo_children()) / num_widgets}")
except ZeroDivisionError:
pass
# redraw the widgets
widgets_to_redraw = list(
reversed(copy.copy(self.scrolled_widget.winfo_children()))) # self.scrolled_widget.grid_slaves()
i = 0
j = 0
while widgets_to_redraw:
if j >= num_widgets:
i += 1
j = 0
widgets_to_redraw.pop().grid(row=i, column=j)
j += 1
def exit_btn(self):
self._super.show_not_saved_indicator()
self.destroy()
self.update()
def serve_gui(self):
"""
This function creates and serves the GUI for the application.
"""
if platform.system() == "Linux":
self.attributes('-zoomed', True)
elif platform.system() == "Windows":
self.state('zoomed')
side_panel_control = Frame(self)
side_panel_control.pack(side="right", expand=False, fill="y")
ctr_btn = Frame(self)
ctr_btn.pack()
#
#
tree = self.tree = Treeview(side_panel_control, columns=("Filename", "type"), show="headings", height=8)
tree.column("#1")
tree.heading("#1", text="Filename")
tree.column("#2", anchor=CENTER, width=80)
tree.heading("#2", text="Type")
tree.pack(expand=True, fill="y", pady=(80, 0), padx=30, side="top")
action_buttons = Frame(side_panel_control)
action_buttons.pack(ipadx=20, ipady=20, pady=(0, 80), fill="x", padx=30)
ButtonWidget(master=action_buttons, text="Delete Selected",
tooltip="Deletes the image for the selected cover/backcovers",
command=lambda: self.run_bulk_action(CoverActions.DELETE)).pack(side="top", fill="x", ipady=10)
ButtonWidget(master=action_buttons, text="Append to Selected",
tooltip="Appends the image for the selected cover/backcovers",
command=lambda: self.run_bulk_action(CoverActions.APPEND)).pack(side="top", fill="x", ipady=10)
ButtonWidget(master=action_buttons, text="Replace Selected",
tooltip="Replaces the image for the selected cover/backcovers",
command=lambda: self.run_bulk_action(CoverActions.REPLACE)).pack(side="top", fill="x", ipady=10)
ButtonWidget(master=action_buttons, text="Clear Selection",
command=self.clear_selection).pack(fill="x", ipady=10)
ButtonWidget(master=action_buttons, text="Close window",
command=self.exit_btn).pack(fill="x", ipady=10)
self.select_similar_btn = ButtonWidget(master=action_buttons, text="Select similar", state="disabled",
command=self.select_similar)
self.select_similar_btn.pack(fill="x", ipady=10)
frame = Frame(action_buttons)
frame.pack(fill="x", pady=(10, 0))
tkinter.Label(frame, text="Delta %").pack(side="left")
self.delta_entry = tkinter.Entry(frame, width="10")
self.delta_entry.insert(0, "90")
self.delta_entry.pack(side="left")
frame = tkinter.LabelFrame(action_buttons, text="Scan:")
frame.pack(fill="x", expand=True, pady=(0, 5))
self.scan_covers = tkinter.BooleanVar(value=True)
self.scan_backcovers = tkinter.BooleanVar(value=False)
tkinter.Checkbutton(frame, text="Covers", variable=self.scan_covers).pack()
tkinter.Checkbutton(frame, text="Back Covers", variable=self.scan_backcovers).pack()
content_frame = Frame(self)
content_frame.pack(fill="both", side="left", expand=True)
frame = ScrolledFrame(master=content_frame, scrolltype="vertical", usemousewheel=True)
frame.pack(fill="both", expand=True)
self.scrolled_widget = frame.innerframe
self.tree_dict = {}
self.prev_width = 0
self.last_folder = ""
self.selected_frames: list[tuple[ComicFrame, str]] = []
for i, cinfo in enumerate(self._super.loaded_cinfo_list):
# create a ComicFrame for each LoadedComicInfo object
comic_frame = ComicFrame(self.scrolled_widget, cinfo)
comic_frame.cover_canvas.bind("",
lambda event, frame_=comic_frame: self.select_frame(event, frame_, "front"))
comic_frame.backcover_canvas.bind("",
lambda event, frame_=comic_frame: self.select_frame(event, frame_,
"back"))
comic_frame.grid()
self.redraw(None)
def select_frame(self, _, frame: ComicFrame, pos: str):
"""
Selects the frame. Adds to selected frames and modifies its border to show green as "selected"
"""
if (frame, pos) in self.selected_frames:
for children in self.tree.get_children():
if self.tree_dict[children]["cinfo"] == frame.loaded_cinfo and self.tree_dict[children]["type"] == pos:
self.selected_frames.remove((frame, pos))
self.tree.delete(children)
del self.tree_dict[children]
if pos == "front":
frame.cover_canvas.configure(highlightbackground="#f0f0f0", highlightcolor="white")
else:
frame.backcover_canvas.configure(highlightbackground="#f0f0f0", highlightcolor="white")
else:
node = self.tree.insert('', 'end', text="1", values=(frame.loaded_cinfo.file_name, pos))
self.tree_dict[node] = {"cinfo": frame.loaded_cinfo, "type": pos}
self.selected_frames.append((frame, pos))
if pos == "front":
frame.cover_canvas.configure(highlightbackground="green", highlightcolor="green")
else:
frame.backcover_canvas.configure(highlightbackground="green", highlightcolor="green")
# noinspection PyTypeChecker
self.select_similar_btn.configure(state="normal" if len(self.selected_frames) == 1 else "disabled")
def run_bulk_action(self, action: CoverActions):
"""
Applies the action to currently selected files
:param action:
:return:
"""
new_cover_file = None
cover = None
if action == CoverActions.APPEND or action == CoverActions.REPLACE:
new_cover_file = askopenfile(parent=self,
initialdir=Settings().get(SettingHeading.Main, 'covers_folder_path')).name
for frame, type_ in self.selected_frames:
# create a ComicFrame for each LoadedComicInfo object
frame: ComicFrame
loaded_cinfo = frame.loaded_cinfo
canva: CanvasCoverWidget = frame.cover_canvas if type_ == "front" else frame.backcover_canvas
if action is not None:
# If reset, undo action changes. Forget about the new cover.
if type_ == "front":
loaded_cinfo.cover_action = action
else:
loaded_cinfo.backcover_action = action
if loaded_cinfo.new_backcover_cache:
cover = loaded_cinfo.new_backcover_cache
else:
cover = loaded_cinfo.backcover_cache
if not cover:
canva.itemconfig(canva.overlay_id, image=canva.overlay_image, state="hidden")
canva.itemconfig(canva.no_image_warning_id, state="normal")
canva.itemconfig(canva.action_id, text="")
canva.itemconfig(canva.image_id, state="hidden")
else:
# A cover exists. Hide warning
canva.itemconfig(canva.no_image_warning_id, state="hidden")
canva.itemconfig(canva.overlay_id, image=canva.overlay_image, state="normal")
canva.itemconfig(canva.image_id, image=cover, state="normal")
match action:
case CoverActions.APPEND | CoverActions.REPLACE:
loaded_cinfo.new_cover_path = new_cover_file
cover = loaded_cinfo.new_cover_cache
# Show the Action label
canva.itemconfig(canva.action_id,
text="Append" if
action == CoverActions.APPEND else "Replace", state="normal")
case CoverActions.DELETE:
canva.itemconfig(canva.action_id, text="Delete", state="normal")
case _:
canva.itemconfig(canva.overlay_id, state="hidden")
canva.itemconfig(canva.action_id, text="", state="normal")
# Update the displayed cover
canva.itemconfig(canva.image_id, image=cover, state="normal")
def clear_selection(self):
"""
Clears the selected files
:return:
"""
while self.selected_frames:
frame, pos = self.selected_frames.pop()
frame.cover_canvas.configure(highlightbackground="#f0f0f0", highlightcolor="white")
frame.backcover_canvas.configure(highlightbackground="#f0f0f0", highlightcolor="white")
for children in self.tree.get_children():
self.tree.delete(children)
del self.tree_dict[children]
########################
# Cover scanner methods
########################
# TODO: Add tests
def select_similar(self):
"""
Compares the selected file with all the loaded covers and backcovers
Selects files that match.
:return:
"""
assert len(self.selected_frames) == 1
frame, pos = self.selected_frames[0]
if pos == "front":
selected_photoimage: ImageTk.PhotoImage = frame.loaded_cinfo.get_cover_cache()
else:
selected_photoimage: ImageTk.PhotoImage = frame.loaded_cinfo.get_cover_cache(True)
selected_image = ImageTk.getimage(selected_photoimage)
x = np.array(selected_image.histogram())
self.clear_selection()
# Compare all covers:
for comicframe in self.scrolled_widget.winfo_children():
comicframe: ComicFrame
lcinfo: LoadedComicInfo = comicframe.loaded_cinfo
try:
if self.scan_covers.get():
self._scan_images(lcinfo=lcinfo, x=x, is_backcover=False, comicframe=comicframe)
if self.scan_backcovers.get():
self._scan_images(lcinfo=lcinfo, x=x, is_backcover=True, comicframe=comicframe)
except Exception:
logger.exception(f"Failed to compare images for file {comicframe.loaded_cinfo.file_name}")
def _scan_images(self, x, lcinfo:LoadedComicInfo, comicframe, is_backcover=False):
"""
:param x: Numpy array containing the selected image histogram
:param lcinfo: The loaded comicinfo of the compared image
:param is_backcover:
:param comicframe: The comicframe the lcinfo is linked to
:return:
"""
image = lcinfo.get_cover_cache(is_backcover)
if image is None:
logger.error(f"Failed to compare cover image. File is not loaded. File '{lcinfo.file_name}'")
else:
compared_image = ImageTk.getimage(image)
self._compare_images(x, compared_image, comicframe, "back" if is_backcover else "front")
def _compare_images(self, x, compared_image, comicframe, pos):
delta = float(self.delta_entry.get())
y = np.array(compared_image.histogram())
if self.compare_image(x, y, delta=delta):
self.select_frame(None, frame=comicframe, pos=pos)
@staticmethod
def compare_image(x, y, delta:float):
"""
Compares the image histograms
:param img1: Image Object
:param imge2: Image Object
:param x: Numpy array containing the selected image histogram
:param y: Numpy array containing the selected image histogram
:param delta: 1-100 match value
:return:
"""
actual_error = 0
if len(x) == len(y):
error = np.sqrt(((x - y) ** 2).mean())
error = str(error)[:2]
actual_error = float(100) - float(error)
logger.debug(f"Match percentage: {actual_error}%")
if actual_error >= delta:
logger.trace("Matched image")
return True
else:
logger.trace("Images not similar")
================================================
FILE: MangaManager/src/MetadataManager/CoverManager/__init__.py
================================================
================================================
FILE: MangaManager/src/MetadataManager/GUI/ControlManager.py
================================================
import logging
import tkinter
import _tkinter
logger = logging.getLogger()
class ControlManager:
"""
"""
control_button_set = set()
control_hooks = [] # Callables to call when it should lock or unlock
def add(self, widget: tkinter.Widget):
self.control_button_set.add(widget)
def append(self, widget: tkinter.Widget):
self.control_button_set.add(widget)
def toggle(self, enabled=True):
for widget in self.control_button_set:
try:
widget.configure(state="normal" if enabled else "disabled")
except _tkinter.TclError:
logger.exception("Unhandled exception updating widget state", exc_info=False)
def lock(self):
self.toggle(False)
def unlock(self):
self.toggle(True)
================================================
FILE: MangaManager/src/MetadataManager/GUI/ExceptionWindow.py
================================================
import logging
import tkinter
import traceback
from tkinter import Frame
from tkinter.font import Font
from tkinter.ttk import Treeview, Style
from src.Common.LoadedComicInfo.LoadedComicInfo import LoadedComicInfo
logger = logging.getLogger()
class ExceptionHandler(logging.Handler):
def __init__(self, tree_widget):
logging.Handler.__init__(self)
self.tree_widget = tree_widget
def emit(self, record):
ei = record.exc_info
parent_id = self.tree_widget.insert("", 'end', text=f"{record.levelname:12s} {record.msg}")
self.tree_widget.dict[parent_id] = record
if "processed_filename" in record.__dict__:
self.tree_widget.insert(parent_id, 'end', text=f"Filename: '{record.processed_filename}'")
if "lcinfo" in record.__dict__:
lcinfo: LoadedComicInfo = record.__dict__["lcinfo"]
self.tree_widget.insert(parent_id, 'end', text=f"Filename: '{lcinfo.file_name}'")
if ei:
stack_tab = self.tree_widget.insert(parent_id, 'end', text="Stack Trace info", open=False)
exc_type, exc_value, exc_traceback = ei
tb_str = ''.join(traceback.format_exception(exc_type, exc_value, exc_traceback))
for string in tb_str.split("\n"):
self.tree_widget.insert(stack_tab, 'end', text=string)
class ExceptionFrame(Frame):
def __init__(self, master=None, is_test=False, **kwargs):
Frame.__init__(self, master, **kwargs)
ter_font = Font(family="Consolas", size=6)
style = Style()
style.configure('Terminal.Treeview', font=ter_font)
self.tree = Treeview(self, style='Terminal.Treeview', show="tree")
self.tree.style = style
self.tree.dict = dict()
self.tree.pack(expand=True, fill='both')
self.selected_logging_level = tkinter.StringVar(self)
self.selected_logging_level.set("WARNING")
self.input_type = tkinter.OptionMenu(self,self.selected_logging_level,*("WARNING", "ERROR", "INFO", "DEBUG","TRACE"))
self.input_type.pack(side="left", fill="y")
tkinter.Button(self,text="Clear logs",command=self.clear_treeview).pack(side="left", fill="y")
self.selected_logging_level.trace("w", self.update_handler_level)
handler = self.handler = ExceptionHandler(self.tree)
handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
handler.setLevel(logging.WARNING)
if not is_test:
logger.addHandler(handler)
# Pump logging events not loaded with the ui
logger.debug("Removing unpumped handler")
logger.removeHandler(logging.umpumped_handler)
while logging.umpumped_events:
record = logging.umpumped_events.pop()
handler.emit(record)
def update_handler_level(self,*args):
self.handler.setLevel(logging.getLevelName(self.selected_logging_level.get()))
logger.info(f"Selected '{self.selected_logging_level.get()}' as UI logging level",extra={"ui":True})
def clear_treeview(self):
# Delete all items in the Treeview
self.tree.delete(*self.tree.get_children())
def __del__(self):
logger.removeHandler(self.handler)
================================================
FILE: MangaManager/src/MetadataManager/GUI/FileChooserWindow.py
================================================
import dataclasses
import fnmatch
import os
import tkinter
from idlelib.tooltip import Hovertip
from pathlib import Path
from tkinter import font
from tkinter import ttk, Frame
from src.MetadataManager.GUI.widgets import ScrolledFrameWidget
@dataclasses.dataclass
class DummyFile:
name: str
def __str__(self):
return self.name
class TreeAutocompleteCombobox(ttk.Combobox):
def set_completion_list(self,path, completion_list):
"""Use our completion list as our drop down selection menu, arrows move through menu."""
if not completion_list:
self._completion_list = []
else:
self._completion_list = [str(Path(path,item)) for item in completion_list] # Work with a sorted list
self._hits = []
self._hit_index = 0
self.position = 0
self.bind('', self.handle_keyrelease)
self['values'] = self._completion_list # Setup our popup menu
def autocomplete(self, delta=0):
"""autocomplete the Combobox, delta may be 0/1/-1 to cycle through possible hits"""
if delta: # need to delete selection otherwise we would fix the current position
self.delete(self.position, tkinter.END)
else: # set position to end so selection starts where textentry ended
self.position = len(self.get())
# collect hits
_hits = []
for element in self._completion_list:
if element.lower().startswith(self.get().lower()): # Match case insensitively
_hits.append(element)
# if we have a new hit list, keep this in mind
if _hits != self._hits:
self._hit_index = 0
self._hits = _hits
# only allow cycling if we are in a known hit list
if _hits == self._hits and self._hits:
self._hit_index = (self._hit_index + delta) % len(self._hits)
# now finally perform the auto completion
if self._hits:
self.delete(0, tkinter.END)
self.insert(0, self._hits[self._hit_index])
self.select_range(self.position, tkinter.END)
def handle_keyrelease(self, event):
"""event handler for the keyrelease event on this widget"""
if event.keysym == "BackSpace":
self.delete(self.index(tkinter.INSERT), tkinter.END)
self.position = self.index(tkinter.END)
if event.keysym == "Left":
if self.position < self.index(tkinter.END): # delete the selection
self.delete(self.position, tkinter.END)
else:
self.position = self.position - 1 # delete one character
self.delete(self.position, tkinter.END)
if event.keysym == "Right":
self.position = self.index(tkinter.END) # go to end (no selection)
if len(event.keysym) == 1:
self.autocomplete()
# No need for up/down, we'll jump to the popup
# list at the position of the autocompletion
class TreeviewExplorerWidget(ttk.Treeview):
def __init__(self,master, *_, **__):
super(TreeviewExplorerWidget, self).__init__(master)
self.on_select_hooks:callable = []
self.nodes:dict = dict()
self.directory_nodes:dict = dict()
self.tree = dict()
self.FOLDER_ICON = tkinter.PhotoImage(
data="""iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABmJLR0QA/wD/AP+gvaeTAAABF0lEQVQ4jcXQQS8DQRTA8f9sZ7dVdCvsgcSNC4kQ4uJLSIRP4nP4IOJuv4CLi8RNE044oJrVaraz3XkOyyLZTboH8U4zk/d+782D/w71dRheHe2BXcpuzmNz6/RiYqB/ebCOsteA8/luFRyL4r6oSAQz245DtXo+0gCm21sTJc6PHEfPtE8c1y1ua4XXu/QQONMA/Ydnd2ohQNUyo9Zo4q9sgFLFABDd3uzmwPTi8ub89s7vMREQKQWSaKABdDaR1TYdlSYX/iJNyAGsQcZxJQBrvoF64HfS5L1SfT3wOzngtbyuNYNKgNfyujmQmgikfOOFobIFa4Dx8M1A+cZLBJMDL9FTONfw9pXgTVIqCtOLTVix4x/FB+6JXX9v9hbtAAAAAElFTkSuQmCC""")
self.FILE_ICON = tkinter.PhotoImage(
data="""iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAAtklEQVRIid2VSwrCMBRFjyJ06LZK99GhIzfg0H2Jo+6i2YArsE5ewYY03rwoSC88Qj435+VHYItqgQBMH+LiBYzC5FWQ2ayMmYDrrwHFEA+gCOIFJCG7FfNaXzwmpYXvkEtTnSQH3TsBsv4GcAdumXo14Mlyj+N6kZRrKvvUFZyADjhadNbmUpxJz/rD6jM+GdAAZ2AAHhaDtTXfAKiqOgO3UoBgpfrpvGceENRS9qvNMZp3Y3oBGlFzy7XlpJsAAAAASUVORK5CYII=""")
self.bind('', self._on_select)
def nothing(self, *event):
""" # Hacking moment: A function that does nothing, for those times you need it...
"""
pass
def _on_select(self,*args):
if not self.selection():
return
self.item(self.selection()[0],open=True)
for hook in self.on_select_hooks:
hook(*args)
return "break"
def clear(self):
self.delete(*self.get_children())
self.nodes = dict()
self.tree = dict()
self.directory_nodes = dict()
def show_nested_items(self, current_path,glob="*.cbz"):
current_path = str(Path(current_path))
self.clear()
files = []
items = os.scandir(current_path)
for item in items:
if item.is_dir():
node = self.insert('', 'end', text=item.name, open=True, image=self.FOLDER_ICON)
self.tree[node] = {"path": str(Path(current_path, item.name)),
"is_dir": True}
else:
files.append(item)
for file in fnmatch.filter(files, glob):
node = self.insert('', 'end', text=file.name, open=True, image=self.FILE_ICON)
self.tree[node] = {"path": str(file.path),
"is_dir": False}
# TODO: This needs to be a Widget or Window submodule
class FileChooser(tkinter.Toplevel):
def __init__(self, parent, initialdir=None,*_, **__):
super(FileChooser, self).__init__(parent)
self.prev_path = [None]
self.next_path = [None]
self.current_search_path: Path = Path(initialdir) if initialdir else None
self.title("File Selector")
self.geometry("800x600")
header = Frame(self)
header.pack(expand=False,fill="x")
self.search_bar = search_bar = Frame(header, highlightbackground="blue", highlightthickness=2,background="grey")
search_bar.pack(expand=False, fill="x", pady=10,padx=10,ipady=5,ipadx=1)
search_bar.bind('', self.change_to_entry)
control_arrow_frame = Frame(header)
control_arrow_frame.pack(side="left")
self.prev_btn = tkinter.Button(control_arrow_frame, text="🡰", command=lambda:self.update_search_bar(self.prev_path[-1]))
self.prev_btn.pack(side="left")
tkinter.Button(control_arrow_frame, text="⮥", command= lambda:self.update_search_bar(Path(*self.current_search_path.parts[:-1]))).pack(side="right")
glob_frame = Frame(header)
glob_frame.pack(side="right")
glob_frame.tooltip = Hovertip(glob_frame, "Find all files with the provided glob. (Enables recursiveness)", 20)
tkinter.Label(glob_frame,text="Glob: ⁱ").pack(side="left")
self.glob_entry = ttk.Entry(glob_frame)
self.glob_entry.insert(0, "*.cbz")
self.glob_entry.pack(side="right")
treeview_frame = ScrolledFrameWidget(self).create_frame()
self.tree = TreeviewExplorerWidget(master=treeview_frame,selectmode="extended")
self.tree.on_select_hooks.append(self.on_treeview_select)
self.tree.heading("#0", text='Filename', anchor='n')
self.tree.pack(expand=True, fill="both")
self.tree.bind("", lambda x: self.update_search_bar(self.current_search_path))
footer = Frame(self)
footer.pack(side="bottom")
tkinter.Button(footer,text="Accept", command=self.get_selection).pack()
self.selection = None
if self.current_search_path:
self.update_search_bar(self.current_search_path)
def update_suggestions(self):
try:
list_of_files = os.listdir(self.entry.get())
except NotADirectoryError:
list_of_files = []
self.entry.set_completion_list(self.entry.get(), list_of_files)
self.entry.event_generate("")
def change_to_entry(self, *_):
self.clear_search_chilren()
self.entry = entry = TreeAutocompleteCombobox(self.search_bar)
entry.set(self.current_search_path)
entry.set_completion_list(self.current_search_path, os.listdir(entry.get()))
entry.bind("",lambda x:self.update_suggestions())
self.entry.focus()
# entry.bind("", lambda x: self.update_search_bar(self.entry.get()))
entry.bind("", lambda x: self.update_search_bar(self.entry.get()))
entry.pack(expand=False, fill="x", anchor="center")
def clear_search_chilren(self, *_):
"""
Removes all widgets in the search bar frame
:param event:
:return:
"""
for child in self.search_bar.winfo_children():
child.destroy()
def update_search_bar(self, new_path):
"""
Processes the given paths and creates a button instance of each part of the path.
Displays the buttons in order in the search frame
:param new_path:
:return:
"""
if not new_path:
return
if new_path != self.prev_path[-1]:
self.prev_path.append(self.current_search_path)
else:
self.prev_path.pop()
self.clear_search_chilren()
parts = Path(new_path).parts
current_iter_path = ""
for i, part in enumerate(parts):
current_iter_path = Path(current_iter_path, part) if current_iter_path else part
self.current_search_path = current_iter_path
s = ttk.Style(self)
# s.theme_use('clam')
s.configure('flat.TButton', borderwidth=0, width="1", font=1)
btn = tkinter.Button(master=self.search_bar,
text=part,
command=lambda x=current_iter_path: self.update_search_bar(x),relief="flat",justify="left", height=1,
anchor="center",padx=2)
btn["font"] = font.Font(size=7)
btn.bind('', lambda evenht,x=current_iter_path:self.update_search_bar(x))
btn.bind('', self.change_to_entry)
btn.pack(side="left", expand=False, fill="none",)
self.tree.show_nested_items(current_iter_path,self.glob_entry.get())
def on_treeview_select(self, *_):
"""
When an item is selected in the treeview.
If double click and is directory, browse to that folder
:param args:
:return:
"""
selection = self.tree.selection()
if not selection or len(selection)>1:
return
item = self.tree.tree.get(selection[0])
if not item.get("is_dir"):
return
self.update_search_bar(Path(item.get("path")))
self.tree.item(self.tree.selection(),open=True)
return "break"
# def update_treeview(self,base_path):
def get_selection(self):
self.selection = [DummyFile(str(Path(self.tree.tree.get(item).get("path")))) for item in self.tree.selection()]
self.destroy()
def get_selected_files(self, *_):
self.wm_protocol("WM_DELETE_WINDOW", self.destroy)
self.wait_window(self)
return self.selection
def exit_btn(self):
self.destroy()
self.update()
def askopenfiles(parent, *args, **kwargs):
from tkinter.filedialog import askopenfiles
return askopenfiles(*args,**kwargs)
# Fixme
# filechooser = FileChooser(parent,*args,**kwargs)
# selection = filechooser.get_selected_files()
# return selection
def askdirectory(*args, **kwargs):
from tkinter.filedialog import askdirectory
return askdirectory(*args, **kwargs)
================================================
FILE: MangaManager/src/MetadataManager/GUI/MessageBox.py
================================================
from typing import Type
from src.MetadataManager.GUI.OneTimeMessageBox import OneTimeMessageBox
from src.MetadataManager.GUI.widgets.MessageBoxWidget import MessageBoxWidget, MessageBoxButton
class MessageBoxWidgetFactory:
"""
A factory that generates predefined widgets. Return values /buttons are predefined (NO, YES, CANCEL)
"""
@staticmethod
def yes_or_no(parent, title, description):
ret = MessageBoxWidget(parent) \
.with_title(title) \
.with_icon(MessageBoxWidget.icon_question) \
.with_description(description) \
.with_actions([MessageBoxButton(0, "No"), MessageBoxButton(1, "Yes")])
return ret.prompt()
@staticmethod
def showerror(parent, title, description):
ret = MessageBoxWidget(parent) \
.with_title(title) \
.with_icon(MessageBoxWidget.icon_error) \
.with_description(description) \
.with_actions([MessageBoxButton(1, "Ok")])
return ret.prompt()
@staticmethod
def showwarning(parent, title, description):
ret = MessageBoxWidget(parent) \
.with_title(title) \
.with_icon(MessageBoxWidget.icon_warning) \
.with_description(description) \
.with_actions([MessageBoxButton(1, "Ok")])
return ret.prompt()
@staticmethod
def get_onetime_messagebox() -> Type[OneTimeMessageBox]:
return OneTimeMessageBox
@staticmethod
def get_box_button() -> Type[MessageBoxButton]:
return MessageBoxButton
================================================
FILE: MangaManager/src/MetadataManager/GUI/OneTimeMessageBox.py
================================================
from tkinter import Checkbutton, BooleanVar
from src.Common.utils import parse_bool
from src.MetadataManager.GUI.widgets.MessageBoxWidget import MessageBoxWidget
from src.Settings import Settings, SettingHeading
DISABLED_MESSAGE_BOX = "disabled_message_box"
class OneTimeMessageBox(MessageBoxWidget):
"""
Messagebox that implements a checkbox to not show again
"""
def __new__(cls, mb_id, *args, **kwargs):
if mb_id is None:
raise AttributeError("mb_id can't be NoneType")
mb_setting = Settings().get_default(SettingHeading.MessageBox, mb_id, False)
if parse_bool(mb_setting):
cls.disabled = True
else:
cls.disabled = False
return super().__new__(cls, *args, **kwargs)
def __init__(self, mb_id=None, *args, **kwargs):
self.mb_id = mb_id
super().__init__(*args, **kwargs)
self.with_dontshowagain()
def with_dontshowagain(self):
if not self.disabled:
self.dont_show_again_value = BooleanVar(self, value=False, name="Dont show again checkbtn")
Checkbutton(self, text="Don't show this window again", variable=self.dont_show_again_value).pack(pady=(10, 0))
def prompt(self):
if self.disabled:
return DISABLED_MESSAGE_BOX
ret = super().prompt()
if self.dont_show_again_value.get():
Settings().set(section=SettingHeading.MessageBox, key=self.mb_id, value=True)
Settings().save()
return ret
================================================
FILE: MangaManager/src/MetadataManager/GUI/__init__.py
================================================
================================================
FILE: MangaManager/src/MetadataManager/GUI/scrolledframe.py
================================================
# Extracted from: https://github.com/alejandroautalan/pygubu/tree/master/pygubu
# encoding: utf8
from __future__ import annotations
import platform
import tkinter as tk
import tkinter.ttk as ttk
CONFIGURE = ''
def bindings(widget, seq):
return [x for x in widget.bind(seq).splitlines() if x.strip()]
def _funcid(binding):
return binding.split()[1][3:]
def remove_binding(widget, seq, index=None, funcid=None):
b = bindings(widget, seq)
if index is not None:
try:
binding = b[index]
widget.unbind(seq, _funcid(binding))
b.remove(binding)
except IndexError:
return
elif funcid:
binding = None
for x in b:
if _funcid(x) == funcid:
binding = x
b.remove(binding)
widget.unbind(seq, funcid)
break
if not binding:
return
else:
raise ValueError('No index or function id defined.')
for x in b:
widget.bind(seq, '+' + x, 1)
class ApplicationLevelBindManager(object):
# Mouse wheel support
mw_active_area = None
mw_initialized = False
@staticmethod
def on_mousewheel(event):
if ApplicationLevelBindManager.mw_active_area:
ApplicationLevelBindManager.mw_active_area.on_mousewheel(event)
@staticmethod
def mousewheel_bind(widget):
ApplicationLevelBindManager.mw_active_area = widget
@staticmethod
def mousewheel_unbind():
ApplicationLevelBindManager.mw_active_area = None
@staticmethod
def init_mousewheel_binding(master):
if not ApplicationLevelBindManager.mw_initialized:
_os = platform.system()
if _os in ('Linux', 'OpenBSD', 'FreeBSD'):
master.bind_all(
'<4>', ApplicationLevelBindManager.on_mousewheel, add='+')
master.bind_all(
'<5>', ApplicationLevelBindManager.on_mousewheel, add='+')
else:
# Windows and MacOS
master.bind_all(
"",
ApplicationLevelBindManager.on_mousewheel,
add='+')
ApplicationLevelBindManager.mw_initialized = True
@staticmethod
def make_onmousewheel_cb(widget, orient, factor=1):
"""Create a callback to manage mousewheel events
orient: string (posible values: ('x', 'y'))
widget: widget that implement tk xview and yview methods
"""
_os = platform.system()
view_command = getattr(widget, orient + 'view')
if _os in ('Linux', 'OpenBSD', 'FreeBSD'):
def on_mousewheel(event):
if event.num == 4:
view_command('scroll', (-1) * factor, 'units')
elif event.num == 5:
view_command('scroll', factor, 'units')
elif _os == 'Windows':
def on_mousewheel(event):
view_command('scroll', (-1) *
int((event.delta / 120) * factor), 'units')
elif _os == 'Darwin':
def on_mousewheel(event):
view_command('scroll', event.delta, 'units')
else:
# FIXME: unknown platform scroll method
def on_mousewheel(*_):
pass
return on_mousewheel
# This is used in CoverManager it seems
# noinspection PyUnresolvedReferences
class ScrolledFrame(ttk.Frame):
VERTICAL = 'vertical'
HORIZONTAL = 'horizontal'
BOTH = 'both'
_framecls = ttk.Frame
_sbarcls = ttk.Scrollbar
# noinspection PyMissingConstructor
def __init__(self, master=None, **kw):
self.scrolltype = kw.pop('scrolltype', self.VERTICAL)
self.usemousewheel = tk.getboolean(kw.pop('usemousewheel', False))
self._bindingids = []
self._framecls.__init__(self, master, **kw)
self._container = self._framecls(self, width=200, height=200)
self._clipper = self._framecls(self._container, width=200, height=200)
self.innerframe = self._framecls(self._clipper)
self.vsb = self._sbarcls(self._container)
self.hsb = self._sbarcls(self._container, orient="horizontal")
# variables
self.hsbOn = 0
self.vsbOn = 0
self.hsbNeeded = 0
self.vsbNeeded = 0
self._jfraction = 0.05
self._scrollTimer = None
self._scrollRecurse = 0
self._startX = 0
self._start_y = 0
# configure scroll
self.hsb.set(0.0, 1.0)
self.vsb.set(0.0, 1.0)
self.vsb.config(command=self.yview)
self.hsb.config(command=self.xview)
# grid
self._container.pack(expand=True, fill='both')
self._clipper.grid(row=0, column=0, sticky=tk.NSEW)
self._container.rowconfigure(0, weight=1)
self._container.columnconfigure(0, weight=1)
# Whenever the clipping window or scrolled frame change size,
# update the scrollbars.
self.innerframe.bind(CONFIGURE, self._reposition)
self._clipper.bind(CONFIGURE, self._reposition)
self.bind(CONFIGURE, self._reposition)
self._configure_mousewheel()
# Set timer to call real reposition method, so that it is not
# called multiple times when many things are reconfigured at the
# same time.
def reposition(self):
if self._scrollTimer is None:
self._scrollTimer = self.after_idle(self._scrollBothNow)
# Called when the user clicks in the horizontal scrollbar.
# Calculates new position of frame then calls reposition() to
# update the frame and the scrollbar.
def xview(self, mode=None, value=None, units=None):
if isinstance(value, str):
value = float(value)
if mode is None:
return self.hsb.get()
elif mode == 'moveto':
frame_width = self.innerframe.winfo_reqwidth()
self._startX = value * float(frame_width)
else: # mode == 'scroll'
clipper_width = self._clipper.winfo_width()
if units == 'units':
jump = int(clipper_width * self._jfraction)
else:
jump = clipper_width
self._startX = self._startX + value * jump
self.reposition()
# Called when the user clicks in the vertical scrollbar.
# Calculates new position of frame then calls reposition() to
# update the frame and the scrollbar.
def yview(self, mode=None, value=None, units=None):
if isinstance(value, str):
value = float(value)
if mode is None:
return self.vsb.get()
elif mode == 'moveto':
frame_height = self.innerframe.winfo_reqheight()
self._start_y = value * float(frame_height)
else: # mode == 'scroll'
clipper_height = self._clipper.winfo_height()
if units == 'units':
jump = int(clipper_height * self._jfraction)
else:
jump = clipper_height
self._start_y = self._start_y + value * jump
self.reposition()
def _reposition(self, *_):
self.reposition()
def _getxview(self):
# Horizontal dimension.
clipper_width = self._clipper.winfo_width()
frame_width = self.innerframe.winfo_reqwidth()
if frame_width <= clipper_width:
# The scrolled frame is smaller than the clipping window.
self._startX = 0
end_scrollX_x = 1.0
# use expand by default
relwidth = 1
else:
# The scrolled frame is larger than the clipping window.
# use expand by default
if self._startX + clipper_width > frame_width:
self._startX = frame_width - clipper_width
end_scrollX_x = 1.0
else:
if self._startX < 0:
self._startX = 0
end_scrollX_x = (self._startX + clipper_width) / float(frame_width)
relwidth = ''
# Position frame relative to clipper.
self.innerframe.place(x=-self._startX, relwidth=relwidth)
return (self._startX / float(frame_width), end_scrollX_x)
def _getyview(self):
# Vertical dimension.
clipper_height = self._clipper.winfo_height()
frame_height = self.innerframe.winfo_reqheight()
if frame_height <= clipper_height:
# The scrolled frame is smaller than the clipping window.
self._start_y = 0
end_scroll_y = 1.0
# use expand by default
relheight = 1
else:
# The scrolled frame is larger than the clipping window.
# use expand by default
if self._start_y + clipper_height > frame_height:
self._start_y = frame_height - clipper_height
end_scroll_y = 1.0
else:
if self._start_y < 0:
self._start_y = 0
end_scroll_y = (self._start_y + clipper_height) / float(frame_height)
relheight = ''
# Position frame relative to clipper.
self.innerframe.place(y=-self._start_y, relheight=relheight)
return (self._start_y / float(frame_height), end_scroll_y)
# According to the relative geometries of the frame and the
# clipper, reposition the frame within the clipper and reset the
# scrollbars.
def _scrollBothNow(self):
self._scrollTimer = None
# Call update_idletasks to make sure that the containing frame
# has been resized before we attempt to set the scrollbars.
# Otherwise the scrollbars may be mapped/unmapped continuously.
self._scrollRecurse = self._scrollRecurse + 1
self.update_idletasks()
self._scrollRecurse = self._scrollRecurse - 1
if self._scrollRecurse != 0:
return
xview = self._getxview()
yview = self._getyview()
self.hsb.set(xview[0], xview[1])
self.vsb.set(yview[0], yview[1])
require_hsb = self.scrolltype in (self.BOTH, self.HORIZONTAL)
self.hsbNeeded = (xview != (0.0, 1.0)) and require_hsb
require_vsb = self.scrolltype in (self.BOTH, self.VERTICAL)
self.vsbNeeded = (yview != (0.0, 1.0)) and require_vsb
# If both horizontal and vertical scrollmodes are dynamic and
# currently only one scrollbar is mapped and both should be
# toggled, then unmap the mapped scrollbar. This prevents a
# continuous mapping and unmapping of the scrollbars.
if (self.hsbNeeded != self.hsbOn and
self.vsbNeeded != self.vsbOn and
self.vsbOn != self.hsbOn):
if self.hsbOn:
self._toggleHorizScrollbar()
else:
self._toggleVertScrollbar()
return
if self.hsbNeeded != self.hsbOn:
self._toggleHorizScrollbar()
if self.vsbNeeded != self.vsbOn:
self._toggleVertScrollbar()
def _toggleHorizScrollbar(self):
self.hsbOn = not self.hsbOn
if self.hsbOn:
self.hsb.grid(row=1, column=0, sticky=tk.EW)
else:
self.hsb.grid_forget()
def _toggleVertScrollbar(self):
self.vsbOn = not self.vsbOn
if self.vsbOn:
self.vsb.grid(row=0, column=1, sticky=tk.NS)
else:
self.vsb.grid_forget()
def configure(self, cnf=None, **kw):
# noinspection PyProtectedMember
args = tk._cnfmerge((cnf, kw))
key = 'usemousewheel'
if key in args:
self.usemousewheel = tk.getboolean(args[key])
del args[key]
self._configure_mousewheel()
self._framecls.configure(self, args)
config = configure
def cget(self, key):
option = 'usemousewheel'
if key == option:
return self.usemousewheel
return self._framecls.cget(self, key)
__getitem__ = cget
def _configure_mousewheel(self):
if self.usemousewheel:
ApplicationLevelBindManager.init_mousewheel_binding(self)
if self.hsb and not hasattr(self.hsb, 'on_mousewheel'):
self.hsb.on_mousewheel = ApplicationLevelBindManager.make_onmousewheel_cb(
self, 'x', 2)
if self.vsb and not hasattr(self.vsb, 'on_mousewheel'):
self.vsb.on_mousewheel = ApplicationLevelBindManager.make_onmousewheel_cb(
self, 'y', 2)
main_sb = self.vsb or self.hsb
if main_sb:
self.on_mousewheel = main_sb.on_mousewheel
bid = self.bind(
'',
lambda event: ApplicationLevelBindManager.mousewheel_bind(self),
add='+')
self._bindingids.append((self, bid))
bid = self.bind('',
lambda event: ApplicationLevelBindManager.mousewheel_unbind(),
add='+')
self._bindingids.append((self, bid))
for s in (self.vsb, self.hsb):
if s:
bid = s.bind(
'',
lambda event,
scrollbar=s: ApplicationLevelBindManager.mousewheel_bind(scrollbar),
add='+')
self._bindingids.append((s, bid))
if s != main_sb:
bid = s.bind(
'',
lambda event: ApplicationLevelBindManager.mousewheel_unbind(),
add='+')
self._bindingids.append((s, bid))
else:
for widget, bid in self._bindingids:
remove_binding(widget, bid)
================================================
FILE: MangaManager/src/MetadataManager/GUI/utils.py
================================================
import re
INT_PATTERN = re.compile("^-*\d*(?:,?\d+|\.?\d+)?$")
def validate_int(value) -> bool:
"""
Validates if all the values in the string matches the int pattern
:param value:
:return: true if matches
"""
ilegal_chars = [character for character in str(value) if not INT_PATTERN.match(character)]
return not ilegal_chars
def center(win):
"""
centers a tkinter window
:param win: the main window or Toplevel window to center
"""
win.update_idletasks()
width = win.winfo_width()
frm_width = win.winfo_rootx() - win.winfo_x()
win_width = width + 2 * frm_width
height = win.winfo_height()
titlebar_height = win.winfo_rooty() - win.winfo_y()
win_height = height + titlebar_height + frm_width
x = win.winfo_screenwidth() // 2 - win_width // 2
y = win.winfo_screenheight() // 2 - win_height // 2
win.geometry('{}x{}+{}+{}'.format(width, height, x, y))
win.deiconify()
================================================
FILE: MangaManager/src/MetadataManager/GUI/widgets/AutocompleteComboboxWidget.py
================================================
import tkinter
from .MMWidget import MMWidget
from tkinter.ttk import Combobox
class AutocompleteComboboxWidget(MMWidget):
def __init__(self, master, cinfo_name, label_text=None, default_values=None, width=None,
force_validation_from_list = True, tooltip:str = None):
super(AutocompleteComboboxWidget, self).__init__(master=master)
self.name = cinfo_name
self.default = ""
self.set_label(label_text, tooltip)
self.widget = Combobox(self, name=cinfo_name.lower(), values=default_values, style="Custom.TCombobox")
if width is not None:
self.widget.configure(width=width)
self._completion_list = default_values or []
self._hits = []
self._hit_index = 0
self.position = 0
self.bind('', self.handle_keyrelease)
self.widget['values'] = self._completion_list # Setup our popup menu
def autocomplete(self, delta=0):
"""autocomplete the Combobox, delta may be 0/1/-1 to cycle through possible hits"""
if delta: # need to delete selection otherwise we would fix the current position
self.widget.delete(self.position, tkinter.END)
else: # set position to end so selection starts where textentry ended
self.position = len(self.get())
# collect hits
_hits = []
for element in self._completion_list:
if element.lower().startswith(self.widget.get().lower()): # Match case insensitively
_hits.append(element)
# if we have a new hit list, keep this in mind
if _hits != self._hits:
self._hit_index = 0
self._hits = _hits
# only allow cycling if we are in a known hit list
if _hits == self._hits and self._hits:
self._hit_index = (self._hit_index + delta) % len(self._hits)
# now finally perform the auto completion
if self._hits:
self.widget.delete(0, tkinter.END)
self.widget.insert(0, self._hits[self._hit_index])
self.widget.select_range(self.position, tkinter.END)
def handle_keyrelease(self, event):
"""event handler for the keyrelease event on this widget"""
if event.keysym == "BackSpace":
self.widget.delete(self.widget.index(tkinter.INSERT), tkinter.END)
self.position = self.widget.index(tkinter.END)
if event.keysym == "Left":
if self.position < self.widget.index(tkinter.END): # delete the selection
self.widget.delete(self.position, tkinter.END)
else:
self.position = self.position - 1 # delete one character
self.widget.delete(self.position, tkinter.END)
if event.keysym == "Right":
self.position = self.widget.index(tkinter.END) # go to end (no selection)
if len(event.keysym) == 1:
self.autocomplete()
# No need for up/down, we'll jump to the popup
# list at the position of the autocompletion
================================================
FILE: MangaManager/src/MetadataManager/GUI/widgets/ButtonWidget.py
================================================
import tkinter
from idlelib.tooltip import Hovertip
class ButtonWidget(tkinter.Button):
def __init__(self, tooltip=None,image=None, *args, **kwargs):
super(ButtonWidget, self).__init__(image=image, *args, **kwargs)
if tooltip:
self.configure(text=self.cget('text') + ' ⁱ')
self.tooltip = Hovertip(self, tooltip, 20)
================================================
FILE: MangaManager/src/MetadataManager/GUI/widgets/CanvasCoverWidget.py
================================================
import logging
import pathlib
import tkinter
from idlelib.tooltip import Hovertip
from os.path import basename
from tkinter import Frame, Label, StringVar, Event, Canvas, NW, CENTER, Button
from tkinter.filedialog import askopenfile
import _tkinter
from PIL import Image, ImageTk
from src.Common import ResourceLoader
from src.Common.LoadedComicInfo.CoverActions import CoverActions
from src.Common.LoadedComicInfo.LoadedComicInfo import LoadedComicInfo
from src.Settings import SettingHeading
from src.Settings.Settings import Settings
logger = logging.getLogger()
window_width, window_height = 0, 0
action_template = ResourceLoader.get('cover_action_template.png')
MULTIPLE_FILES_SELECTED = "Multiple Files Selected"
class CanvasCoverWidget(Canvas):
overlay_id = None
overlay_image = None
no_image_warning_id = None
action_id = None
image_id = None
class CoverFrame(Frame):
canvas_cover_image_id = None
canvas_backcover_image_id = None
action_buttons = []
displayed_cinfo: LoadedComicInfo | None = None
cover_frame = None
backcover_frame = None
def get_canvas(self, cover_else_backcover: bool = True) -> CanvasCoverWidget:
if cover_else_backcover:
return self.cover_canvas
else:
return self.backcover_canvas
def get_cinfo_cover_data(self):
...
def resized(self, event: Event):
global window_width, window_height
if window_width != event.width:
if 1000 >= event.width:
self.hide_back_image()
window_width, window_height = event.width, event.height
elif 1000 < event.width and window_width + 400 < event.width:
if not Settings().get(SettingHeading.Main, 'cache_cover_images'):
return
self.show_back_image()
window_width, window_height = event.width, event.height
def __init__(self, master):
super(CoverFrame, self).__init__(master, highlightbackground="black", highlightthickness=2)
self.configure(pady=5)
canvas_frame = self
master.master.bind("", self.resized)
self.selected_file_path_var = StringVar(canvas_frame, value="No file selected")
self.selected_file_var = StringVar(canvas_frame, value="No file selected")
self.cover_subtitle = Label(canvas_frame, background="violet", textvariable=self.selected_file_var)
self.cover_subtitle.configure(width=25, compound="right", justify="left")
self.selected_file_var.set('No file selected')
self.tooltip_filename = Hovertip(self, "No file selected", 20)
self.cover_subtitle.grid(row=0, sticky="nsew")
self.grid_columnconfigure(0, weight=1)
images_frame = Frame(canvas_frame)
images_frame.grid(column=0, row=1, sticky="nsew")
overlay_image = Image.open(action_template)
overlay_image = overlay_image.resize((190, 260), Image.NEAREST)
# COVER
self.cover_frame = Frame(images_frame)
self.cover_frame.pack(side="left")
self.cover_canvas = CanvasCoverWidget(self.cover_frame)
self.cover_canvas.configure(background='#878787', height='260', width='190')
self.cover_canvas.pack(side="top", expand=False, anchor=CENTER)
self.cover_canvas.overlay_image = ImageTk.PhotoImage(overlay_image, master=self.cover_canvas)
self.cover_canvas.overlay_id = self.cover_canvas.create_image(150, 150, image=self.cover_canvas.overlay_image,
state="hidden")
self.cover_canvas.action_id = self.cover_canvas.create_text(150, 285, text="", justify="center", fill="yellow",
font=('Helvetica 15 bold'))
self.cover_canvas.no_image_warning_id = self.cover_canvas.create_text(150, 120,
text="No Cover!\nNo image\ncould be\nloaded",
justify="center", fill="red",
state="hidden",
font=('Helvetica 28 bold'))
self.cover_canvas.image_id = self.cover_canvas.create_image(0, 0, anchor=NW)
self.cover_canvas.scale("all", -1, 1, 0.63, 0.87)
self.cover_canvas.tag_lower(self.cover_canvas.image_id)
btn_frame = Frame(self.cover_frame)
btn_frame.pack(side="bottom", anchor=CENTER, fill="x")
btn = Button(btn_frame, text="✎", command=lambda:
self.cover_action(action=CoverActions.REPLACE))
btn.pack(side="left", fill="x", expand=True)
self.action_buttons.append(btn)
btn = Button(btn_frame, text="🗑", command=lambda:
self.cover_action(action=CoverActions.DELETE))
btn.pack(side="left", fill="x", expand=True)
self.action_buttons.append(btn)
btn = Button(btn_frame, text="➕", command=lambda:
self.cover_action(action=CoverActions.APPEND))
btn.pack(side="left", fill="x", expand=True)
self.action_buttons.append(btn)
btn = Button(btn_frame, text="Reset", command=lambda:
self.cover_action(action=CoverActions.RESET))
btn.pack(side="left", fill="x", expand=True)
self.action_buttons.append(btn)
# BACK COVER
self.backcover_frame = Frame(images_frame)
self.backcover_frame.pack(side="left")
self.backcover_canvas = CanvasCoverWidget(self.backcover_frame)
self.backcover_canvas.configure(background='#878227', height='260', width='190')
self.backcover_canvas.pack(side="top", expand=False, anchor=CENTER)
self.backcover_canvas.overlay_image = ImageTk.PhotoImage(overlay_image, master=self.backcover_canvas)
self.backcover_canvas.overlay_id = self.backcover_canvas.create_image(150, 150,
image=self.backcover_canvas.overlay_image,
state="hidden")
self.backcover_canvas.action_id = self.backcover_canvas.create_text(150, 285, text="", justify="center",
state="hidden",
fill="yellow", font=('Helvetica 15 bold'))
self.backcover_canvas.no_image_warning_id = self.backcover_canvas.create_text(150, 120,
text="No Cover!\nNo image\ncould be\nloaded",
justify="center", fill="red",
state="hidden",
font=('Helvetica 28 bold'))
self.backcover_canvas.image_id = self.backcover_canvas.create_image(0, 0, anchor=NW)
self.backcover_canvas.scale("all", -1, 1, 0.63, 0.87)
self.backcover_canvas.tag_lower(self.backcover_canvas.image_id)
btn_frame = Frame(self.backcover_frame)
btn_frame.pack(side="bottom", anchor=CENTER, fill="x")
btn = Button(btn_frame, text="✎", command=lambda: self.backcover_action(action=CoverActions.REPLACE))
btn.pack(side="left", fill="x", expand=True)
self.action_buttons.append(btn)
btn = Button(btn_frame, text="🗑", command=lambda: self.backcover_action(action=CoverActions.DELETE))
btn.pack(side="left", fill="x", expand=True)
self.action_buttons.append(btn)
btn = Button(btn_frame, text="➕", command=lambda: self.backcover_action(action=CoverActions.APPEND))
btn.pack(side="left", fill="x", expand=True)
self.action_buttons.append(btn)
btn = Button(btn_frame, text="Reset", command=lambda: self.backcover_action(action=CoverActions.RESET))
btn.pack(side="bottom", fill="x", expand=True)
self.action_buttons.append(btn)
self.toggle_action_buttons(False)
def cover_action(self, loaded_cinfo: LoadedComicInfo = None, auto_trigger=False, action=None, parent=None, proc_update=True):
if not parent:
parent = self
if loaded_cinfo is None:
loaded_cinfo = self.displayed_cinfo
front_canva: CanvasCoverWidget = self.cover_canvas
if action is not None:
loaded_cinfo.cover_action = action
lcinfo_action = loaded_cinfo.cover_action
# If the file has a new cover selected, display the new cover and show "edit overlay"
if loaded_cinfo.new_cover_path:
cover = loaded_cinfo.new_cover_cache
else:
cover = loaded_cinfo.cover_cache
if not cover:
front_canva.itemconfig(front_canva.overlay_id, image=front_canva.overlay_image, state="hidden")
front_canva.itemconfig(front_canva.no_image_warning_id, state="normal")
front_canva.itemconfig(front_canva.action_id, text="")
front_canva.itemconfig(front_canva.image_id, state="hidden")
if proc_update:
self.update()
return
# A cover exists. Hide warning
front_canva.itemconfig(front_canva.no_image_warning_id, state="hidden")
if proc_update:
self.update()
front_canva.itemconfig(front_canva.overlay_id, image=front_canva.overlay_image, state="normal")
front_canva.itemconfig(front_canva.image_id, image=cover, state="normal")
match lcinfo_action:
case CoverActions.APPEND | CoverActions.REPLACE:
# If the function was manually called, ask the user to select the new cover
if not auto_trigger:
new_cover_file = askopenfile(parent=parent, initialdir=Settings().get(SettingHeading.Main, 'covers_folder_path')).name
loaded_cinfo.new_cover_path = new_cover_file
cover = loaded_cinfo.new_cover_cache
# Show the Action label
front_canva.itemconfig(front_canva.action_id,
text="Append" if
lcinfo_action == CoverActions.APPEND else "Replace", state="normal")
case CoverActions.DELETE:
front_canva.itemconfig(front_canva.action_id, text="Delete", state="normal")
case _:
front_canva.itemconfig(front_canva.overlay_id, state="hidden")
front_canva.itemconfig(front_canva.action_id, text="", state="normal")
# Update the displayed cover
front_canva.itemconfig(front_canva.image_id, image=cover, state="normal")
self.update()
def backcover_action(self, loaded_cinfo: LoadedComicInfo = None, auto_trigger=False, action=None, parent=None, proc_update=True):
if not parent:
parent = self
if loaded_cinfo is None:
loaded_cinfo = self.displayed_cinfo
back_canva: CanvasCoverWidget = self.backcover_canvas
if action is not None:
# If reset, undo action changes. Forget about the new cover.
loaded_cinfo.backcover_action = action
lcinfo_action = loaded_cinfo.backcover_action
# If the file has a new cover selected, display the new cover and show "edit overlay"
if loaded_cinfo.new_backcover_cache:
cover = loaded_cinfo.new_backcover_cache
else:
cover = loaded_cinfo.backcover_cache
if not cover:
back_canva.itemconfig(back_canva.overlay_id, image=back_canva.overlay_image, state="hidden")
back_canva.itemconfig(back_canva.no_image_warning_id, state="normal")
back_canva.itemconfig(back_canva.action_id, text="")
back_canva.itemconfig(back_canva.image_id, state="hidden")
if proc_update:
self.update()
return
# A cover exists. Hide warning
back_canva.itemconfig(back_canva.no_image_warning_id, state="hidden")
if proc_update:
self.update()
back_canva.itemconfig(back_canva.overlay_id, image=back_canva.overlay_image, state="normal")
back_canva.itemconfig(back_canva.image_id, image=cover, state="normal")
match lcinfo_action:
case CoverActions.APPEND | CoverActions.REPLACE:
# If the function was manually called, ask the user to select the new cover
if not auto_trigger:
new_cover_file = askopenfile(parent=parent, initialdir=Settings().get(SettingHeading.Main, 'covers_folder_path')).name
loaded_cinfo.new_backcover_path = new_cover_file
cover = loaded_cinfo.new_backcover_cache
# Show the Action label
back_canva.itemconfig(back_canva.action_id,
text="Append" if
lcinfo_action == CoverActions.APPEND else "Replace",state="normal")
case CoverActions.DELETE:
back_canva.itemconfig(back_canva.action_id, text="Delete", state="normal")
case _:
back_canva.itemconfig(back_canva.overlay_id, state="hidden")
back_canva.itemconfig(back_canva.action_id, text="", state="normal")
# Update the displayed cover
back_canva.itemconfig(back_canva.image_id, image=cover, state="normal")
self.update()
def clear(self):
self.tooltip_filename.text = "No file selected"
try:
self.cover_canvas.itemconfig(self.cover_canvas.image_id, state="hidden")
except _tkinter.TclError as e:
if str(e).startswith('image "pyimage') and str(e).endswith(f' doesn\'t exist'):
# Handle the case where the image with the given id doesn't exist
logger.warning("Attempted to configure an item with an image that no longer exists", exc_info=True)
else:
# If the error is caused by something else, re-raise the exception
raise e
self.backcover_canvas.itemconfig(self.backcover_canvas.image_id, state="hidden")
self.hide_actions()
def update_cover_image(self, loadedcomicinfo_list: list[LoadedComicInfo], **__):
if len(loadedcomicinfo_list) > 1:
self.clear()
# self.cover_subtitle.configure(text=MULTIPLE_FILES_SELECTED)
self.selected_file_var.set(MULTIPLE_FILES_SELECTED)
self.selected_file_path_var.set(MULTIPLE_FILES_SELECTED)
self.tooltip_filename.text = "\n".join(
[basename(loadedcomicinfo.file_path) for loadedcomicinfo in loadedcomicinfo_list])
# self.update()
self.toggle_action_buttons(False)
return
if not loadedcomicinfo_list:
# raise NoFilesSelected()
self.toggle_action_buttons(False)
return
loadedcomicinfo = loadedcomicinfo_list[0]
if loadedcomicinfo.file_path is None:
return
self.toggle_action_buttons(True)
self.displayed_cinfo = loadedcomicinfo
if not loadedcomicinfo.cover_cache and not loadedcomicinfo.backcover_cache:
self.clear()
else:
# self.update_cover_button.grid(column=0, row=1)
...
self.tooltip_filename.text = loadedcomicinfo.file_name
self.selected_file_var.set(loadedcomicinfo.file_name)
self.selected_file_path_var.set(loadedcomicinfo.file_path)
# Checks to display actions:
self.cover_action(loadedcomicinfo, auto_trigger=True)
# Update backcover
self.backcover_action(loadedcomicinfo, auto_trigger=True)
def hide_actions(self):
self.cover_canvas.itemconfig(self.cover_canvas.overlay_id, state="hidden")
self.cover_canvas.itemconfig(self.cover_canvas.action_id, state="hidden")
self.backcover_canvas.itemconfig(self.backcover_canvas.overlay_id, state="hidden")
self.backcover_canvas.itemconfig(self.backcover_canvas.action_id, state="hidden")
def display_action(self, _: str = None):
image = Image.open(
pathlib.Path(action_template))
image = image.resize((190, 260), Image.NEAREST)
self.watermark = ImageTk.PhotoImage(image, master=self.cover_canvas)
self._watermark_image_id = self.cover_canvas.create_image(150, 150, image=self.watermark)
self.cover_canvas.tag_lower(self._image_id)
self._text_id = self.cover_canvas.create_text(150, 285, text="", justify="center", fill="yellow",
font=('Helvetica 15 bold'))
self.cover_canvas.scale("all", -1, 1, 0.63, 0.87)
self.update()
self.cover_canvas.itemconfig(self._text_id, text="Replace")
self.update()
self.cover_canvas.itemconfig(self._text_id, text="Delete")
self.update()
self.cover_canvas.itemconfig(self._text_id, text="Append")
self.update()
def hide_back_image(self):
self.backcover_frame.pack_forget()
self.cover_frame.pack(side="top")
def show_back_image(self):
self.cover_frame.pack(side="left")
self.backcover_frame.pack(side="right")
def opencovers(self):
...
def display_next_cover(self, event):
...
def toggle_action_buttons(self, enabled=True):
for button in self.action_buttons:
button:Button
try:
button.configure(state="normal" if enabled else "disabled")
except:
pass
================================================
FILE: MangaManager/src/MetadataManager/GUI/widgets/ComboBoxWidget.py
================================================
from tkinter.ttk import Combobox
from src.MetadataManager.GUI.utils import validate_int
from .MMWidget import MMWidget
class ComboBoxWidget(MMWidget):
def __init__(self, master, cinfo_name:str, label_text=None, default_values=None, width=None, default="",
validation=None, tooltip: str = None):
super(ComboBoxWidget, self).__init__(master=master,name=cinfo_name.lower())
if label_text is None:
label_text = cinfo_name
self.name = cinfo_name
self.default = default
self.default_vals = default_values
# Label:
self.set_label(label_text, tooltip)
# Input:
self.validation = validation
vcmd = (self.register(validate_int), '%S')
if validation == "int":
self.widget: Combobox = Combobox(self, name=cinfo_name.lower(), values=default_values,
validate='key', validatecommand=vcmd)
else:
self.widget: Combobox = Combobox(self, name=cinfo_name.lower(), values=default_values)
if width is not None:
self.widget.configure(width=width)
================================================
FILE: MangaManager/src/MetadataManager/GUI/widgets/FileMultiSelectWidget.py
================================================
import copy
import logging
import tkinter
from tkinter.ttk import Treeview
from src.Common.LoadedComicInfo.LoadedComicInfo import LoadedComicInfo
logger = logging.getLogger()
class FileMultiSelectWidget(Treeview):
def __init__(self, *args, **kwargs):
super(FileMultiSelectWidget, self).__init__(*args, **kwargs)
self.heading('#0', text='Click to select all files', command=self.select_all)
# self.pack(expand=True, side="top")
self.bind('<>', self._on_select)
self._hook_items_inserted: list[callable] = []
self._hook_items_selected: list[callable] = []
self.content = {}
self.prev_selection = None
self.bind("", self.popup)
self.ctx_menu = tkinter.Menu(self, tearoff=0)
self.ctx_menu.add_command(label="{clicked_file}", state="disabled")
self.ctx_menu.add_separator()
self.ctx_menu.add_command(label="Open in Explorer", command=self.open_in_explorer)
self.ctx_menu.add_command(label="Reset changes", command=self.reset_loadedcinfo_changes,state="disabled")
def clear(self):
self.delete(*self.get_children())
def select_all(self, *_):
for item in self.get_children():
self.selection_add(item)
def get_selected(self) -> list[LoadedComicInfo]:
return [self.content.get(item) for item in self.selection()]
def insert(self, loaded_cinfo: LoadedComicInfo, *args, **kwargs):
super(FileMultiSelectWidget, self).insert("", 'end', loaded_cinfo.file_path, text=loaded_cinfo.file_name, tags=("darkmode", "important"), *args,
**kwargs)
self.content[loaded_cinfo.file_path] = loaded_cinfo
# self._call_hook_item_inserted(loaded_cinfo)
self.select_all()
def _on_select(self, *_):
prev_selection = copy.copy(self.prev_selection)
selected = [self.content.get(item) for item in self.selection()]
self.prev_selection = selected
if not selected:
return
self._call_hook_item_selected(selected, prev_selection)
##################
# Hook Stuff
##################
def add_hook_item_selected(self, function: callable):
self._hook_items_selected.append(function)
def add_hook_item_inserted(self, function: callable):
self._hook_items_inserted.append(function)
def _call_hook_item_selected(self, loaded_cinfo_list: list[LoadedComicInfo], prev_selection):
self._run_hook(self._hook_items_selected, loaded_cinfo_list, prev_selection)
def _call_hook_item_inserted(self, loaded_comicinfo: LoadedComicInfo):
self._run_hook(self._hook_items_inserted, [loaded_comicinfo])
def popup(self, event):
"""action in event of button 3 on tree view"""
# select row under mouse
iid = self.identify_row(event.y)
if iid:
# mouse pointer over item
self.selection_set(iid)
self.ctx_menu.entryconfigure(0, label=iid)
self.ctx_menu.entryconfigure(2,command=lambda x=iid: self.open_in_explorer(x))
self.ctx_menu.post(event.x_root, event.y_root)
else:
# mouse pointer not over item
# occurs when items do not fill frame
# no action required
pass
def open_in_explorer(self, event=None):
raise NotImplementedError()
def reset_loadedcinfo_changes(self, event=None):
raise NotImplementedError()
@staticmethod
def _run_hook(source: list[callable], *args):
for hook_function in source:
try:
hook_function(*args)
except:
logger.exception("Error calling hook")
================================================
FILE: MangaManager/src/MetadataManager/GUI/widgets/FormBundleWidget.py
================================================
from idlelib.tooltip import Hovertip
from tkinter import Frame, Label, Entry, Checkbutton, StringVar, BooleanVar
from src.MetadataManager.GUI.widgets import ComboBoxWidget
from src.Settings import SettingControl, SettingSection
class FormBundleWidget(Frame):
label: Label
input_widget: ComboBoxWidget | Checkbutton | Entry
input_var: StringVar | BooleanVar
# This is the frame that holds the bundle
row: Frame
control: SettingControl
section: SettingSection
validation_error: StringVar
# Reference held so UI can render it
validation_label: Label
validation_row: Frame
mapper_fn = None
def __init__(self, master, mapper_fn,name=None, *_, **kwargs):
super(FormBundleWidget, self).__init__(master, name=name, **kwargs)
self.mapper_fn = mapper_fn
self.row = Frame(master)
self.row.pack(expand=True, fill="x")
self.validation_row = Frame(master)
self.validation_row.pack(expand=True, fill="x")
self.pack(expand=True, fill='both', side='top')
def with_label(self, title, tooltip=""):
self.label = Label(master=self.row, text=title, width=30, justify="right", anchor="e")
if tooltip:
self.label.tooltip = Hovertip(self.label, tooltip, 20)
self.label.pack(side="left")
return self
def with_input(self, control: SettingControl, section: SettingSection):
entry, string_var = self.mapper_fn(self.row, control, section)
self.control = control
self.section = section
self.input_widget = entry
self.input_var = string_var
return self
def build(self):
self.validation_error = StringVar()
self.validation_error.set("")
self.validation_label = Label(master=self.validation_row, width=30, justify="right", anchor="e",
textvariable=self.validation_error, fg='red')
self.validation_label.pack(side="left")
self.validation_label.pack_forget()
return self
def validate(self):
if self.control.validate is None:
return True
error = self.control.validate(self.control.key, str(self.input_var.get()))
self.validation_error.set(error)
has_error = error != ""
if has_error:
self.validation_label.pack()
else:
self.validation_label.pack_forget()
return not has_error
def format_output(self):
if self.control.format_value is None:
return str(self.input_var.get())
return self.control.format_value(self.input_var.get())
================================================
FILE: MangaManager/src/MetadataManager/GUI/widgets/HyperlinkLabelWidget.py
================================================
import webbrowser
from tkinter import Frame, Label
class HyperlinkLabelWidget(Frame):
def __init__(self, master=None, text="", url="", url_text=None, **kwargs):
Frame.__init__(self, master, **kwargs)
self.url = url
self.label = Label(self,text=text, font=("Helvetica", 12), justify="left")
self.label.pack(side="left")
self.url_label = Label(self, text=url_text if url_text else url, font=("Helvetica", 12), justify="left")
self.url_label.configure(foreground="blue", underline=True)
self.url_label.pack(side="left")
self.url_label.bind("<1>", lambda e: self.open_url())
self.url_label.bind("", lambda e: self.configure(cursor="hand2"))
self.url_label.bind("", lambda e: self.configure(cursor=""))
def open_url(self):
webbrowser.open(self.url)
def set_text(self, text):
self.configure(text=text)
def set_url(self, url):
self.url = url
================================================
FILE: MangaManager/src/MetadataManager/GUI/widgets/LongTextWidget.py
================================================
from tkinter.scrolledtext import ScrolledText
from .MMWidget import MMWidget, _LongText
class LongTextWidget(MMWidget):
def __init__(self, master, cinfo_name, label_text=None, width: int = None):
super(LongTextWidget, self).__init__(master,name=cinfo_name.lower())
if label_text is None:
label_text = cinfo_name
self.set_label(label_text)
self.default = ""
self.name = cinfo_name
# Input
self.widget_slave = ScrolledText(self)
self.widget_slave.configure(height='5', width=width)
self.widget_slave.pack(fill='both', side='top')
self.widget = _LongText(name=cinfo_name)
self.widget.linked_text_field = self.widget_slave
================================================
FILE: MangaManager/src/MetadataManager/GUI/widgets/MMWidget.py
================================================
from idlelib.tooltip import Hovertip
from tkinter.ttk import Combobox, OptionMenu, Frame, Label
from tkinter import Text, INSERT, END
from src.MetadataManager.GUI.utils import validate_int
# This class is used for LongTextWidget but it itself isn't technically a widget.
# We have it here as MM needs it but LongTextWidget needs MMWidget
class _LongText:
"""
Helper class to have a multi line summary input
"""
linked_text_field: Text | None = None
name: str
_value: str = ""
def __init__(self, name=None):
if name:
self.name = name
def set(self, value: str):
"""
Sets the text to be displayed in the input field
:param value: The text to be displayed
:return:
"""
if not self.linked_text_field: # If it's not defined then UI is not being use. Store value in class variable.
self._value = value
return # self._value
self.linked_text_field.delete(1.0, END)
self.linked_text_field.insert(INSERT, value)
def clear(self):
"""
Clears the input text and sets it to empty string
:return:
"""
if not self.linked_text_field:
self._value = ""
return
self.linked_text_field.delete(1.0, END)
def get(self) -> str:
"""
Returns the value in the input field
:return:
"""
if not self.linked_text_field: # If it's not defined then UI is not being use. Store value in class variable.
return self._value
return self.linked_text_field.get(index1="1.0", index2='end-1c')
def __str__(self):
return self.name
class MMWidget(Frame):
validation: str | None = None
widget_slave = None
widget: Combobox | _LongText | OptionMenu
name: str
NONE = "~~# None ##~~"
def __init__(self, master,name):
super(MMWidget, self).__init__(master,name=name)
def set(self, value):
if value is None:
return
if not self.validation:
self.widget.set(value)
return
if value and validate_int(value):
if self.validation == "rating" and (float(value) < 0 or float(value) > 10):
return
self.widget.set(str(int(value)))
def set_default(self):
self.widget.set("")
def get(self):
return self.widget.get()
def pack(self, **kwargs):
widget = self.widget_slave or self.widget
widget.pack(fill="both", side="top")
super(Frame, self).pack(kwargs or {"fill": "both", "side": "top"})
return self
def grid(self, row=None, column=None, **kwargs):
widget = self.widget_slave or self.widget
widget.pack(fill="both", side="top")
super(Frame, self).grid(row=row, column=column, sticky="we", **kwargs)
return self
def set_label(self, text, tooltip=None):
self.label = Label(self, text=text)
if text:
self.label.pack(side="top")
if tooltip:
self.label.configure(text=self.label.cget('text') + ' ⁱ')
self.label.tooltip = Hovertip(self.label, tooltip, 20)
================================================
FILE: MangaManager/src/MetadataManager/GUI/widgets/MessageBoxWidget.py
================================================
from tkinter import Button, Label, Frame, Toplevel
from src.MetadataManager.GUI.scrolledframe import ScrolledFrame
from src.MetadataManager.GUI.utils import center
class MessageBoxButton:
def __init__(self, id, title):
self.id = id
self.title = title
class MessageBoxWidget(Toplevel):
"""A Toplevel widget that represents a message box with a title, content, and buttons."""
icon_warning = "::tk::icons::warning"
icon_error = "::tk::icons::error"
icon_information = "::tk::icons::information"
icon_question = "::tk::icons::question"
selected_value = None
disabled = False
def __init__(self, *args, **kwargs):
super().__init__()
if self.disabled:
self.wm_deiconify()
self.destroy()
return
# Setting Geometry
self.geometry("500x260+100+100")
content = Frame(self)
self.label = Label(content, text="", font=("Helvetica", 16), justify="center")
self.label.configure(wraplength=500)
self.label.pack(side="top", fill="x", expand=False)
self.description = Label(content, text="", font=("monospace", 12), justify="center")
self.description.configure(wraplength=500)
self.description.pack(side="top", fill="x", expand=False)
self._scrolled_frame = ScrolledFrame(master=content, scrolltype="vertical", usemousewheel=True)
self.content_frame = self._scrolled_frame.innerframe
content.pack(side="top", fill="both")
self.control_frame = Frame(self,height=50)
self.control_frame.pack(pady=10, side="bottom", anchor="center")
# Removing titlebar from the Dialogue
self.overrideredirect(False)
# Making MessageBox Visible
center(self)
# Make the windows always on top
self.attributes("-topmost", True)
self.lift()
# Force focus on this window
self.grab_set()
def with_icon(self, icon_path):
"""Adds an icon to the MessageBoxWidget.
:param icon_path: The path to the icon image.
:return: The MessageBoxWidget instance.
"""
if not self.disabled:
self.label.configure(image=icon_path, compound="left")
return self
def with_title(self, title):
"""
Adds a title to the MessageBoxWidget.
:param title: The text for the title.
:return: The MessageBoxWidget instance.
"""
if not self.disabled:
self.title = title
self.label.configure(text=title)
return self
def with_description(self, description):
if not self.disabled:
self.description.configure(text=description)
return self
def with_content(self, content_frame: Frame):
"""
Adds content to the MessageBoxWidget.
:param content_frame: The content frame.
:return: The MessageBoxWidget instance.
"""
if not self.disabled:
self._scrolled_frame.pack(fill="both", expand=True)
self.content_frame.pack()
content_frame.master = self.content_frame
return self
def with_actions(self, action_buttons: list[MessageBoxButton]):
"""
Adds buttons to the MessageBoxWidget.
:param action_buttons: A list of MessageBoxButton instances.
:return: The MessageBoxWidget instance.
"""
if not self.disabled:
# button is a MessageBoxButton class with id, title
for button in action_buttons:
Button(self.control_frame, text=button.title, padx=10, pady=5, borderwidth=3,
command=lambda btn=button: self._set_selected_value(btn.id)).pack(side="left", ipadx=20)
Label(self.control_frame).pack(side="left", padx=5)
return self
def build(self):
"""
Builds the MessageBoxWidget.
:return: The MessageBoxWidget instance.
"""
return self
def prompt(self):
"""
Displays the MessageBoxWidget and waits for a button press.
:return: The selected value (the value of the pressed button).
"""
# Set to wait for window to get deleted.
self.wm_protocol("WM_DELETE_WINDOW", self.destroy)
self.wait_window(self)
# return the value
return self.selected_value
def _set_selected_value(self, value):
"""
Sets the selected value of the MessageBoxWidget.
:param value: The selected value.
"""
self.selected_value = value
self.destroy()
================================================
FILE: MangaManager/src/MetadataManager/GUI/widgets/OptionMenuWidget.py
================================================
import logging
import tkinter
from tkinter.ttk import Combobox
from common.models import AgeRating, Formats, YesNo, Manga
from .MMWidget import MMWidget
logger = logging.getLogger()
class OptionMenuWidget(MMWidget):
def __init__(self, master: tkinter.Frame, cinfo_name, label_text=None, width=None, max_width=None, default=None, values=None):
super(OptionMenuWidget, self).__init__(master=master,name=cinfo_name.lower())
if values is None:
values = []
if label_text is None:
label_text = cinfo_name
self.default = default
self.name = cinfo_name
self.set_label(label_text)
# noinspection PyTypeChecker
self.widget = tkinter.StringVar(self, name=cinfo_name, value=default)
self.widget_slave: Combobox = Combobox(self, textvariable=self.widget)
self.widget_slave.configure(state="readonly")
if width:
self.widget_slave.configure(width=width)
self.update_listed_values(self.default, list(values))
# noinspection PyUnresolvedReferences
if max_width:
self.widget_slave.configure(width=max_width)
def update_listed_values(self, default_selected, values) -> None:
self.widget_slave["values"] = list(values)
self.widget_slave.set(default_selected)
def get_options(self) -> list[str]:
values_list = []
match self.name:
case "AgeRating":
values_list = AgeRating.list()
case "Format":
values_list = list(Formats)
case "BlackAndWhite":
values_list = YesNo.list()
case "Manga":
values_list = Manga.list()
case _:
logger.error(f"Unhandled error. '{self.name}' is not a registered widget which can extract options from")
return values_list
def append_first(self, value: str):
self.update_listed_values(value, [value] + self.get_options())
def remove_first(self):
self.update_listed_values("", self.get_options())
================================================
FILE: MangaManager/src/MetadataManager/GUI/widgets/ProgressBarWidget.py
================================================
import logging
import tkinter
from tkinter.ttk import Progressbar, Style
from src.Common.progressbar import ProgressBar
logger = logging.getLogger()
class ProgressBarWidget(ProgressBar):
def __init__(self, parent):
pb_frame = tkinter.Frame(parent)
pb_frame.pack(expand=False, fill="x")
super().__init__()
self.style = Style(pb_frame)
self.style.layout('text.Horizontal.TProgressbar',
[
('Horizontal.Progressbar.trough',
{
'children': [
('Horizontal.Progressbar.pbar',
{
'side': 'left',
'sticky': 'ns'
}
)
],
'sticky': 'nswe'
}
),
('Horizontal.Progressbar.label',
{
'sticky': 'nswe'
}
)
]
)
self.style.configure('text.Horizontal.TProgressbar', text='0 %', anchor='center')
self.progress_bar = Progressbar(pb_frame, length=10, style='text.Horizontal.TProgressbar',
mode="determinate") # create progress bar
self.progress_bar.pack(expand=False, fill="x", side="top")
self.pb_label_variable = tkinter.StringVar(value=self.label_text)
self.pb_label = tkinter.Label(pb_frame, justify="right", textvariable=self.pb_label_variable)
self.pb_label.pack(expand=False, fill="x", side="right")
logger.debug("Initialized progress bar")
def update_progress_label(self):
self.pb_label_variable.set(self.label_text)
def _update(self):
if not self.timer:
return
if self.processed >= self.total:
self.timer.stop()
self.update_progress_label()
self.style.configure('text.Horizontal.TProgressbar',
text='{:g} %'.format(round(self.percentage, 2))) # update label
self.progress_bar['value'] = self.percentage
self.progress_bar.update()
================================================
FILE: MangaManager/src/MetadataManager/GUI/widgets/ScrolledFrameWidget.py
================================================
import tkinter
from tkinter import Frame
from src.MetadataManager.GUI.scrolledframe import ScrolledFrame
class ScrolledFrameWidget(ScrolledFrame):
def __init__(self, master, *_, **kwargs):
super(ScrolledFrameWidget, self).__init__(master, **kwargs)
self.configure(usemousewheel=True)
self.paned_window = tkinter.PanedWindow(self.innerframe)
self.paned_window.pack(fill="both", expand=False)
self.pack(expand=False, fill='both', side='top')
def create_frame(self, **kwargs):
"""Creates a subframe and packs it"""
frame = Frame(self.paned_window)
frame.pack(**kwargs or {})
# frame.pack()
self.paned_window.add(frame)
return frame
================================================
FILE: MangaManager/src/MetadataManager/GUI/widgets/WidgetManager.py
================================================
from .OptionMenuWidget import OptionMenuWidget
from .LongTextWidget import LongTextWidget
from .ComboBoxWidget import ComboBoxWidget
class WidgetManager:
cinfo_tags: list[str] = list()
def get_widget(self, name) -> ComboBoxWidget | LongTextWidget | OptionMenuWidget:
return getattr(self, name)
def add_widget(self, name, widget_frame: ComboBoxWidget | LongTextWidget | OptionMenuWidget):
self.cinfo_tags.append(name)
setattr(self, name, widget_frame)
def __setattr__(self, key, value):
self.cinfo_tags.append(key)
object.__setattr__(self, key, value)
def clean_widgets(self):
for widget_name in self.__dict__:
widget = self.get_widget(widget_name)
widget.set_default()
if isinstance(widget, type(ComboBoxWidget)):
widget.widget['values'] = widget.default_vals or []
def toggle_widgets(self, enabled=True):
for widget_name in self.__dict__:
widget = self.get_widget(widget_name)
if isinstance(widget, type(OptionMenuWidget)):
widget.widget_slave.configure(state="normal" if enabled else "disabled")
elif isinstance(widget, LongTextWidget):
#widget.widget_slave.configure(state="normal" if enabled else "readonly")
pass
elif isinstance(widget, ComboBoxWidget):
widget.widget.configure(state="normal" if enabled else "disabled")
def get_tags(self):
return [tag for tag in self.cinfo_tags]
================================================
FILE: MangaManager/src/MetadataManager/GUI/widgets/__init__.py
================================================
from .MMWidget import MMWidget
from .OptionMenuWidget import OptionMenuWidget
from .LongTextWidget import LongTextWidget
from .ScrolledFrameWidget import ScrolledFrameWidget
from .FileMultiSelectWidget import FileMultiSelectWidget
from .ProgressBarWidget import ProgressBarWidget
from .ButtonWidget import ButtonWidget
from .WidgetManager import WidgetManager
from .ComboBoxWidget import ComboBoxWidget
from .AutocompleteComboboxWidget import AutocompleteComboboxWidget
from .HyperlinkLabelWidget import HyperlinkLabelWidget
from .CanvasCoverWidget import CanvasCoverWidget
================================================
FILE: MangaManager/src/MetadataManager/GUI/windows/AboutWindow.py
================================================
import logging
import tkinter
from typing import NamedTuple
import requests
from src.MetadataManager.GUI.widgets import HyperlinkLabelWidget, ButtonWidget
from src.__version__ import __version__
log = logging.getLogger('AboutWindow')
Versions = NamedTuple('Versions', [('prev_latest', str), ('latest', str), ('prev_nightly', str)])
def get_release_tag() -> Versions:
"""
Get the latest release tag from GitHub
:return: previous latest release tag, latest release tag
"""
# Replace these values with your own
username = 'MangaManagerOrg'
repo_name = 'Manga-Manager'
# Make a GET request to the GitHub API endpoint to get the releases
response = requests.get(f'https://api.github.com/repos/{username}/{repo_name}/releases', params={'per_page': 100})
# Parse the JSON response
data = response.json()
# Filter out pre-releases
latest_release:dict = None
prev_latest_release:dict = None
nightly_release:dict = None
for release in data:
if latest_release and prev_latest_release and nightly_release:
break
if release['draft']:
continue
if release['prerelease']:
if not nightly_release:
nightly_release = release
else:
if not latest_release:
latest_release = release
else:
prev_latest_release = latest_release
if nightly_release['published_at'] < latest_release['published_at']:
nightly_release = latest_release
return Versions(prev_latest_release["tag_name"], latest_release["tag_name"],nightly_release["tag_name"])
class AboutWindow:
top_level = None
frame = None
def __init__(self, parent):
self.top_level = tkinter.Toplevel(parent)
self.frame = tkinter.Frame(self.top_level)
self.frame.pack(pady=30, padx=30, fill="both")
HyperlinkLabelWidget(self.frame, "Github repo:", url_text="Go to Github rework main page",
url="https://github.com/MangaManagerORG/Manga-Manager/tree/rework/master") \
.pack(fill="x", expand=True, side="top", anchor="center")
HyperlinkLabelWidget(self.frame, "Get support:", url_text="Join MangaManager channel in Kavita discord",
url="https://discord.gg/kavita-821879810934439936")\
.pack(fill="x", expand=True, side="top", anchor="center")
HyperlinkLabelWidget(self.frame, "Report issue in GitHub", url_text="Create GitHub Issue",
url="https://github.com/MangaManagerORG/Manga-Manager/issues/new?assignees=ThePromidius&labels=Rework+Issue&template=rework_issue.md&title=%5BRework+Issue%5D").pack(
fill="x", expand=True, side="top", anchor="center")
HyperlinkLabelWidget(self.frame, "Donate in Ko-fi",
"https://ko-fi.com/thepromidius")\
.pack(fill="x", expand=True, side="top", anchor="center")
tkinter.Label(self.frame, text="", font=("Helvetica", 12), justify="left")\
.pack(fill="x", expand=True, side="top", anchor="center")
tkinter.Label(self.frame, text="Software licensed under the GNU General Public License v3.0",
font=("Helvetica", 12), justify="left").pack(fill="x", expand=True, side="top", anchor="center")
version_url = "https://github.com/MangaManagerORG/Manga-Manager/releases/latest"
parsed_version = __version__.split(":")
version = __version__
releases = get_release_tag()
if len(parsed_version) > 2:
if parsed_version[1].startswith("nightly"):
version_url = f"https://github.com/MangaManagerOrg/Manga-Manager/compare/{releases.prev_nightly}...{parsed_version[2]}"
if parsed_version[1].startswith("stable"):
version_url = f"https://github.com/MangaManagerOrg/Manga-Manager/compare/{releases.prev_latest}...{releases.latest}"
version = f"{parsed_version[0]}:stable"
HyperlinkLabelWidget(self.frame, "Version number", url_text=version,
url=version_url).pack(fill="x", expand=True, side="top", anchor="center", pady=10)
# create close button
ButtonWidget(master=self.frame, text="Close", command=self.close).pack()
def close(self):
self.top_level.destroy()
================================================
FILE: MangaManager/src/MetadataManager/GUI/windows/DragAndDrop.py
================================================
import re
import tkinter as tk
from tkinterdnd2 import DND_FILES, TkinterDnD
def extract_paths_from_string(input_string):
return re.findall(r'{(.*?)}', input_string)
class DragAndDropFilesApp(TkinterDnD.Tk):
def __init__(self):
super().__init__()
self.title("Drag and Drop Files")
self.geometry("400x400")
self.lb = tk.Listbox(self)
self.lb.insert(1, "Drag files here")
# Register the Listbox as a drop target
self.lb.drop_target_register(DND_FILES)
self.lb.dnd_bind('<>', self.on_drop)
self.lb.pack(fill="both")
def on_drop(self, event):
# Get the list of filenames from the dropped data
files_str = event.data
files = extract_paths_from_string(files_str)
# Split the string into individual filenames
# Process the dropped files and add their filenames to the Listbox
for file in files:
self.lb.insert(tk.END, file)
if __name__ == "__main__":
app = DragAndDropFilesApp()
app.mainloop()
================================================
FILE: MangaManager/src/MetadataManager/GUI/windows/LoadingWindow.py
================================================
import tkinter
from unittest.mock import Mock
from src.Common.progressbar import ProgressBar
from src.MetadataManager.GUI.utils import center
from src.MetadataManager.GUI.widgets import ProgressBarWidget
class LoadingWindow(tkinter.Toplevel):
initialized: bool = False
# abort_flag:bool = None
def __new__(cls, total, *args, **kwargs):
if total <=1:
a = Mock()
a.is_abort = lambda :False
a.abort_flag = False
return a
else:
return super(LoadingWindow, cls).__new__(cls)
def __init__(self, total):
super().__init__()
content = tkinter.Frame(self,background="white",borderwidth=3,border=3,highlightcolor="black",highlightthickness=2,highlightbackground="black")
content.pack(ipadx=20, ipady=20,expand=False,fill="both")
self.title = "Loading Files"
self.loading_label_value = tkinter.StringVar(content, name="Loading_label")
self.loading_label = tkinter.Label(content, textvariable=self.loading_label_value)
# Removing titlebar from the Dialogue
self.geometry("300x100+30+30")
# Make the windows always on top
self.attributes("-topmost", True)
self.lift()
self.abort_flag = False
# Force focus on this window
self.grab_set()
center(self)
self.overrideredirect(True)
self.pb = ProgressBarWidget(content)
self.pb.pb_label.configure(justify="center",background="white")
self.pb.pb_label.pack(expand=False, fill="x", side="top")
self.pb.set_template(f"Loaded:{ProgressBar.PROCESSED_TAG}/{ProgressBar.TOTAL_TAG}\n")
self.pb.start(total)
abort_btn = tkinter.Button(content,text="Abort",command=self.set_abort)
abort_btn.pack()
self.initialized = True
def is_abort(self):
return self.abort_flag
def set_abort(self,*_):
if self.initialized:
self.abort_flag = True
self.pb.set_template("Aborting...\n")
self.after(2000, self.finish_loading)
#
# self.pb = ProgressBar()
# self.pb.set_template(f"Loaded:{ProgressBar.PROCESSED_TAG}/{ProgressBar.TOTAL_TAG}\n")
def loaded_file(self, value: str):
if self.initialized:
self.pb.set_template(f"Loading: {ProgressBar.PROCESSED_TAG}/{ProgressBar.TOTAL_TAG}\nLast loaded: '{value}'")
self.pb.increase_processed()
def finish_loading(self):
if self.initialized:
self.grab_release()
self.destroy()
if __name__ == '__main__':
root = tkinter.Tk()
a = LoadingWindow(2,False)
a.loaded_file("asda")
root.mainloop()
================================================
FILE: MangaManager/src/MetadataManager/GUI/windows/MainWindow.py
================================================
import json
import re
import tkinter
from tkinter import Frame, ttk
from tkinter.ttk import Notebook
from tkinterdnd2 import DND_FILES
from src.__version__ import __version__
from common.models import Formats, AgeRating
from src.Common import ResourceLoader
from src.MetadataManager.CoverManager.CoverManager import CoverManager
from src.MetadataManager.GUI.ExceptionWindow import ExceptionFrame
from src.MetadataManager.GUI.widgets import ScrolledFrameWidget, ButtonWidget, FileMultiSelectWidget, ProgressBarWidget, \
ComboBoxWidget, LongTextWidget, OptionMenuWidget
from src.MetadataManager.GUI.widgets.CanvasCoverWidget import CoverFrame
from src.MetadataManager.MetadataManagerGUI import GUIApp
with open(ResourceLoader.get('languages.json'), 'r', encoding="utf-8") as f:
data = json.loads(f.read())
languages = [language["isoCode"] for language in data]
EXTRACT_PATHS = re.compile(r'{(.*?)}')
class MainWindow(GUIApp):
# The clear button
clear_btn = None
fetch_online_btn = None
process_btn = None
fill_from_filename_btn = None
cover_manager_btn = None
def __init__(self):
super().__init__()
self.title("Manga Manager: v" + __version__.split(':')[0])
#########################################################
# GUI Display Methods
############
# Overview LAYOUT
self.control_frame_top = Frame(self.main_frame,name="control_frame")
self.control_frame_top.pack(fill="x", side="top",padx=30,pady=3)
self.display_menu_bar()
ttk.Separator(self.main_frame, orient='horizontal').pack(fill="x")
mid_content_frame = Frame(self.main_frame,name="mid_content_frame")
mid_content_frame.pack(fill="both", expand=True)
self.file_selection_frame_left = Frame(mid_content_frame,name="file_selection_frame")
self.file_selection_frame_left.pack(side="left", padx=30, expand=False, fill="both")
self.display_side_bar()
self.main_content_frame_right = Frame(mid_content_frame,pady=10,name="main_content_frame_right")
self.main_content_frame_right.pack(fill="both", side="right", expand=True, padx=(0, 20))
self.init_main_content_frame()
self.display_main_content_widgets()
ttk.Separator(self.main_frame, orient='horizontal').pack(fill="x")
self.selection_progress_frame_bottom = Frame(self.main_frame)
self.selection_progress_frame_bottom.pack(fill="x", side="bottom", pady=(5, 2))
self.display_bottom_frame()
def display_side_bar(self) -> None:
################
# Sidebar actions and covers
################
self.side_info_frame = self.file_selection_frame_left
# Show Selected Files - ListBox
self.files_selected_frame = tkinter.LabelFrame(self.side_info_frame)
self.files_selected_frame.selected_files_label = tkinter.Label(self.files_selected_frame, text="Opened Files:")
self.files_selected_frame.selected_files_label.pack(expand=False, fill="x")
self.selected_files_treeview = FileMultiSelectWidget
self.selected_files_treeview.open_in_explorer = self._treeview_open_explorer
self.selected_files_treeview.reset_loadedcinfo_changes = self._treview_reset
self.selected_files_treeview = self.selected_files_treeview(self.files_selected_frame)#, padding=[-15, 0, 0, 0]) # padding -15 to remove the left indent
self.selected_files_treeview.drop_target_register(DND_FILES)
self.selected_files_treeview.dnd_bind('<>', self.on_drop)
self.selected_files_treeview.pack(expand=True, fill="both")
# Selected Covers
self.image_cover_frame = CoverFrame(self.side_info_frame)
self.selected_files_treeview.add_hook_item_selected(self.on_file_selection_preview)
# self.selected_files_treview.update_cover_image = self.image_cover_frame.update_cover_image TODO:this is commented check if needed. Levaing it as it is in merge
self.image_cover_frame.pack(expand=False, fill='x')
self.files_selected_frame.pack(expand=True, fill="both", pady=(20, 0))
def display_menu_bar(self) -> None:
# Action Buttons
control_frame = self.control_frame_top
btn = ButtonWidget(master=control_frame, text="Open Files",
tooltip="Load the metadata and cover to edit them (Ctrl+O)")
btn.configure(image=self.open_file_icon, command=self.select_files, compound="left")
btn.pack(side="left", fill="y", padx=(0, 5))
self.control_mngr.append(btn)
btn = ButtonWidget(master=control_frame, text="Open Folder")
btn.configure(image=self.open_folder_icon, command=self.select_folder, compound="left")
btn.pack(side="left", fill="y", padx=(0, 5))
self.control_mngr.append(btn)
self.clear_btn = ButtonWidget(master=control_frame, text="Clear", tooltip="Clean the metadata from the current view")
self.clear_btn.configure(image=self.clear_icon, command=self.widget_mngr.clean_widgets, compound="left")
self.clear_btn['state'] = 'disabled'
self.clear_btn.pack(side="left", fill="y", padx=(0, 5))
self.control_mngr.append(self.clear_btn)
self.fetch_online_btn = ButtonWidget(master=control_frame, text="Fetch\n Online")
self.fetch_online_btn.configure(image=self.fetch_online_icon, command=self.process_fetch_online, compound="left")
self.fetch_online_btn['state'] = 'disabled'
self.fetch_online_btn.pack(side="left", fill="y", padx=(0, 5))
self.control_mngr.append(self.fetch_online_btn)
self.process_btn = ButtonWidget(master=control_frame, text="Process", tooltip="Save the metadata and cover changes (Ctrl+S)")
self.process_btn.configure(command=self.pre_process, image=self.save_icon, compound="left")
self.process_btn['state'] = 'disabled'
self.process_btn.pack(side="left", fill="y", padx=(0, 5))
self.control_mngr.append(self.process_btn)
self.fill_from_filename_btn = ButtonWidget(master=control_frame, text="Filename Fill", tooltip="Fill data from Filename")
self.fill_from_filename_btn.configure(image=self.filename_fill_icon, command=self.fill_from_filename, compound="left")
self.fill_from_filename_btn['state'] = 'disabled'
self.fill_from_filename_btn.pack(side="left", fill="y", padx=(0, 5))
self.control_mngr.append(self.fill_from_filename_btn)
self.cover_manager_btn = ButtonWidget(master=control_frame, text="Cover Manager", tooltip="Opens covermanager for the loaded files")
self.cover_manager_btn.configure(command=lambda: CoverManager(self, self))
self.cover_manager_btn['state'] = 'disabled'
self.cover_manager_btn.pack(side="left", fill="y", padx=(0, 5))
self.control_mngr.append(self.cover_manager_btn)
def init_main_content_frame(self) -> None:
self.notebook = Notebook(self.main_content_frame_right)
self.notebook.pack(expand=True, fill="both")
tab_1 = ScrolledFrameWidget(self.notebook, scrolltype="vertical")
self.basic_info_frame = tab_1.create_frame()
self.notebook.add(tab_1, text="Basic Info")
tab_2 = ScrolledFrameWidget(self.notebook, scrolltype="vertical")
self.people_info_frame = tab_2.create_frame()
# self.people_info_frame.configure(padx=20)
self.notebook.add(tab_2, text="People Info")
tab_3 = ScrolledFrameWidget(self.notebook, scrolltype="vertical")
self.numbering_info_frame = tab_3.create_frame()
# self.numbering_info_frame.configure(padx=20)
self.notebook.add(tab_3, text="Extended")
extension_tab = ScrolledFrameWidget(self.notebook, scrolltype="Vertical")
self.extensions_tab_frame = extension_tab.create_frame()
self.notebook.add(extension_tab, text="Extensions")
errors_tab = ScrolledFrameWidget(self.notebook, scrolltype="Vertical")
errors_tab.pack(fill="both",expand=True)
errors_tab.paned_window.pack(fill="both",expand=True)
self.errors_tab_frame = errors_tab.create_frame(fill="both",expand=True)
self.notebook.add(errors_tab, text="Errors")
ExceptionFrame(master=self.errors_tab_frame,is_test=self.is_test).pack(fill="both",expand=True)
self.display_extensions(self.extensions_tab_frame)
self.changes_saved = tkinter.Label(master=self, text="Changes are not saved", font=('Arial', 10))
self.focus()
def display_main_content_widgets(self) -> None:
#################
# Basic info - first column
#################
parent_frame = Frame(self.basic_info_frame, padx=20)
parent_frame.pack(side="right", expand=True, fill="both")
frame = Frame(parent_frame)
frame.pack(fill="both", side="top")
label = tkinter.Label(frame, text="Series")
label.pack(fill="x", expand=False, side="top")
self.widget_mngr.Series = ComboBoxWidget(frame, cinfo_name="Series", label_text="",
tooltip="The name of the series").pack(side="left", expand=True,
fill="x")
self.widget_mngr.Series.label = label
btn = ButtonWidget(master=frame, text="⋯", tooltip="If one file selected, load the filename",
command=self._fill_filename)
btn.pack(side="right")
self.control_mngr.append(btn)
btn = ButtonWidget(master=frame, text="⋯F", tooltip="If one file selected, load the FOLDER name",
command=self._fill_foldername)
btn.pack(side="right")
self.control_mngr.append(btn)
self.widget_mngr.LocalizedSeries = ComboBoxWidget(parent_frame, cinfo_name="LocalizedSeries",
label_text="LocalizedSeries",
tooltip="The translated series name").pack()
self.widget_mngr.SeriesSort = ComboBoxWidget(parent_frame, cinfo_name="SeriesSort",
label_text="Series Sort").pack()
self.widget_mngr.Title = ComboBoxWidget(parent_frame, cinfo_name="Title",
tooltip="The title of the chapter").pack()
# Summary and Review widget
long_text_notebook = Notebook(parent_frame, height=95)
long_text_notebook.pack(fill="x", expand=False, pady=(14, 5))
tab = ScrolledFrameWidget(long_text_notebook, scrolltype="vertical")
summary_frame = tab.create_frame(fill="both", expand=True)
long_text_notebook.add(tab, text="Summary")
self.widget_mngr.Summary = LongTextWidget(summary_frame, cinfo_name="Summary", label_text="").pack(fill="both",
expand="True")
tab = ScrolledFrameWidget(long_text_notebook, scrolltype="vertical",)
review_frame = tab.create_frame(fill="both",expand=True)
long_text_notebook.add(tab, text="Review")
self.widget_mngr.Review = LongTextWidget(review_frame, cinfo_name="Review", label_text="").pack(fill="both",
expand="True")
self.widget_mngr.Genre = ComboBoxWidget(parent_frame, cinfo_name="Genre").pack()
self.widget_mngr.Tags = ComboBoxWidget(parent_frame, cinfo_name="Tags").pack()
self.widget_mngr.Web = ComboBoxWidget(parent_frame, cinfo_name="Web").pack()
combo_width = 17
numbering = Frame(parent_frame)
numbering.columnconfigure("all", weight=0)
numbering.pack(fill="both", expand=True)
self.widget_mngr.Number = ComboBoxWidget(numbering, "Number", width=combo_width,
tooltip="The chapter absolute number") \
.pack(side="left", expand=False, fill="x")
self.widget_mngr.Volume = ComboBoxWidget(numbering, "Volume", width=combo_width,
validation="int", default="-1") \
.pack(side="left", expand=False, fill="x", padx=(10, 0))
self.widget_mngr.Count = ComboBoxWidget(numbering, "Count", width=combo_width,
validation="int", default="-1") \
.pack(side="left", expand=False, fill="x", padx=(10, 0))
self.widget_mngr.Format = OptionMenuWidget(numbering, "Format", "Format", combo_width, 18, "",
Formats) \
.pack(side="left", expand=False, fill="x", padx=(10, 0))
self.widget_mngr.Manga = OptionMenuWidget(numbering, "Manga", "Manga", combo_width, 18,
"Unknown", ("Unknown", "Yes", "No", "YesAndRightToLeft")) \
.pack(side="left", expand=False, fill="x", padx=(10, 0))
numbering2 = Frame(parent_frame)
numbering2.columnconfigure("all", weight=0)
numbering2.pack(fill="both", expand=True)
self.widget_mngr.Year = ComboBoxWidget(numbering2, "Year", width=combo_width,
validation="int", default="-1") \
.pack(side="left", expand=False, fill="x")
self.widget_mngr.Month = ComboBoxWidget(numbering2, "Month", width=combo_width,
validation="int", default="-1") \
.pack(side="left", expand=False, fill="x", padx=(10, 0))
self.widget_mngr.Day = ComboBoxWidget(numbering2, "Day", width=combo_width,
validation="int", default="-1") \
.pack(side="left", expand=False, fill="x", padx=(10, 0))
self.widget_mngr.AgeRating = OptionMenuWidget(numbering2, "AgeRating", "Age Rating", combo_width, 18,
"Unknown", AgeRating.list()) \
.pack(side="left", expand=False, fill="x", padx=(10, 0))
self.widget_mngr.LanguageISO = ComboBoxWidget(numbering2, "LanguageISO", label_text="Language ISO",
width=combo_width + 1, default="", default_values=languages) \
.pack(side="left", expand=False, fill="x", padx=(10, 0))
self.widget_mngr.Notes = ComboBoxWidget(parent_frame, cinfo_name="Notes").pack()
#################
# People column
#################
parent_frame = Frame(self.people_info_frame, padx=20)
parent_frame.pack(side="right", expand=True, fill="both")
self.widget_mngr.Writer = ComboBoxWidget(parent_frame, "Writer").pack()
self.widget_mngr.Penciller = ComboBoxWidget(parent_frame, "Penciller").pack()
self.widget_mngr.Inker = ComboBoxWidget(parent_frame, "Inker").pack()
self.widget_mngr.Colorist = ComboBoxWidget(parent_frame, "Colorist").pack()
self.widget_mngr.Letterer = ComboBoxWidget(parent_frame, "Letterer").pack()
self.widget_mngr.CoverArtist = ComboBoxWidget(parent_frame, "CoverArtist", label_text="Cover Artist").pack()
self.widget_mngr.Editor = ComboBoxWidget(parent_frame, "Editor").pack()
self.widget_mngr.Translator = ComboBoxWidget(parent_frame, "Translator").pack()
self.widget_mngr.Publisher = ComboBoxWidget(parent_frame, "Publisher").pack()
self.widget_mngr.Imprint = ComboBoxWidget(parent_frame, "Imprint").pack()
self.widget_mngr.Characters = ComboBoxWidget(parent_frame, "Characters").pack()
self.widget_mngr.Teams = ComboBoxWidget(parent_frame, "Teams").pack()
self.widget_mngr.Locations = ComboBoxWidget(parent_frame, "Locations").pack()
self.widget_mngr.MainCharacterOrTeam = ComboBoxWidget(parent_frame, "MainCharacterOrTeam",
label_text="Main Character Or Team").pack()
self.widget_mngr.Other = ComboBoxWidget(parent_frame, "Other").pack()
#################
# Numbering column
# #################
# parent_frame = Frame(self.numbering_info_frame, padx=20)
# parent_frame.pack(side="right", expand=False, fill="both")
parent_frame = Frame(self.numbering_info_frame,padx=20)
parent_frame.pack(side="right", expand=True, fill="both")
self.widget_mngr.SeriesGroup = ComboBoxWidget(parent_frame, cinfo_name="SeriesGroup",
label_text="Series Group").pack()
self.widget_mngr.AlternateSeries = ComboBoxWidget(parent_frame, cinfo_name="AlternateSeries",
label_text="Alternate Series").pack()
self.widget_mngr.StoryArc = ComboBoxWidget(parent_frame, "StoryArc", label_text="Story Arc").pack()
numbering = Frame(parent_frame)
numbering.pack(fill="x")
self.widget_mngr.AlternateCount = ComboBoxWidget(numbering, "AlternateCount",
label_text="Alt Count", tooltip="Alternate Count",
width=combo_width,
validation="int", default="-1")\
.pack(side="left", expand=False, fill="x")
self.widget_mngr.AlternateNumber = ComboBoxWidget(numbering, "AlternateNumber", width=combo_width,
label_text="Alt Number", tooltip="Alternate Number",
validation="int")\
.pack(side="left", expand=False, fill="x", padx=(10, 0))
self.widget_mngr.StoryArcNumber = ComboBoxWidget(numbering, "StoryArcNumber", width=combo_width,
label_text="Story Arc Number")\
.pack(side="left", expand=False, fill="x", padx=(10, 0))
self.widget_mngr.CommunityRating = ComboBoxWidget(numbering, cinfo_name="CommunityRating",
label_text="Community Rating",
width=combo_width,
validation="rating")\
.pack(side="left", expand=False, fill="x", padx=(10, 0))
self.widget_mngr.BlackAndWhite = OptionMenuWidget(numbering, "BlackAndWhite", "Black And White", combo_width, 18,
"Unknown", ("Unknown", "Yes", "No"))\
.pack(side="left", expand=False, fill="x", padx=(10, 0))
self.widget_mngr.PageCount = ComboBoxWidget(parent_frame, "PageCount", label_text="Page Count",
width=combo_width,
validation="int", default="0")
self.widget_mngr.ScanInformation = ComboBoxWidget(parent_frame, cinfo_name="ScanInformation",
label_text="Scan Information").pack()
self.widget_mngr.GTIN = ComboBoxWidget(parent_frame, cinfo_name="GTIN",
label_text="GTIN").pack()
def display_bottom_frame(self):
frame = self.selection_progress_frame_bottom
tkinter.Label(frame, text="No files selected", textvariable=self.image_cover_frame.selected_file_path_var)\
.pack(side="left")
progress_bar_frame = tkinter.Frame(frame)
pb = self.pb = ProgressBarWidget(progress_bar_frame)
pb.progress_bar.configure(length=200)
pb.set_template(f"""Processed: {pb.PROCESSED_TAG}/{pb.TOTAL_TAG} - {pb.ERRORS_TAG} errors""")
progress_bar_frame.pack(expand=False, fill="both", side="right")
self.pb.pb_label.pack(side="right")
self.pb.progress_bar.pack(side="right", fill="x", expand=True)
# Implementations
def on_file_selection_preview(self, *args):
"""
Method called when the user selects one or more files to preview the metadata
Called dynamically
:return:
"""
new_selection, old_selection = args
if not self.inserting_files:
self.process_gui_update(old_selection, new_selection)
self.image_cover_frame.update_cover_image(new_selection)
# When a file is selected (at least one), then enable the buttons
for btn in [self.fetch_online_btn, self.clear_btn, self.process_btn, self.fill_from_filename_btn]:
btn['state'] = 'normal'
def on_drop(self,event):
files_str = event.data
files = EXTRACT_PATHS.findall(files_str)
self.load_selected_files(files,is_event_dragdrop=True)
================================================
FILE: MangaManager/src/MetadataManager/GUI/windows/SettingsWindow.py
================================================
from __future__ import annotations
import logging
import re
import tkinter
from tkinter import ttk, Frame
from tkinter.ttk import LabelFrame, Label, Notebook, Combobox
from ExternalSources.MetadataSources import ScraperFactory
from common.models import ComicInfo
from src import MM_PATH
from src.Common.LoadedComicInfo.LoadedComicInfo import LoadedComicInfo
from src.Common.utils import open_folder
from src.DynamicLibController.models import IMetadataSource
from src.MetadataManager.GUI.utils import center
from src.MetadataManager.GUI.widgets import ButtonWidget
from src.MetadataManager.GUI.widgets.FormBundleWidget import FormBundleWidget
from src.Settings import SettingHeading
from src.Settings.SettingControl import SettingControl
from src.Settings.SettingControlType import SettingControlType
from src.Settings.SettingSection import SettingSection
from src.Settings.Settings import Settings
logger = logging.getLogger("SettingsWidgetManager")
def template_validation(key_list):
return [keyword for keyword in key_list if
keyword not in LoadedComicInfo(None, ComicInfo, False).get_template_values().keys()]
setting_control_map = {
SettingHeading.Main: {
"library_path": SettingControl("library_path", "Library Path", SettingControlType.Text, "",
"The path to your library. This location will be opened by default when choosing files"),
"covers_folder_path": SettingControl("covers_folder_path", "Covers folder path", SettingControlType.Text, "",
"The path to your covers. This location will be opened by default when choosing covers"),
"cache_cover_images": SettingControl("cache_cover_images", "Cache cover images", SettingControlType.Bool, True,
"If enabled, the covers of the file will be cached and shown in the ui"),
"create_backup_comicinfo": SettingControl("create_backup_comicinfo", "Create Backup XML",
SettingControlType.Bool, True,
"If enabled, all ComicInfo.xml existing within an archive will be backed up as Old_ComicInfo.xml.bak"),
"move_to_template": SettingControl("move_to_template", "Rename filename", SettingControlType.Text, "",
tooltip=f"Leave empty to not set.\nAvailable tags: {', '.join(['{' + key + '}' for key in LoadedComicInfo(None, ComicInfo, False).get_template_values().keys()])}",
validate=lambda key, value: '[' + ", ".join(template_validation(
re.findall(r'\{(\w+)\}', value))) + "] are not valid tags" if len(
template_validation(re.findall(r'\{(\w+)\}', value))) != 0 else ""),
"clean_ui_on_drag_drop": SettingControl("remove_old_selection_on_drag_drop","Clean previous selection\non drag and drop", SettingControlType.Bool, True, "After you drag and drop, previous selected files will be discarded")
},
SettingHeading.WebpConverter: {
"default_base_path": SettingControl("default_base_path", "Default base path", SettingControlType.Text, "",
"The starting point where the glob will begin looking for files that match the pattern"),
},
SettingHeading.ExternalSources: {
"default_metadata_source": SettingControl("default_metadata_source", "Default metadata source",
SettingControlType.Options,
"The source that will be hit when looking for metadata"),
"default_cover_source": SettingControl("default_cover_source", "Default cover source",
SettingControlType.Options,
"The source that will be hit when looking for cover images"),
},
SettingHeading.MessageBox: {
}
}
# TODO: Load dynamically loaded extensions (this will be moved in another PR)
providers: list[IMetadataSource] = [ScraperFactory().get_scraper("MangaUpdates"),
ScraperFactory().get_scraper("AniList")]
def populate_default_settings():
default_settings = {}
for section in setting_control_map:
if section not in default_settings:
controls = []
for (key, value) in setting_control_map[section].items():
setting = Settings().get(section, key)
if setting is None:
continue
controls.append(value)
default_settings[section] = SettingSection(section, section, controls)
# Setup extension based settings
for metadata_source in default_settings[SettingHeading.ExternalSources].values:
if metadata_source.key == 'default_metadata_source':
metadata_source.set_values([p.name for p in providers])
return default_settings
class SettingsWindow:
def __init__(self, parent):
self.strings_vars: list[tkinter.Variable] = []
self.bundles: list[FormBundleWidget] = []
self.default_settings = populate_default_settings()
settings_window = self.settings_window = tkinter.Toplevel(parent, pady=10, padx=30)
settings_window.geometry("900x420")
settings_window.title("Settings")
main_frame = tkinter.Frame(settings_window)
main_frame.pack(fill="both")
# There is nothing that requires a restart yet, so I'm removing this
# frame = Label(master=main_frame, text="\nNote: Fields marked with * need a restart to take effect")
# frame.pack(expand=True, fill="both")
style = ttk.Style(main_frame)
style.configure('lefttab.TNotebook', tabposition='ws')
self.widgets_frame = Notebook(main_frame, style='lefttab.TNotebook')
self.widgets_frame.pack(expand=True, fill="both")
control_frame = tkinter.Frame(settings_window)
ButtonWidget(master=control_frame, text="Save", tooltip="Saves the settings to the config file",
command=self.save_settings) \
.pack(side="right", padx=(0, 5))
ButtonWidget(master=control_frame, text="Open Settings Folder",
tooltip="Opens the folder where Manga Manager stores it's files",
command=lambda x=None: open_folder(folder_path=MM_PATH)) \
.pack()
control_frame.pack(side="right")
self.settings_widget = {}
logger.info('Setting up settings for Manga Manager')
for setting_section in self.default_settings:
section = self.default_settings[setting_section]
logger.info('Setting up settings for ' + section.pretty_name)
section_frame = Frame(master=self.widgets_frame, name="default_" + setting_section.name)
section_frame.pack(expand=True, fill="both")
self.settings_widget[section.pretty_name] = {}
self.build_setting_entries(section_frame, section.values, section)
self.widgets_frame.add(section_frame, text=section.pretty_name)
logger.info('Setting up settings for Extensions')
for provider in providers:
settings = provider.settings
for section in settings:
logger.info('Setting up settings for ' + provider.name)
section_frame = LabelFrame(master=self.widgets_frame, text=section.pretty_name,
name="provider_" + provider.name)
section_frame.pack(expand=True, fill="both")
self.settings_widget[self.default_settings[SettingHeading.ExternalSources].pretty_name][
section.pretty_name] = {}
self.build_setting_entries(section_frame, section.values, section)
self.widgets_frame.add(section_frame, text=section.pretty_name)
# Display checkbox toggles
frame = self.widgets_frame.children.get("default_MessageBox")
for entry in list(Settings.config_parser[SettingHeading.MessageBox]):
control = SettingControl(key=entry, name=entry, control_type=SettingControlType.Bool)
self.build_setting_entry(frame, control=control, section=self.default_settings[SettingHeading.MessageBox])
center(settings_window)
def build_setting_entry(self, parent_frame, control: SettingControl, section):
# Update the control's value from Settings
control.value = Settings().get(section.key, control.key)
row = FormBundleWidget(parent_frame, self.setting_control_to_widget, name=control.key) \
.with_label(title=control.name, tooltip=control.tooltip) \
.with_input(control=control, section=section) \
.build()
self.bundles.append(row)
def build_setting_entries(self, parent_frame, settings, section):
for i, setting in enumerate(settings):
self.build_setting_entry(parent_frame, setting, section)
def save_settings(self):
"""
Saves the settings from the GUI to Setting provider and extensions that dynamically loaded their settings
"""
# Validate the setting is correct before allowing any persistence
is_errors = False
for bundle in self.bundles:
if bundle.control:
if not bundle.validate():
is_errors = True
if is_errors:
return
for bundle in self.bundles:
if bundle.control:
Settings().set(bundle.section.key, bundle.control.key, bundle.format_output())
# Tell Extensions that an update to Settings has occurred
for provider in providers:
provider.save_settings()
Settings().save()
self.settings_window.destroy()
@staticmethod
def setting_control_to_widget(parent_frame: tkinter.Frame, control: SettingControl, section: SettingSection):
match control.control_type:
case SettingControlType.Text:
string_var = tkinter.StringVar(value=control.value, name=f"{section.pretty_name}.{control.key}")
entry = tkinter.Entry(master=parent_frame, width=80, textvariable=string_var)
entry.pack(side="right", expand=True, fill="x", padx=(5, 30))
case SettingControlType.Bool:
if isinstance(control.value,bool):
value = control.value
else:
value = control.value == 'True'
string_var = tkinter.BooleanVar(value=value, name=f"{section.pretty_name}.{control.key}")
entry = tkinter.Checkbutton(parent_frame, variable=string_var, onvalue=1, offvalue=0)
entry.pack(side="left")
case SettingControlType.Options:
string_var = tkinter.StringVar(value="default", name=f"{section.pretty_name}.{control.key}")
entry = Combobox(master=parent_frame, textvariable=string_var, width=30, state="readonly")
entry["values"] = control.values
entry.set(str(control.value))
entry.pack(side="left", expand=False, fill="x", padx=(5, 30))
entry.set(control.value)
return entry, string_var
================================================
FILE: MangaManager/src/MetadataManager/GUI/windows/__init__.py
================================================
from .AboutWindow import AboutWindow
================================================
FILE: MangaManager/src/MetadataManager/MetadataManagerCLI.py
================================================
import itertools
import logging
import shutil
import sys
import textwrap
import time
import prompt_toolkit
from prompt_toolkit import prompt
from prompt_toolkit.application.current import get_app
from prompt_toolkit.completion import WordCompleter
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.validation import Validator
from common.models import ComicInfo
from src.Common.LoadedComicInfo.LoadedComicInfo import LoadedComicInfo
from src.Common.utils import ShowPathTreeAsDict
from src.MetadataManager.MetadataManagerLib import MetadataManagerLib
def prompt_autocomplete():
app = get_app()
b = app.current_buffer
if b.complete_state:
b.complete_next()
else:
b.start_completion(select_first=False)
def grouper(n, iterable, fillvalue=None):
"Collect data into fixed-length chunks or blocks"
# grouper(3, 'ABCDEFG', 'x') --> ABC DEF Gxx"
args = [iter(iterable)] * n
return itertools.zip_longest(fillvalue=fillvalue, *args)
class bcolors:
HEADER = '\033[95m'
OKBLUE = '\033[94m'
OKCYAN = '\033[96m'
OKGREEN = '\033[92m'
WARNING = '\033[93m'
FAIL = '\033[91m'
ENDC = '\033[0m'
BOLD = '\033[1m'
UNDERLINE = '\033[4m'
bindings = KeyBindings()
app = None
@bindings.add('c-q')
def _(event: prompt_toolkit.key_binding.KeyPressEvent):
"""Exit when `c-q` is pressed. """
event.app.running = False
app.quit()
@bindings.add('c-p')
def _(event: prompt_toolkit.key_binding.KeyPressEvent):
"""Exit when `c-q` is pressed."""
event.app.running = False
event.app.exit()
# app.quit()
app.process()
@bindings.add('c-l')
def _(event: prompt_toolkit.key_binding.KeyPressEvent):
"""Exit when `c-l` is pressed."""
app.restart()
event.app.exit()
app.quit()
print("Selected files will be shown for 10 seconds")
print("\033[%d;%dH" % (0, 0))
print(f"{' '*int(shutil.get_terminal_size().columns)}\n"* int(shutil.get_terminal_size().lines))
print("\033[%d;%dH" % (0, 0))
# print("\033[%d;%dH" % (row, 0))
print("Selected files will be shown for 10 seconds")
time.sleep(10)
logger = logging.getLogger()
class App(MetadataManagerLib):
def on_processed_item(self, loaded_info: LoadedComicInfo):
pass
def on_manga_not_found(self, exception, series_name):
pass
def __init__(self, file_paths: list[str]):
self.is_cli = True
self._restart = False
self.terminal_height = int(shutil.get_terminal_size().lines)
self.terminal_width = int(shutil.get_terminal_size().columns)
self.selected_files_path = file_paths
self.serve_ui()
def _parse_lcinfo_list_to_gui(self, loaded_cinfo_list) -> ComicInfo:
displayed_gui = self.new_edited_cinfo = ComicInfo()
for cinfo_tag in self.cinfo_tags:
tag_values = set()
for loaded_cinfo in loaded_cinfo_list:
tag_value = str(loaded_cinfo.cinfo_object.get_by_tag_name(cinfo_tag))
tag_values.add(tag_value if tag_value not in ("",-1,0,"-1","0") else None)
tag_values = tuple(tag_values)
tag_values_len = len(tag_values)
# All files have the same content for this field
if tag_values_len == 1 and tag_values[0] not in ("", -1, 0, "-1", "0", None):
displayed_gui.get_by_tag_name(cinfo_tag, tag_values[0])
# Multiple values across different files for this field
elif tag_values_len > 1:
# Append "multiple_values" string to the suggestion listbox
tag_values = (self.MULTIPLE_VALUES_CONFLICT,) + tag_values
displayed_gui.get_by_tag_name(cinfo_tag, self.MULTIPLE_VALUES_CONFLICT)
def serve_ui(self):
self.open_cinfo_list()
# self.merge_changed_metadata()
global app
app = self
self.terminal_width_half = int(self.terminal_width / 2 - 40)
custom_entered_values = []
self.clear()
self._parse_lcinfo_list_to_gui(self.loaded_cinfo_list)
self.process()
self.running = True
# Set the validator for the user prompts.
is_valid_tag = Validator.from_callable(
self._is_valid_tool,
error_message='Not a valid tool. Select one in the list',
move_cursor_to_end=False)
while self.running:
# Clear terminal, so it will redraw because of the loop with the modified values.
self.clear()
# Display current values
for tag_1, tag_2 in grouper(2, self.cinfo_tags, fillvalue=None):
# We get 2 different values to support the 2 column layout and also support wrapping the text if multiline
value_1 = textwrap.wrap(str(self.new_edited_cinfo.get_by_tag_name(tag_1)), width=self.terminal_width_half) or [""]
if tag_2:
value_2 = textwrap.wrap(str(self.new_edited_cinfo.get_by_tag_name(tag_2)), width=self.terminal_width_half) or [""]
else:
value_2 = [""]
print_once = False
print(" " * self.terminal_width, end="\r")
# Divide/wrap both strings. Make a list of each line.
# Add empty strings to the list of lines with less lines to allow consistent wrapping
for val_1, val2 in itertools.zip_longest(value_1, value_2, fillvalue=" "):
# Print one allow to not print the label on each row if the text is being wrapped in multiple lines
if not print_once:
print(f"{bcolors.OKBLUE if tag_1 in custom_entered_values else ''}"+f"{tag_1}".ljust(16) +
f"{bcolors.ENDC if tag_1 in custom_entered_values else ''}: ", end='')
else:
print(f" ".ljust(19), end="")
# Print the actual value inline:
print(f"{val_1.ljust(self.terminal_width_half)}", end=' ')
if not print_once:
print(f"{bcolors.OKBLUE if tag_2 in custom_entered_values else ''}" + f"{tag_2}".ljust(16) +
f"{bcolors.ENDC if tag_2 in custom_entered_values else ''}: ", end='')
else:
print(f" ".ljust(19), end="")
print(f"{val2}")
# Set the flag that the labels have been printed
print_once = True
# Prompt to have the user select what tag to edit
choosed_tag = prompt("Select tag to edit (Use arrow keys to navigate) ",
completer=WordCompleter(self.cinfo_tags),
validator=is_valid_tag, pre_run=prompt_autocomplete,
bottom_toolbar="Exit:ctrl+q - Process:ctrl+p - Show Selected:ctrl+l",
key_bindings=bindings)
if choosed_tag is None:
logger.warning("No tag selected. Restarting")
continue
if self._restart:
self._restart = False
continue
if not self.running:
return
# User selected one tag. If it has prefefined values, show a list of them and let the user select those.
# If one field has multiple values from different files show a list of those.
# Adds an option "Custom" to custom enter the value
print(f"You selected {bcolors.HEADER}{choosed_tag}{bcolors.ENDC}")
choosed_value = ""
if self.new_edited_cinfo.get_by_tag_name(choosed_tag) == self.MULTIPLE_VALUES_CONFLICT:
print("Multiple values conflict. Select one value to keep."
f" '{bcolors.HEADER}Cancel{bcolors.ENDC}' to cancel editing."
f" '{bcolors.HEADER}Custom{bcolors.ENDC}' to manually enter a new value."
f" '{bcolors.HEADER}None{bcolors.ENDC}' to clear the content")
if choosed_tag == "AgeRating":
validation_vals = ComicInfo.AgeRating.list()
elif choosed_tag == "Manga":
validation_vals = ComicInfo.Manga.list()
elif choosed_tag == "BlackAndWhite":
validation_vals = ComicInfo.YesNo.list()
elif choosed_tag == "CommunityRating":
validation_vals = range(1,5)
else:
validation_vals = ["Cancel", "None", "Custom",
*[lcinfo.cinfo_object.get_by_tag_name(choosed_tag)
for lcinfo in self.loaded_cinfo_list
if lcinfo.cinfo_object.get_by_tag_name(choosed_tag)]]
choosed_value = prompt(f"Value to keep as '{choosed_tag}': ",
completer=WordCompleter(validation_vals),
pre_run=prompt_autocomplete,
validator=Validator.from_callable(lambda value: value in validation_vals,
error_message="Invalid value. Select one in the list",
move_cursor_to_end=False))
if choosed_value == "Cancel":
continue
elif choosed_value == "None":
choosed_value = None
elif choosed_value == "Custom" or self.new_edited_cinfo.get_by_tag_name(choosed_tag) != self.MULTIPLE_VALUES_CONFLICT:
alt_enter = " (Alt+Enter to save)" if choosed_tag == "Summary" else ""
# Make the prompt to edit the value. Make it multiline if the tag is "Summary"
choosed_value = prompt(f"Write new value for {choosed_tag}{alt_enter}: ", multiline=choosed_tag == "Summary")
# Mark the field as modified.
custom_entered_values.append(choosed_tag)
# Edit the field in the ""Gui""
self.new_edited_cinfo.get_by_tag_name(choosed_tag, choosed_value)
def restart(self):
self._restart = True
def clear(self):
sys.stdout.write("\033[F" * int(self.terminal_height))
sys.stdout.flush()
def quit(self):
self.clear()
self.running = False
exit()
def process(self):
self.clear()
self.running = False
# export = StringIO(self.new_edited_cinfo.to_xml())
# print(export.getvalue())
self.merge_changed_metadata(self.loaded_cinfo_list)
super(App, self).process()
self.new_edited_cinfo = ComicInfo()
self._parse_lcinfo_list_to_gui(self.loaded_cinfo_list)
def tree_selected(self) -> int:
print("")
# print(path)
paths = ShowPathTreeAsDict([lcinfo.file_path for lcinfo in self.loaded_cinfo_list])
return paths.display_tree()
def _is_valid_tool(self, value):
return True if value in self.cinfo_tags else False
def on_badzipfile_error(self, exception, file_path):
pass
def on_corruped_metadata_error(self, exception, loaded_info: LoadedComicInfo):
pass
def on_writing_error(self, exception, loaded_info: LoadedComicInfo):
pass
def on_writing_exception(self, exception, loaded_info: LoadedComicInfo):
pass
================================================
FILE: MangaManager/src/MetadataManager/MetadataManagerGUI.py
================================================
from __future__ import annotations
import glob
import logging
import os
import tkinter
from tkinter import Tk, Frame
from common.models import ComicInfo
from src.Common import ResourceLoader
from src.Common.parser import parse_volume, parse_series, parse_number
from src.Common.utils import get_platform, open_folder
from src.MetadataManager.GUI.ControlManager import ControlManager
from src.MetadataManager.GUI.MessageBox import MessageBoxWidgetFactory as mb
from src.MetadataManager.GUI.windows.AboutWindow import AboutWindow
from src.MetadataManager.GUI.windows.LoadingWindow import LoadingWindow
from src.Settings import SettingHeading
from src.Settings.Settings import Settings
if get_platform() == "linux":
from src.MetadataManager.GUI.FileChooserWindow import askopenfiles, askdirectory
else:
from tkinter.filedialog import askopenfiles, askdirectory
from _tkinter import TclError
from tkinterdnd2.TkinterDnD import Tk
from src.Common.LoadedComicInfo.LoadedComicInfo import LoadedComicInfo
from src.MetadataManager.GUI.widgets import ComboBoxWidget, OptionMenuWidget, WidgetManager, ButtonWidget
from src.MetadataManager.GUI.windows.SettingsWindow import SettingsWindow
from src.MetadataManager.MetadataManagerLib import MetadataManagerLib
class GUIApp(Tk, MetadataManagerLib):
"""
This is the main logic and app
"""
main_frame: Frame
"""
A loading indicator to help not process changes in MainWindow when GUI is performing loading of files
"""
inserting_files = False
widget_mngr = WidgetManager()
control_mngr = ControlManager() # widgets that should be disabled while processing
loading_window: LoadingWindow | None = None
def __init__(self):
super(GUIApp, self).__init__()
self.last_folder = ""
# self.wm_minsize(1000, 660)
self.tk.eval('package require tile')
self.geometry("1000x820")
self.title("Manga Manager")
self.selected_files_path = None
self.loaded_cinfo_list: list[LoadedComicInfo] = []
self.log = logging.getLogger("MetaManager.GUI")
# MENU
self.main_frame = Frame(self)
self.main_frame.pack(expand=True, fill="both")
# Add binds
self.bind('', lambda x: self.select_files())
self.bind('', lambda x: self.pre_process())
self.bind('', self.process_fetch_online)
# Icons
icon_path = ResourceLoader.get('settings.png')
self.settings_icon = tkinter.PhotoImage(name="settings_icon", master=self, file=icon_path)
icon_path = ResourceLoader.get('clear_icon.png')
self.clear_icon = tkinter.PhotoImage(name="clear_icon", master=self, file=icon_path)
icon_path = ResourceLoader.get('fetch_online_ico.png')
self.fetch_online_icon = tkinter.PhotoImage(name="fetch_online_icon", master=self, file=icon_path)
icon_path = ResourceLoader.get('save_icon.png')
self.save_icon = tkinter.PhotoImage(name="save_icon", master=self, file=icon_path)
icon_path = ResourceLoader.get('filename_fill_icon.png')
self.filename_fill_icon = tkinter.PhotoImage(name="filename_fill", master=self, file=icon_path)
icon_path = ResourceLoader.get('open_folder.png')
self.open_folder_icon = tkinter.PhotoImage(name="open_folder", master=self, file=icon_path)
icon_path = ResourceLoader.get('open_file.png')
self.open_file_icon = tkinter.PhotoImage(name="open_file", master=self, file=icon_path)
# Floating icons
frame = Frame(self)
frame.place(anchor=tkinter.NE, relx=1,rely=0.003)
ButtonWidget(master=frame, text="Settings", image=self.settings_icon, font=('Arial', 10), compound="left",
command=self.show_settings).pack(side="left", fill="y", padx=(0, 5))
ButtonWidget(master=frame, text="About", font=('Arial', 10), command=self.show_about).pack(side="left", fill="y", padx=(0, 5))
def report_callback_exception(self, *_):
"""
Overrides builtin method so exceptions get loged and are not silent
:param _:
:return:
"""
self.log.exception("Unhandled exception")
@property
def cinfo_tags(self):
return self.widget_mngr.cinfo_tags
@property
def selected_items(self):
"""
Returns the list of selected loaded_cinfo if any is selected. Else returns loaded_cinfo list
:return:
"""
return self.selected_files_treeview.get_selected() or self.loaded_cinfo_list
#########################################################
# GUI Control Methods
############
def select_files(self):
# These are some tricks to make it easier to select files.
# Saves last opened folder to not have to browse to it again
if not self.last_folder:
initial_dir = Settings().get(SettingHeading.Main, 'library_path')
else:
initial_dir = self.last_folder
self.log.debug("Selecting files")
# Open select files dialog
selected_paths_list = askopenfiles(parent=self, initialdir=initial_dir,
title="Select file(s)",
filetypes=(("CB* Files", (".cbz", ".cbr")), ("CBZ Files", ".cbz"),
("CBR Files", ".cbr"), ("All Files", "*"), ("Zip files", ".zip"))
# ("Zip files", ".zip"))
) or []
if selected_paths_list:
selected_parent_folder = os.path.dirname(selected_paths_list[0].name)
if self.last_folder != selected_parent_folder or not self.last_folder:
self.last_folder = selected_parent_folder
self.selected_files_path = [file.name for file in selected_paths_list]
self.load_selected_files()
def select_folder(self):
# These are some tricks to make it easier to select files.
# Saves last opened folder to not have to browse to it again
if not self.last_folder:
initial_dir = Settings().get(SettingHeading.Main, 'library_path')
else:
initial_dir = self.last_folder
self.log.debug("Selecting files")
# Open select files dialog
folder_path = askdirectory(initialdir=initial_dir)
self.selected_files_path = glob.glob(root_dir=folder_path, pathname=os.path.join(folder_path, "**/*.cbz"),
recursive=True)
# TODO: Auto select recursive or not
# self.selected_files_path = [str(Path(folder_path, file)) for file in os.listdir(folder_path) if file.endswith(".cbz")]
self.load_selected_files()
def load_selected_files(self,new_selection:list=None,is_event_dragdrop = False):
self.control_mngr.lock()
self.widget_mngr.toggle_widgets(enabled=False)
append_and_keep = is_event_dragdrop and not Settings().get(SettingHeading.Main,"remove_old_selection_on_drag_drop")
if append_and_keep: # Should keep previously selected files. Just load the new ones in selection
self.selected_files_path = list(set((self.selected_files_path or []) + new_selection))
else:
# Append new files and keep the old ones
self.widget_mngr.clean_widgets() # New file selection. Proceed to clean the ui to a new state
self.image_cover_frame.clear()
self.selected_files_path = self.selected_files_path if new_selection is None else new_selection
self.selected_files_treeview.clear()
self.selected_files_path = sorted(self.selected_files_path)
self.log.debug(f"Selected files [{', '.join(self.selected_files_path)}]")
self.inserting_files = True
self.loading_window = LoadingWindow(len(self.selected_files_path))
if self.open_cinfo_list(self.loading_window.is_abort,append_and_keep):
self._serialize_cinfolist_to_gui()
else:
self.clean_selected()
self.loading_window.finish_loading()
self.loading_window = None
self.inserting_files = False
self.control_mngr.unlock()
self.widget_mngr.toggle_widgets(enabled=True)
def show_settings(self):
SettingsWindow(self)
def show_about(self):
AboutWindow(self)
def are_unsaved_changes(self, exist_unsaved_changes=False):
"""
Displays the text "unsaved changes"
:return:
"""
if exist_unsaved_changes: # Place the warning sign
self.changes_saved.place(anchor=tkinter.NE, relx=0.885)
else: # remove the warning sign
self.changes_saved.place_forget()
def update_item_saved_status(self, loaded_cinfo):
"""
Adds a warning in the filename if the loadedcinfo has changes
:param loaded_cinfo:
:return:
"""
try:
self.selected_files_treeview.item(loaded_cinfo.file_path,
text=f"{'⚠' if loaded_cinfo.has_changes else ''}{loaded_cinfo.file_name}")
except TclError: # Tests fails due to not being correctly populated. Log and skip
self.log.error(f"Error updating saved status for item {loaded_cinfo.file_path}")
def show_not_saved_indicator(self, loaded_cinfo_list=None):
"""
Shows a litle triangle when files are not saved and are modified
:param loaded_cinfo_list:
:param mark_saved:
:return:
"""
if loaded_cinfo_list is None:
loaded_cinfo_list = self.loaded_cinfo_list
any_has_changes = False
for loaded_cinfo in loaded_cinfo_list:
self.update_item_saved_status(loaded_cinfo)
if loaded_cinfo.has_changes:
any_has_changes = True
self.are_unsaved_changes(any_has_changes)
#########################################################
# INTERFACE IMPLEMENTATIONS
############
def on_item_loaded(self, loaded_cinfo: LoadedComicInfo, cursor, total) -> bool:
"""
Called by backend when an item gets added to the loaded comic info list
:param loaded_cinfo:
:return:
"""
if self.loading_window.initialized:
self.loading_window.update()
self.loading_window.loaded_file(loaded_cinfo.file_name)
self.selected_files_treeview.insert(loaded_cinfo)
self.image_cover_frame.update_cover_image([loaded_cinfo])
self.update()
return self.loading_window.abort_flag
#########################################################
# Errors handling / hooks implementations
############
def on_processed_item(self, loaded_info: LoadedComicInfo):
self.pb.increase_processed()
self.update_item_saved_status(loaded_info)
self.update()
def on_badzipfile_error(self, exception, file_path: LoadedComicInfo): # pragma: no cover
mb.showerror(self.main_frame, "Error loading file",
f"Failed to read the file '{file_path}'.\nThis can be caused by wrong file format"
f" or broken file.\n"
f"Read the logs for more information.\n"
f"Skipping file...")
def on_writing_exception(self, exception, loaded_info: LoadedComicInfo): # pragma: no cover
self.pb.increase_failed()
mb.showerror(self.main_frame, "Unhandled exception",
"There was an exception that was not handled while writing the changes to the file."
"Please check the logs and raise an issue so this can be investigated")
def on_writing_error(self, exception, loaded_info: LoadedComicInfo): # pragma: no cover
self.pb.increase_failed()
mb.showerror(self.main_frame, "Error writing to file",
"There was an error writing to the file. Please check the logs.")
def on_corruped_metadata_error(self, exception, loaded_info: LoadedComicInfo): # pragma: no cover
mb.showwarning(self.main_frame, f"Error reading the metadata from file",
f"Failed to read metadata from '{loaded_info.file_path}'\n"
"The file data couldn't be parsed probably because of corrupted data or bad format.\n"
f"Recovery was attempted and failed.\nCreating new metadata object...")
def on_manga_not_found(self, exception, series_name): # pragma: no cover
mb.showerror(self.main_frame, "Couldn't find matching series",
f"The metadata source couldn't find the series '{series_name}'")
def on_missing_rar_tools(self, exception):
box = mb.get_onetime_messagebox()("missing_rar_tools")
box.with_title("Missing Rar Tools"). \
with_description("CBR files can't be read because third party rar tools are missing. Skipping files"). \
with_icon(mb.get_onetime_messagebox().icon_error). \
with_actions([mb.get_box_button()(0, "Ok")]). \
build().prompt()
#########################################################
# Processing Methods
############
def _serialize_cinfolist_to_gui(self, loaded_cinfo_list=None):
"""
Display the loaded cinfo values in the ui.
If multiple values for one field, shows conflict (keeping values)
:param loaded_cinfo_list:
:return:
"""
# Clear current values
self.widget_mngr.clean_widgets()
if loaded_cinfo_list is None:
loaded_cinfo_list = self.selected_items
if Settings().get(SettingHeading.Main, 'cache_cover_images'):
self.image_cover_frame.update_cover_image(loaded_cinfo_list)
# Iterate all cinfo tags. Should there be any values that are not equal. Show "different values selected"
for cinfo_tag in self.widget_mngr.get_tags():
widget = self.widget_mngr.get_widget(cinfo_tag)
tag_values = set()
for loaded_cinfo in loaded_cinfo_list:
tag_value = str(loaded_cinfo.cinfo_object.get_by_tag_name(cinfo_tag))
tag_values.add(tag_value if tag_value != widget.default else "")
tag_values = tuple(tag_values)
tag_values_len = len(tag_values)
# All files have the same content for this field
if tag_values_len == 1 and tag_values[0] != widget.default:
widget.set(tag_values[0])
# Multiple values across different files for this field
elif tag_values_len > 1:
# Append "multiple_values" string to the suggestion listbox
tag_values = (self.MULTIPLE_VALUES_CONFLICT,) + tag_values
widget.widget.set(self.MULTIPLE_VALUES_CONFLICT)
# If it's a combobox update the suggestions listbox with the loaded values
if isinstance(widget, ComboBoxWidget):
widget.widget['values'] = list(tag_values)
elif isinstance(widget, OptionMenuWidget):
if tag_values_len == 1:
widget.update_listed_values(tag_values[0], widget.get_options())
elif tag_values_len > 1:
widget.append_first(self.MULTIPLE_VALUES_CONFLICT)
def _serialize_gui_to_cinfo(self) -> ComicInfo:
"""
Parses current UI values to a 'new_edited_cinfo'
:return:
"""
# is_metadata_modified
LOG_TAG = "[UI->CINFO] "
ci = ComicInfo()
for cinfo_tag in self.widget_mngr.get_tags():
widget = self.widget_mngr.get_widget(cinfo_tag)
widget_value = widget.widget.get()
match widget_value:
case self.MULTIPLE_VALUES_CONFLICT:
self.log.trace(LOG_TAG + f"Omitting {cinfo_tag}. Keeping original")
ci.set_by_tag_name(cinfo_tag, self.MULTIPLE_VALUES_CONFLICT)
case "None":
if widget.name == "Format":
ci.set_by_tag_name(cinfo_tag, "")
case widget.default: # If it matches the default then do nothing
self.log.trace(LOG_TAG + f"Omitting {cinfo_tag}. Has default value")
case "":
ci.set_by_tag_name(cinfo_tag, "")
self.log.trace(LOG_TAG + f"Tag '{cinfo_tag}' content was reset or was empty")
case _:
ci.set_by_tag_name(cinfo_tag, widget_value)
self.log.trace(LOG_TAG + f"Tag '{cinfo_tag}' has overwritten content: '{widget_value}'")
# self.log.warning(f"Unhandled case: {widget_value}")
return ci
def process_gui_update(self, old_selection: list[LoadedComicInfo], new_selection: list[LoadedComicInfo]):
self.new_edited_cinfo = self._serialize_gui_to_cinfo()
self.merge_changed_metadata(old_selection)
self.show_not_saved_indicator(old_selection)
self.widget_mngr.clean_widgets()
# Display new selection data
self._serialize_cinfolist_to_gui(new_selection)
def fill_from_filename(self) -> None:
"""Handles taking the currently selected file and parsing any information out of it and writing to Empty fields"""
if not self.selected_files_path:
mb.showwarning(self.main_frame, "No files selected", "No files were selected.")
self.log.warning("No files selected")
return
self.control_mngr.toggle(enabled=False)
self.changes_saved.place_forget()
self.pb.start(len(self.loaded_cinfo_list))
# Make sure current view is saved:
self.process_gui_update(self.selected_items, self.selected_items)
any_items_changed = False
try:
for item in self.selected_items:
# We can parse Series, Volume, Number, and Scan Info
if not item.cinfo_object.volume:
vol = parse_volume(item.file_name)
if vol:
item.cinfo_object.volume = vol
item.has_changes = True
any_items_changed = True
if not item.cinfo_object.series:
series = parse_series(item.file_name)
if series:
item.cinfo_object.series = series
item.has_changes = True
any_items_changed = True
if not item.cinfo_object.number:
number = parse_number(item.file_name)
if number:
item.cinfo_object.number = number
item.has_changes = True
any_items_changed = True
finally:
self.pb.stop()
self.show_not_saved_indicator(self.loaded_cinfo_list)
if any_items_changed:
self.show_not_saved_indicator(self.selected_items)
self._serialize_cinfolist_to_gui(self.selected_items)
self.control_mngr.toggle(enabled=True)
def pre_process(self) -> None:
"""
Handles UI stuff to be started prior to processing such as converting ui data to comicinfo and starting the timer
"""
if not self.selected_files_path:
mb.showwarning(self.main_frame, "No files selected", "No files were selected.")
self.log.warning("No files selected")
return
self.control_mngr.toggle(enabled=False)
self.changes_saved.place_forget()
self.pb.start(len(self.loaded_cinfo_list))
# Make sure current view is saved:
self.process_gui_update(self.selected_items, self.selected_items)
try:
self.process()
finally:
self.pb.stop()
self.show_not_saved_indicator(self.loaded_cinfo_list)
self.new_edited_cinfo = None # Nulling value to be safe
self.control_mngr.toggle(enabled=True)
# Unique methods
def _fill_filename(self):
if len(self.selected_items) == 1:
self.widget_mngr.get_widget("Series").set(self.selected_items[0].file_name)
def _fill_foldername(self):
if len(self.selected_items) == 1:
self.widget_mngr.get_widget("Series").set(
os.path.basename(os.path.dirname(self.selected_items[0].file_path)))
else:
for loaded_cinfo in self.selected_items:
_ = loaded_cinfo.cinfo_object
loaded_cinfo.cinfo_object.series = os.path.basename(os.path.dirname(loaded_cinfo.file_path))
loaded_cinfo.has_changes = True
self.show_not_saved_indicator(self.selected_items)
self.widget_mngr.clean_widgets()
# Display new selection data
self._serialize_cinfolist_to_gui(self.selected_items)
def _treeview_open_explorer(self, file):
open_folder(os.path.dirname(file), file)
...
def _treview_reset(self, event=None):
...
def display_extensions(self, parent_frame):
from src import loaded_extensions
for loaded_extension in loaded_extensions:
tkinter.Button(parent_frame, text=loaded_extension.name, command=lambda load_ext=loaded_extension:
load_ext(parent_frame, super_=self)).pack(side="top")
def process_fetch_online(self, *_):
series_name = self.widget_mngr.get_widget("Series").get().strip()
if series_name == self.MULTIPLE_VALUES_CONFLICT:
mb.showwarning(self.main_frame, "Not a valid series name. Multiple values conflict.")
self.log.info("Not a valid series name - Conflic with other series name in selection")
return
if series_name in (None, "") and self.widget_mngr.get_widget("Web").get() in (None,""):
mb.showwarning(self.main_frame, "Not a valid series name", "The current series name is empty or not valid.")
self.log.info("Not a valid series name - The current series name is empty or not valid.")
return
# If multiple files are selected, validate that all files have the same series name
if len(self.selected_items) > 1:
if not all(series_name == item.cinfo_object.series.strip() for item in self.selected_items):
mb.showwarning(self.main_frame, "All series MUST match and may not contain blanks",
"All files' series names are not the same.")
self.log.info(
"All series MUST match and may not contain blanks - All files' series names are not the same.")
return
cinfo = self.fetch_online(self._serialize_gui_to_cinfo())
if cinfo is None:
return
self._serialize_cinfolist_to_gui([LoadedComicInfo(None, cinfo, load_default_metadata=False)])
def clean_selected(self):
self.widget_mngr.clean_widgets()
self.image_cover_frame.clear()
self.selected_files_path = list()
self.selected_files_treeview.clear()
================================================
FILE: MangaManager/src/MetadataManager/MetadataManagerLib.py
================================================
from __future__ import annotations
import abc
import logging
from abc import ABC
from ExternalSources.MetadataSources import ScraperFactory
from common.models import ComicInfo
from src.Common.LoadedComicInfo.LoadedComicInfo import LoadedComicInfo
from src.Common.errors import EditedCinfoNotSet, MangaNotFoundError, MissingRarTool
from src.Common.errors import NoComicInfoLoaded, CorruptedComicInfo, BadZipFile
from src.Common.terminalcolors import TerminalColors as TerCol
from src.Settings import SettingHeading
from src.Settings.Settings import Settings
logger = logging.getLogger("MetaManager.Core")
class _IMetadataManagerLib(abc.ABC):
def on_item_loaded(self, loaded_cinfo: LoadedComicInfo,cursor,total):
"""
Called when a loadedcomicinfo is loaded
:return:
"""
@abc.abstractmethod
def on_badzipfile_error(self, exception, file_path):
"""
Called while loading a file, and it's not a valid zip or it's broken
"""
@abc.abstractmethod
def on_processed_item(self, loaded_info: LoadedComicInfo):
"""
Called when a file is successfully processed
"""
@abc.abstractmethod
def on_corruped_metadata_error(self, exception, loaded_info: LoadedComicInfo):
"""
Called while loading a file, and it's metadata can't be read.
"""
@abc.abstractmethod
def on_writing_error(self, exception, loaded_info: LoadedComicInfo):
"""
Called while trying to save to the file.
Posible callees (but not limited to): FailedBackup,
"""
@abc.abstractmethod
def on_writing_exception(self, exception, loaded_info: LoadedComicInfo):
"""
Called when an unhandled exception occurred trying to save the file
"""
@abc.abstractmethod
def on_manga_not_found(self, exception, series_name):
"""
Called when a series is not found in the api
"""
@abc.abstractmethod
def on_missing_rar_tools(self,exception):
"""
Caññed whem rar tools are not available
"""
class MetadataManagerLib(_IMetadataManagerLib, ABC):
"""
The core of metadata editor.
It has the logic to merge all the data of each fields across multiple files.
"""
is_cli = False
is_test = False
selected_files_path = None
new_edited_cinfo: ComicInfo | None = None
loaded_cinfo_list: list[LoadedComicInfo] = list()
cinfo_tags: list[str] = ["Title", "Series", "LocalizedSeries", "Number", "Count", "Volume", "AlternateSeries", "AlternateNumber",
"AlternateCount", "Summary", "Notes", "Year", "Month", "Day", "Writer", "Penciller",
"Inker", "Colorist", "Letterer", "CoverArtist", "Editor", "Translator", "Publisher",
"Imprint", "Genre", "Tags", "Web", "PageCount", "LanguageISO", "Format", "BlackAndWhite",
"Manga", "Characters", "Teams", "Locations", "ScanInformation", "StoryArc",
"StoryArcNumber", "SeriesGroup", "AgeRating", "CommunityRating",
"MainCharacterOrTeam", "Other", "Review","GTIN"
]
MULTIPLE_VALUES_CONFLICT = "~~## Keep Original Value ##~~"
tags_with_multiple_values = []
@property
def loaded_cinfo_list_to_process(self) -> list[LoadedComicInfo]:
return [loaded_cinfo for loaded_cinfo in self.loaded_cinfo_list if loaded_cinfo.has_changes]
def process(self):
"""
Iterates the list of loaded_cinfo.
Skips cinfo that do not have modified metadata
:return: list of loadedcinfo that failed to update :
"""
LOG_TAG = "[Processing] "
if not self.loaded_cinfo_list:
self.loaded_cinfo_list_to_proces: list[LoadedComicInfo] = list()
raise NoComicInfoLoaded()
try:
for loaded_cinfo in self.loaded_cinfo_list:
if not loaded_cinfo.has_changes:
logger.info(LOG_TAG + f"Skipping file processing. No changes to it. File: '{loaded_cinfo.file_name}'")
self.on_processed_item(loaded_cinfo)
continue
# noinspection PyBroadException
self.preview_export(loaded_cinfo)
try:
loaded_cinfo.write_metadata()
loaded_cinfo.has_changes = False
self.on_processed_item(loaded_cinfo)
except PermissionError as e:
logger.error("Failed to write changes because of missing permissions "
"or because other program has the file opened", exc_info=True)
self.on_writing_error(exception=e, loaded_info=loaded_cinfo)
# failed_processing.append(loaded_info)
except Exception as e:
logger.exception("Unhandled exception saving changes")
self.on_writing_exception(exception=e, loaded_info=loaded_cinfo)
finally:
self.loaded_cinfo_list_to_proces: list[LoadedComicInfo] = list()
def merge_changed_metadata(self, loaded_cinfo_list: list[LoadedComicInfo]) -> bool:
"""
Merges new_edited_cinfo with each individual loaded_cinfo.
If field is ~~Multiple...Values~~, nothing will be changed.
Else new_cinfo value will be saved
:return: True if any loaded_cinfo has changes
"""
LOG_TAG = "[Merging] "
any_has_changes = False
if not self.new_edited_cinfo:
raise EditedCinfoNotSet()
if loaded_cinfo_list is None:
return False
for loaded_cinfo in loaded_cinfo_list:
logger.debug(LOG_TAG + f"Merging changes to {loaded_cinfo.file_path}")
for cinfo_tag in self.cinfo_tags:
if cinfo_tag == "PageCount":
continue
# Check if the ui has $Multiple_files_selected$
new_value = str(self.new_edited_cinfo.get_by_tag_name(cinfo_tag))
if new_value == self.MULTIPLE_VALUES_CONFLICT:
logger.trace(LOG_TAG + f"Ignoring {cinfo_tag}. Keeping old values")
continue
# Check if the new value in the ui is the same as the one in the comicinfo
old_value = str(loaded_cinfo.cinfo_object.get_by_tag_name(cinfo_tag))
if old_value == new_value:
logger.trace(LOG_TAG + f"Ignoring {cinfo_tag}. Field has not changed")
continue
# Nothing matches so overriding comicinfo value with whatever is in the ui
if cinfo_tag not in loaded_cinfo.changed_tags:
loaded_cinfo.changed_tags.append((cinfo_tag, old_value, new_value))
logger.debug(LOG_TAG + f"[{cinfo_tag:15s}] {TerCol.GREEN}Updating{TerCol.RESET} - Old '{TerCol.RED}{old_value}{TerCol.RESET}' vs "
f"New: '{TerCol.YELLOW}{new_value}{TerCol.RESET}' - Keeping {TerCol.YELLOW}new{TerCol.RESET} value")
loaded_cinfo.cinfo_object.set_by_tag_name(cinfo_tag, new_value)
loaded_cinfo.has_changes = True
any_has_changes = True
# Check if covers are modified
if any((loaded_cinfo.cover_action is not None, loaded_cinfo.backcover_action is not None)):
any_has_changes = True
loaded_cinfo.has_changes = True
self.new_edited_cinfo = None
return any_has_changes
def open_cinfo_list(self, abort_load_check:callable,append_items=False) -> bool:
"""
Creates a list of comicinfo with the comicinfo metadata from the selected files.
:raises CorruptedComicInfo: If the data inside ComicInfo.xml could not be read after trying to fix the data
:raises BadZipFile: If the provided zip is not a valid zip or is broken
"""
logger.debug("Loading files")
if append_items is False:
self.loaded_cinfo_list: list[LoadedComicInfo] = list()
# Skip warnings if one was already displayed
missing_rar_tool = False
total_files = len(self.selected_files_path)
if total_files == 0:
return False
for i, file_path in enumerate(self.selected_files_path):
if any(file_path in comic.file_path for comic in self.loaded_cinfo_list):
logger.warning("Skipped loading file: File already loaded",extra={'processed_filename':file_path})
continue
if abort_load_check():
logger.info("Abort loading")
self.loaded_cinfo_list: list[LoadedComicInfo] = list()
return False
try:
loaded_cinfo = LoadedComicInfo(path=file_path)
if Settings().get(SettingHeading.Main, 'cache_cover_images') and not self.is_cli:
loaded_cinfo.load_all()
else:
loaded_cinfo.load_metadata()
except CorruptedComicInfo as e:
# Logging is handled already in LoadedComicInfo load_metadata method
loaded_cinfo = LoadedComicInfo(path=file_path, comicinfo=ComicInfo()).load_metadata()
self.on_corruped_metadata_error(e, loaded_info=loaded_cinfo or file_path)
continue
except BadZipFile as e:
logger.error("Bad zip file. Either the format is not correct or the file is broken", exc_info=False)
self.on_badzipfile_error(e, file_path=file_path)
continue
except EOFError as e:
logger.error("Bad zip file. The file seems to be broken", exc_info=True)
self.on_badzipfile_error(e, file_path=file_path)
continue
except MissingRarTool as e:
if not missing_rar_tool:
logger.exception("Error loading the metadata for some files. No rar tools available", exc_info=False)
self.on_missing_rar_tools(e)
missing_rar_tool = True
continue
self.loaded_cinfo_list.append(loaded_cinfo)
self.on_item_loaded(loaded_cinfo=loaded_cinfo, cursor=i, total=total_files)
# self.on_item_loaded(loaded_cinfo)
logger.debug("Files selected")
return True
def preview_export(self, loaded_cinfo):
"""
Debug function to preview loaded_cinfo in terminal
:param loaded_cinfo:
:return:
"""
...
def fetch_online(self, partial_comic_info):
selected_source = ScraperFactory().get_scraper(Settings().get(SettingHeading.ExternalSources, 'default_metadata_source'))
if not selected_source:
raise Exception("Unhandled exception. Metadata sources are not loaded or there's a bug in it."
"Raise an issue if this happens.")
try:
return selected_source.get_cinfo(partial_comic_info)
except MangaNotFoundError as e:
logger.exception(str(e))
self.on_manga_not_found(e, partial_comic_info)
return None
================================================
FILE: MangaManager/src/MetadataManager/__init__.py
================================================
import logging
import src
from src.Common import ResourceLoader
from src.MetadataManager.GUI.windows.MainWindow import MainWindow
from src.MetadataManager.GUI.OneTimeMessageBox import OneTimeMessageBox
from src.MetadataManager.GUI.widgets.MessageBoxWidget import MessageBoxButton
from src.Settings import Settings, SettingHeading
logger = logging.getLogger()
icon_path = ResourceLoader.get('icon.ico')
def load_extensions():
from src.DynamicLibController.extension_manager import load_extensions
try:
src.loaded_extensions = load_extensions(src.EXTENSIONS_DIR)
except Exception:
logger.exception("Exception loading the extensions")
def execute_gui():
# Ensure there are some settings, if not, set them as the default
Settings().set_default(SettingHeading.ExternalSources, 'default_metadata_source', "AniList")
Settings().set_default(SettingHeading.ExternalSources, 'default_cover_source', "MangaDex")
app = MainWindow()
try:
app.iconbitmap(icon_path)
except:
logger.exception("Exception loading icon")
OneTimeMessageBox("test_welcome_to_mm"). \
with_title("Welcome to MangaManager"). \
with_actions([MessageBoxButton(0, "Thanks")]). \
build().prompt()
app.mainloop()
================================================
FILE: MangaManager/src/Settings/SettingControl.py
================================================
import abc
from typing import Callable
from .SettingControlType import SettingControlType
class SettingControl(abc.ABC):
key: str = ''
name: str = ''
tooltip: str | None = ''
control_type: SettingControlType
value = ''
"""
Only applicable for SettingControlType.Options
"""
values: list = []
# Used to run validations on the control
validate: Callable[[str], str] = None
# Used to format the value before saving to persistence layer
format_value: Callable[[str], str] = None
def __init__(self, key, name, control_type, value='', tooltip='', validate=None, format_value=None):
self.key = key
self.name = name
self.control_type: SettingControlType = control_type
self.value = value
self.tooltip = tooltip
self.validate = validate
self.format_value = format_value
if value == '' and type is SettingControlType.Bool:
self.value = False
def set_values(self, values):
if self.control_type is not SettingControlType.Options:
return
self.values = values
================================================
FILE: MangaManager/src/Settings/SettingControlType.py
================================================
from enum import Enum
class SettingControlType(Enum):
Bool = 0,
Text = 1,
Options = 2
================================================
FILE: MangaManager/src/Settings/SettingSection.py
================================================
from .SettingControl import SettingControl
class SettingSection:
"""
A section of config controls. Will render under a group in Settings window
"""
pretty_name: str = ''
values: list[SettingControl] = []
def __init__(self, name, key, values=None):
if values is None:
values = list()
self.pretty_name = name
self.key = key
self.values = values
def get_control(self, key):
for v in self.values:
if v.key == key:
return v
return None
================================================
FILE: MangaManager/src/Settings/Settings.py
================================================
import configparser
import logging
import os
from pathlib import Path
from src.Settings.SettingsDefault import default_settings
logger = logging.getLogger()
SETTING_FILE = "settings.ini"
class Settings:
""" This is a singleton that holds settings.ini key/values """
__instance = None
config_parser = configparser.ConfigParser(interpolation=None)
_config_file: Path = Path(Path.home(), "MangaManager/" + SETTING_FILE)
@property
def config_file(self):
return Settings._config_file
def __new__(cls):
if Settings.__instance is None:
Settings.__instance = object.__new__(cls)
# Settings._config_file= os.path.abspath(config_file)
if len(Settings.__instance.config_parser.sections()) == 0:
logger.info('Loading Config from: {}'.format(Settings.__instance.config_file))
Settings.__instance.load()
return Settings.__instance
def __init__(self):
# self.config_file = config_file
if os.path.exists(self.config_file):
self.load()
else:
if not os.path.exists(SETTING_FILE):
self.save()
self.load()
else:
self.load(SETTING_FILE)
self.save()
def save(self):
"""Save the current settings from memory to disk"""
with open(self._config_file, 'w') as configfile:
self.config_parser.write(configfile)
def load(self,override_settings_from=None):
"""Load the data from file and populate DefaultSettings"""
self.config_parser.read(override_settings_from or self._config_file) # migration, change file location
# Ensure all default settings exists, else add them
for section in default_settings:
if section not in self.config_parser.sections():
self.config_parser.add_section(section)
for item in default_settings[section]:
for (key, value) in item.items():
if key not in self.config_parser[section] or self.config_parser.get(section, key) == "":
self.config_parser.set(section, key, str(value))
self.save()
def get(self, section, key):
"""Get a key's value, None if not present"""
if not self.config_parser.has_section(section) or not self.config_parser.has_option(section, key):
logger.error('Section or Key did not exist in settings: {}.{}'.format(section, key))
return None
value = self.config_parser.get(section, key).strip()
match value.lower():
case "true":
return True
case "false":
return False
case _:
return value
def set_default(self, section, key, value):
"""Sets a key's value only if it doesn't exist"""
self._create_section(section)
if key not in self.config_parser[section]:
self.config_parser.set(section, key, str(value))
def get_default(self, section, key, default_value):
"""
Returns default value and creates the key if it doesn't exist
"""
self.set_default(section, key, default_value)
return self.get(section, key)
def set(self, section, key, value):
"""Sets a key's value. Will Save to disk and reload Settings"""
self._create_section(section)
self.config_parser.set(section, key, str(value))
self.save()
self.load()
def _create_section(self, section):
if section not in self.config_parser:
self.config_parser.add_section(section)
def _load_test(self):
Settings._config_file = "test_settings.ini"
Settings.config_parser = configparser.ConfigParser(interpolation=None)
self.save()
self.load()
================================================
FILE: MangaManager/src/Settings/SettingsDefault.py
================================================
from enum import StrEnum
class SettingHeading(StrEnum):
Main = "Main",
WebpConverter = "Webp Converter",
ExternalSources = "External Sources",
MessageBox = "Message Box"
default_settings = {
SettingHeading.Main: [
{"library_path": ""},
{"covers_folder_path": ""},
{"cache_cover_images": True},
{"create_backup_comicinfo": True},
# {"selected_layout": "default"},
{"move_to_template": ""},
{"remove_old_selection_on_drag_drop":True}
],
SettingHeading.WebpConverter: [
{"default_base_path": ""},
],
SettingHeading.ExternalSources: [
{"default_metadata_source": "AniList"},
{"default_cover_source": "MangaDex"},
],
SettingHeading.MessageBox: {}
}
================================================
FILE: MangaManager/src/Settings/__init__.py
================================================
from .SettingControl import SettingControl
from .SettingControlType import SettingControlType
from .SettingSection import SettingSection
from .Settings import Settings
from .SettingsDefault import SettingHeading
================================================
FILE: MangaManager/src/__init__.py
================================================
import logging
from os import environ
from os.path import abspath
from pathlib import Path
import requests # Needed for sources to work
requests.s3423 = "" # Random patch so import does not get cleaned up
from pkg_resources import resource_filename
logger = logging.getLogger()
MM_PATH = Path(Path.home(), "MangaManager")
MM_PATH.mkdir(exist_ok=True, parents=True)
DEV_BUILD = f'{environ.get("$$_ENV_DEVELOPMENT_MM_$$")}'
DEV_BUILD = DEV_BUILD.lower() == "true"
sub_mm_path = abspath(resource_filename(__name__, '../'))
logger.error(f"sub_mm_path:{sub_mm_path}")
EXTENSIONS_DIR = Path(sub_mm_path, "Extensions")
EXTENSIONS_DIR.mkdir(exist_ok=True)
SOURCES_DIR = Path(sub_mm_path, "ExternalSources")
SOURCES_DIR.mkdir(exist_ok=True)
loaded_extensions = []
================================================
FILE: MangaManager/src/__version__.py
================================================
__version__ = "1.0.4:stable:fd7b72b0"
================================================
FILE: MangaManager/tests/Common/__init__.py
================================================
================================================
FILE: MangaManager/tests/Common/test_ComicInfo.py
================================================
import unittest
from common.models import ComicInfo
from tests.common import is_valid_xml
class ComicInfoTests(unittest.TestCase):
def test_sample_xml_isvalid(self):
cinfo = ComicInfo()
cinfo.series = "SeriesName"
cinfo.writer = "WriterName"
self.assertTrue(is_valid_xml(cinfo.to_xml()))
def test_valid_xml(self):
TEST_COMIC_INFO_STRING = """
Title
AlternateSeries
Summary
Notes
Writer
Inker
Colorist
Letterer
CoverArtist
Editor
Translator
Publisher
Imprint
Genre
Tags
Web
Characters
Teams
Locations
ScanInformation
StoryArc
SeriesGroup
Unknown
3
Other field
"""
self.assertTrue(is_valid_xml(TEST_COMIC_INFO_STRING))
if __name__ == '__main__':
unittest.main()
================================================
FILE: MangaManager/tests/Common/test_utils.py
================================================
import unittest
from common.models import ComicInfo
from src.DynamicLibController.models.IMetadataSource import IMetadataSource
class MyTestCase(unittest.TestCase):
def test_update_people_from_mapping(self):
people_mapping = {
"Author": [
"Writer"
],
"Artist": [
"Penciller",
"Inker",
]
}
data = {
"authors": [
{
"name": "Author 1",
"role": "Author"
},
{
"name": "Artist 1",
"role": "Artist"
},
]
}
comicinfo = ComicInfo()
IMetadataSource.update_people_from_mapping(data["authors"], people_mapping, comicinfo,
lambda item: item["name"],
lambda item: item["role"])
self.assertEqual("Author 1", comicinfo.writer)
self.assertEqual("Artist 1", comicinfo.penciller)
self.assertEqual("Artist 1", comicinfo.inker)
if __name__ == '__main__':
unittest.main()
================================================
FILE: MangaManager/tests/ExtensionsTests/__init__.py
================================================
================================================
FILE: MangaManager/tests/ExtensionsTests/test_WebpConverter.py
================================================
# from src.Common.loadedcomicinfo import LoadedComicInfo
from logging_setup import add_trace_level
add_trace_level()
# Fixme reimplement extensions
# class LoadedComicInfoConversToWebpTests(unittest.TestCase):
# def setUp(self) -> None:
# print(os.getcwd())
# # Make sure there are no test files else delete them:
# leftover_files = [listed for listed in os.listdir() if listed.startswith("Test__") and listed.endswith(".cbz")
# or listed.startswith("tmp")]
# for file in leftover_files:
# os.remove(file)
# self.test_files_names = []
# print("\n", self._testMethodName)
# print("Setup:")
# self.random_int = random.random() + random.randint(1, 40)
# for ai in range(3):
# out_tmp_zipname = f"Test__{ai}_{random.randint(1, 6000)}.cbz"
# self.test_files_names.append(out_tmp_zipname)
# self.temp_folder = tempfile.mkdtemp()
# print(f" Creating: {out_tmp_zipname}") # , self._testMethodName)
# # Create a random int so the values in the cinfo are unique each test
#
# with zipfile.ZipFile(out_tmp_zipname, "w") as zf:
# for i in range(5):
# image = Image.new('RGB', size=(20, 20), color=(255, 73, 95))
# image.format = "JPEG"
# # file = tempfile.NamedTemporaryFile(suffix=f'.jpg', prefix=str(i).zfill(3), dir=self.temp_folder)
# imgByteArr = io.BytesIO()
# image.save(imgByteArr, format=image.format)
# imgByteArr = imgByteArr.getvalue()
# zf.writestr(os.path.basename(f"{str(i).zfill(3)}.jpg"), imgByteArr)
# self.initial_dir_count = len(os.listdir(os.getcwd()))
#
# def tearDown(self) -> None:
# print("Teardown:")
# for filename in self.test_files_names:
# print(f" Deleting: {filename}") # , self._testMethodName)
# try:
# os.remove(filename)
# except Exception as e:
# print(e)
#
# def test_processing_should_convert_to_webp(self):
# file_name = self.test_files_names[0]
# loaded_cinfo = LoadedComicInfo(file_name).load_cover_info()
# loaded_cinfo.convert_to_webp()
#
# with zipfile.ZipFile(file_name, "r") as zf:
# for filename in zf.namelist():
# with zf.open(filename) as imagebytes:
# image = Image.open(imagebytes)
# self.assertEqual("WEBP", image.format)
# if __name__ == '__main__':
# unittest.main()
================================================
FILE: MangaManager/tests/ExternalMetadataTests/__init__.py
================================================
================================================
FILE: MangaManager/tests/ExternalMetadataTests/test_AniList.py
================================================
import unittest
from common.models import ComicInfo
class TestSources(unittest.TestCase):
def test_AnilistReturnMatches(self):
from ExternalSources.MetadataSources import ScraperFactory
scraper = ScraperFactory().get_scraper("AniList")
cinfo = ComicInfo()
cinfo.series = "tensei shitara datta ken"
ret_cinfo = scraper.get_cinfo(cinfo)
print("Assert series name matches")
self.assertEqual("Tensei Shitara Slime Datta Ken", ret_cinfo.series)
print("Assert loc series name matches")
self.assertEqual("That Time I Got Reincarnated as a Slime", ret_cinfo.localized_series)
def test_AnilistReturnMatches_url(self):
from ExternalSources.MetadataSources import ScraperFactory
scraper = ScraperFactory().get_scraper("AniList")
cinfo = ComicInfo()
cinfo.web = "https://anilist.co/manga/98797/Adachi-to-Shimamura/"
ret_cinfo = scraper.get_cinfo(cinfo)
print("Assert series name matches")
self.assertIn("https://anilist.co/manga/98797", [s.strip() for s in ret_cinfo.web.split(",")])
================================================
FILE: MangaManager/tests/LoadedComicInfo/__init__.py
================================================
================================================
FILE: MangaManager/tests/LoadedComicInfo/test_Covers.py
================================================
import io
import unittest
import zipfile
from PIL import Image
from src.Common.LoadedComicInfo.LoadedComicInfo import CoverActions, LoadedComicInfo
from src.Common.utils import obtain_cover_filename
from tests.common import CBZManipulationTests, create_test_cbz
class LoadedCInfo_Utils(unittest.TestCase):
def test_CoverParsing(self):
list_filenames_to_test = [
("000001.jpg", ("0_ not valid image file 00001.jpg", "000001.jpg", "000002.jpg",
"this is a random image from page 4.png")),
("cover_0001.jpg", ("cover_0001.jpg", "0_ not valid image file 00001.jpg", "000001.jpg", "000002.jpg",
"this is a random image from page 4.png"))
]
print("Running unit tests for cover filename parsing")
for filename in list_filenames_to_test:
with self.subTest(f"Subtest - Parsed name should match {filename[0]}"):
selected = str(obtain_cover_filename(filename[1])[0])
print(f"Selected file is: {selected}")
self.assertEqual(filename[0], selected)
# self.assertEqual(True, False) # add assertion here
class CoverHandling_Recompressing_Tests(CBZManipulationTests):
def setUp(self) -> None:
super().setUp()
self.test_files_names = create_test_cbz(2)
image = Image.new('RGB', size=(20, 20), color=(255, 73, 95))
image.format = "JPEG"
# imgByteArr = io.BytesIO()
self.test_image_file = "Test__new_cover.jpeg"
image.save("Test__new_cover.jpeg", format=image.format)
self.test_files_names.append("Test__new_cover.jpeg")
def test_delete_cover(self):
for file in self.test_files_names:
if not file.endswith(".cbz"):
continue
lcinfo = LoadedComicInfo(file).load_cover_info(False)
lcinfo.cover_action = CoverActions.DELETE
lcinfo._process(False, False)
with zipfile.ZipFile(file, "r") as zf:
print("Asserting the processed file has one image less")
self.assertEqual(3, len(zf.namelist()))
def test_delete_backcover(self):
for file in self.test_files_names:
if not file.endswith(".cbz"):
continue
lcinfo = LoadedComicInfo(file).load_cover_info(False)
lcinfo.backcover_action = CoverActions.DELETE
lcinfo._process(False, False)
with zipfile.ZipFile(file, "r") as zf:
print("Asserting the processed file has one image less")
self.assertEqual(3, len(zf.namelist()))
def test_append_cover(self):
for file in self.test_files_names:
if not file.endswith(".cbz"):
continue
lcinfo = LoadedComicInfo(file).load_cover_info(False)
lcinfo.cover_action = CoverActions.APPEND
lcinfo.new_cover_path = self.test_image_file
lcinfo._process(False, False)
with zipfile.ZipFile(file, "r") as zf:
print("Asserting the processed file has one image more")
self.assertEqual(5, len(zf.namelist()))
def test_append_backcover(self):
for file in self.test_files_names:
if not file.endswith(".cbz"):
continue
lcinfo = LoadedComicInfo(file).load_cover_info(False)
lcinfo.backcover_action = CoverActions.APPEND
lcinfo.new_backcover_path = self.test_image_file
lcinfo._process(False, False)
with zipfile.ZipFile(file, "r") as zf:
print("Asserting the processed file has one image more")
self.assertEqual(5, len(zf.namelist()))
self.assertTrue(obtain_cover_filename(zf.namelist())[1].startswith("~99"))
def test_replace_cover(self):
for file in self.test_files_names:
if not file.endswith(".cbz"):
continue
lcinfo = LoadedComicInfo(file).load_cover_info(False)
lcinfo.cover_action = CoverActions.REPLACE
lcinfo.new_cover_path = self.test_image_file
lcinfo._process(False, False)
with zipfile.ZipFile(file, "r") as zf:
print("Asserting the processed file has one image more")
self.assertEqual(4, len(zf.namelist()))
image_data = zf.read(obtain_cover_filename(zf.namelist())[0])
image = Image.open(io.BytesIO(image_data))
image_color = image.getpixel((0,0))
self.assertFalse(image_color == (255,255,255))
def test_replace_backcover(self):
for file in self.test_files_names:
if not file.endswith(".cbz"):
continue
lcinfo = LoadedComicInfo(file).load_cover_info(False)
lcinfo.backcover_action = CoverActions.REPLACE
lcinfo.new_backcover_path = self.test_image_file
lcinfo._process(False, False)
with zipfile.ZipFile(file, "r") as zf:
print("Asserting the processed file has one image more")
self.assertEqual(4, len(zf.namelist()))
image_data = zf.read(obtain_cover_filename(zf.namelist())[1])
image = Image.open(io.BytesIO(image_data))
image_color = image.getpixel((0,0))
self.assertFalse(image_color == (255,255,255))
================================================
FILE: MangaManager/tests/LoadedComicInfo/test_LoadedCInfo.py
================================================
import os
import random
import tempfile
import unittest
import zipfile
from unittest import skip
from common.models import ComicInfo
from src.Common.LoadedComicInfo.LoadedComicInfo import LoadedComicInfo
TEST_COMIC_INFO_STRING = """
Title
AlternateSeries
Summary
Notes
Writer
Inker
Colorist
Letterer
CoverArtist
Editor
Translator
Publisher
Imprint
Genre
Tags
Web
Characters
Teams
Locations
ScanInformation
StoryArc
SeriesGroup
Unknown
3
"""
class LoadedComicInfo_MetadataTests(unittest.TestCase):
"""
The purpose of this Test case is to test the LoadedComicInfo class against simple scenarios where
it's only the comicinfo.xml file
"""
def setUp(self) -> None:
print(os.getcwd())
# Make sure there are no test files else delete them:
leftover_files = [listed for listed in os.listdir() if listed.startswith("Test__") and listed.endswith(".cbz")
or listed.startswith("tmp")]
for file in leftover_files:
os.remove(file)
self.test_files_names = []
print("\n", self._testMethodName)
print("Setup:")
self.random_int = random.random() + random.randint(1, 40)
for ai in range(3):
out_tmp_zipname = f"Test__{ai}_{random.randint(1, 6000)}.cbz"
self.test_files_names.append(out_tmp_zipname)
self.temp_folder = tempfile.mkdtemp()
print(f" Creating: {out_tmp_zipname}") # , self._testMethodName)
# Create a random int so the values in the cinfo are unique each test
with zipfile.ZipFile(out_tmp_zipname, "w") as zf:
zf.writestr("Dummyfile1.ext", "Dummy")
zf.writestr("Dummyfile2.ext", "Dummy")
zf.writestr("Dummyfile3.ext", "Dummy")
zf.writestr("Dummyfile4.ext", "Dummy")
cinfo = ComicInfo()
cinfo.series = f"Series-{ai}-{self.random_int}"
cinfo.writer = f"Writer-{ai}-{self.random_int}"
zf.writestr("ComicInfo.xml", str(cinfo.to_xml()))
with zipfile.ZipFile(f"Test__nometadata.cbz", "w") as zf:
zf.writestr("Dummyfile1.ext", "Dummy")
zf.writestr("Dummyfile2.ext", "Dummy")
zf.writestr("Dummyfile3.ext", "Dummy")
zf.writestr("Dummyfile4.ext", "Dummy")
cinfo = ComicInfo()
cinfo.series = f"Series-{ai}-{self.random_int}"
cinfo.writer = f"Writer-{ai}-{self.random_int}"
self.initial_dir_count = len(os.listdir(os.getcwd()))
def tearDown(self) -> None:
print("Teardown:")
self.test_files_names.append("Test__nometadata.cbz")
for filename in self.test_files_names:
print(f" Deleting: {filename}") # , self._testMethodName)
try:
os.remove(filename)
except Exception as e:
print(e)
def test_simple_read(self):
for i, file_names in enumerate(self.test_files_names):
with self.subTest(f"Testing individual file read metadata - {i + 1}/{len(self.test_files_names)}"):
cinfo = LoadedComicInfo(file_names).load_metadata()
self.assertEqual(f"Series-{i}-{self.random_int}", cinfo.cinfo_object.series)
self.assertEqual(f"Writer-{i}-{self.random_int}", cinfo.cinfo_object.writer)
def test_simple_write(self):
print("Writing new values")
for i, file_names in enumerate(self.test_files_names):
with self.subTest(f"Testing individual file read metadata - {i + 1}/{len(self.test_files_names)}"):
cinfo = LoadedComicInfo(file_names).load_metadata()
cinfo.cinfo_object.notes = f"This text was modified - {self.random_int}"
cinfo.write_metadata()
print("Test files keep all images")
for file_names in self.test_files_names:
with zipfile.ZipFile(file_names, "r") as zf:
self.assertAlmostEqual(len(zf.namelist()), 5,delta=1)
# check changes are saved
print("Testing reading saved values")
for i, file_names in enumerate(self.test_files_names):
with self.subTest(f"Testing individual write metadata - {i + 1}/{len(self.test_files_names)}"):
cinfo = LoadedComicInfo(file_names).load_metadata()
self.assertEqual(f"This text was modified - {self.random_int}", cinfo.cinfo_object.notes)
@skip
def test_simple_backup(self):
for i, file_name in enumerate(self.test_files_names):
with self.subTest(f"Backing up individual metadata - {i + 1}/{len(self.test_files_names)}"):
cinfo = LoadedComicInfo(file_name).load_metadata()
# No backup will be created if no modified metadata
cinfo.cinfo_object.notes = "Notes modified"
cinfo.write_metadata()
with zipfile.ZipFile(file_name, "r") as zf:
print("Asserting backup is in the file")
# In this test there should only be the backed up file because the new modified metadata file gets
# appended later, after the backup flow is run.
self.assertTrue("Old_ComicInfo.xml.bak" in zf.namelist())
print("Making sure the backed up file has content and matches original values:")
cinfo = ComicInfo.from_xml(zf.open("Old_ComicInfo.xml.bak").read().decode("utf-8"))
self.assertEqual(f"Series-{i}-{self.random_int}", cinfo.series)
def test_simple_backup_nometadata(self):
file_name = "Test__nometadata.cbz"
with self.subTest(f"Backing up individual metadata - {file_name}"):
cinfo = LoadedComicInfo(file_name).load_metadata()
cinfo.write_metadata()
with zipfile.ZipFile(file_name, "r") as zf:
print("Asserting backup is in the file")
# In this test there should only be the backed up file because the new modified metadata file gets
# appended later, after the backup flow is run.
self.assertFalse("Old_ComicInfo.xml.bak" in zf.namelist())
if __name__ == '__main__':
unittest.main()
================================================
FILE: MangaManager/tests/LoadedComicInfo/test_LoadedCInfo_backup.py
================================================
import os
import random
import tempfile
import unittest
import zipfile
from common.models import ComicInfo
from src.Common.LoadedComicInfo.LoadedComicInfo import LoadedComicInfo
from src.Settings import Settings, SettingHeading
TEST_COMIC_INFO_STRING = """
Title
AlternateSeries
Summary
Notes
Writer
Inker
Colorist
Letterer
CoverArtist
Editor
Translator
Publisher
Imprint
Genre
Tags
Web
Characters
Teams
Locations
ScanInformation
StoryArc
SeriesGroup
Unknown
3
"""
class LoadedComicInfo_SaveTests(unittest.TestCase):
"""
The purpose of this Test case is to test the LoadedComicInfo class against simple scenarios where
it's only the comicinfo.xml file
"""
s = Settings()
def setUp(self) -> None:
print(os.getcwd())
self.s.set(SettingHeading.Main, "create_backup_comicinfo", False)
# Make sure there are no test files else delete them:
leftover_files = [listed for listed in os.listdir() if listed.startswith("Test__") and listed.endswith(".cbz")
or listed.startswith("tmp")]
for file in leftover_files:
os.remove(file)
self.test_files_names = []
print("\n", self._testMethodName)
print("Setup:")
self.random_int = random.random() + random.randint(1, 40)
for ai in range(1):
out_tmp_zipname = f"Test__{ai}_{random.randint(1, 6000)}.cbz"
self.test_files_names.append(out_tmp_zipname)
self.temp_folder = tempfile.mkdtemp()
print(f" Creating: {out_tmp_zipname}") # , self._testMethodName)
# Create a random int so the values in the cinfo are unique each test
with zipfile.ZipFile(out_tmp_zipname, "w") as zf:
zf.writestr("Dummyfile1.ext", "Dummy")
zf.writestr("Dummyfile2.ext", "Dummy")
zf.writestr("Dummyfile3.ext", "Dummy")
zf.writestr("Dummyfile4.ext", "Dummy")
cinfo = ComicInfo()
cinfo.series = f"Series-{ai}-{self.random_int}"
cinfo.writer = f"Writer-{ai}-{self.random_int}"
zf.writestr("ComicInfo.xml", str(cinfo.to_xml()))
self.initial_dir_count = len(os.listdir(os.getcwd()))
def tearDown(self) -> None:
print("Teardown:")
for filename in self.test_files_names:
print(f" Deleting: {filename}") # , self._testMethodName)
try:
os.remove(filename)
except Exception as e:
print(e)
# I give up, I can't figure out how to do it and mock settings
# def test_simple_backup_doesnt_create_when_turned_off(self):
# self.s.set(SettingHeading.Main, "create_backup_comicinfo", False)
# for i, file_names in enumerate(self.test_files_names):
# with self.subTest(f"Backing up individual metadata - {i + 1}/{len(self.test_files_names)}"):
# cinfo = LoadedComicInfo(file_names).load_metadata()
# cinfo.write_metadata()
# with zipfile.ZipFile(file_names, "r") as zf:
# print("Asserting backup is in the file")
# # In this test there should only be the backed up file because the new modified metadata file gets
# # appended later, after the backup flow is run.
# self.assertFalse("Old_ComicInfo.xml.bak" in zf.namelist())
if __name__ == '__main__':
unittest.main()
================================================
FILE: MangaManager/tests/LoadedComicInfo/test_moveto.py
================================================
import unittest
from common.models import ComicInfo
from src.Common.LoadedComicInfo.LoadedComicInfo import LoadedComicInfo
class MoveToTemplate(unittest.TestCase):
def test_template(self):
cinfo = ComicInfo()
cinfo.volume = 11
cinfo.number = 22
cinfo.publisher = "Publisher33"
cinfo.series = "Series44"
cinfo.title = "Title55"
filename = "filename66.cbz"
a = LoadedComicInfo(None,cinfo,False)
a.file_name = filename
self.assertEqual("Series44 - Publisher33",a.get_template_filename("{series} - {publisher}"))
self.assertEqual("Series44 - Vol.11 Ch.22 - Title55", a.get_template_filename("{series} - Vol.{volume} Ch.{chapter} - {title}"))
self.assertIsNone(a.get_template_filename("this {key_here} does not exist"))
================================================
FILE: MangaManager/tests/MetadataManagerTests/GUI/__init__.py
================================================
================================================
FILE: MangaManager/tests/MetadataManagerTests/GUI/test_MetadataEditorGUI.py
================================================
import glob
import importlib
import os
import random
from tkinter.filedialog import askopenfiles
from common.models import ComicInfo
from logging_setup import add_trace_level
from src.Common.LoadedComicInfo.LoadedComicInfo import LoadedComicInfo
from src.MetadataManager import MetadataManagerGUI
from src.MetadataManager.MetadataManagerLib import MetadataManagerLib
from tests.common import create_dummy_files, TKinterTestCase, parameterized_class, create_test_cbz
add_trace_level()
layouts_path = os.path.abspath("src/Layouts")
print(layouts_path)
modules = glob.glob(os.path.join(layouts_path, "*.py"))
print(f"Found modules: [{', '.join(modules)}]")
extensions = [os.path.basename(f)[:-3] for f in modules if os.path.isfile(f) and not f.endswith('__init__.py')]
print(f"Found extensions: [{', '.join(extensions)}]")
loaded_layouts = []
# Note: Layout is the class
for ext in extensions:
loaded_layouts.append([importlib.import_module(f'.{ext}', package="src.Layouts").Layout])
@parameterized_class(('GUI',), loaded_layouts)
class UiToCinfoTest(TKinterTestCase):
test_files_names = None
def setUp(self) -> None:
leftover_files = [listed for listed in os.listdir() if listed.startswith("Test__") and listed.endswith(".cbz")]
for file in leftover_files:
os.remove(file)
self.test_files_names = create_dummy_files(2)
def tearDown(self) -> None:
MetadataManagerGUI.askopenfiles = askopenfiles
print("Teardown:")
for filename in self.test_files_names:
print(f" Deleting: {filename}") # , self._testMethodName)
try:
os.remove(filename)
except Exception as e:
print(e)
def test_all_ui_fields_loaded(self):
self.root = app = self.GUI()
app.is_test = True
app.title("test_all_ui_fields_loaded")
for tag in MetadataManagerLib.cinfo_tags:
with self.subTest(f"{tag}"):
print(f"Assert '{tag}' widget is displayed")
self.assertTrue(tag in app.widget_mngr.get_tags())
app.destroy()
def test_all_fields_map_to_cinfo(self):
self.root = app = self.GUI()
app.is_test = True
app.title("test_all_fields_map_to_cinfo")
# new_edited = comicinfo.ComicInfo()
# app.new_edited_cinfo = new_edited
app.loaded_cinfo_list = [LoadedComicInfo(filename).load_metadata() for filename in self.test_files_names]
self.pump_events()
app.focus_set()
random_number = random.random()
for cinfo_tag in app.cinfo_tags:
widget = app.widget_mngr.get_widget(cinfo_tag)
if widget.validation:
app.widget_mngr.get_widget(cinfo_tag).set(random_number, )
try:
app.widget_mngr.get_widget(cinfo_tag).set(cinfo_tag, )
except AttributeError:
app.widget_mngr.get_widget(cinfo_tag).set(cinfo_tag, )
# Set different entry types values
app.widget_mngr.get_widget("Summary").widget.set("Summary", )
app.widget_mngr.get_widget("AgeRating").widget.set("AgeRating", )
app.widget_mngr.get_widget("BlackAndWhite").widget.set("BlackAndWhite", )
app.widget_mngr.get_widget("Manga").widget.set("Manga", )
# app.serialize_gui_to_edited_cinfo()
# app.pre_process()
app._serialize_gui_to_cinfo()
for cinfo_tag in app.cinfo_tags:
widget = app.widget_mngr.get_widget(cinfo_tag)
# if not isinstance(widget, ComboBoxWidget):
# continue
with self.subTest(f"{cinfo_tag}"):
print(f"Comparing '{widget.get()}' vs ('{cinfo_tag}' or '{random_number}')")
self.assertTrue(widget.get() == cinfo_tag or widget.get() == random_number or str(random_number))
# app.process()
app.destroy()
def test_full_flow(self):
def custom_askopenfiles(*_, **__):
return [open(filename, "r") for filename in self.test_files_names]
MetadataManagerGUI.askopenfiles = custom_askopenfiles
self.root = app = self.GUI()
app.is_test = True
app.title("test_full_flow")
self.pump_events()
app.select_files()
app.loaded_cinfo_list = [LoadedComicInfo(filename).load_metadata() for filename in self.test_files_names]
self.pump_events()
app.focus_set()
random_number = random.random()
for cinfo_tag in app.cinfo_tags:
widget = app.widget_mngr.get_widget(cinfo_tag)
if widget.validation:
app.widget_mngr.get_widget(cinfo_tag).set(random_number, )
try:
app.widget_mngr.get_widget(cinfo_tag).set(cinfo_tag, )
except AttributeError:
app.widget_mngr.get_widget(cinfo_tag).set(cinfo_tag, )
# Set different entry types values
app.widget_mngr.get_widget("Summary").widget.set("Summary", )
app.widget_mngr.get_widget("AgeRating").widget.set("AgeRating", )
app.widget_mngr.get_widget("BlackAndWhite").widget.set("BlackAndWhite", )
app.widget_mngr.get_widget("Manga").widget.set("Manga", )
app.pre_process()
app.destroy()
@parameterized_class(('GUI',), loaded_layouts)
class CinfoToUiTest(TKinterTestCase):
test_files_names = None
def setUp(self) -> None:
self.GUI.is_test = True
leftover_files = [listed for listed in os.listdir() if listed.startswith("Test__") and listed.endswith(".cbz")]
for file in leftover_files:
try:
os.remove(file)
except PermissionError:
...
self.test_files_names = create_dummy_files(2)
def tearDown(self) -> None:
MetadataManagerGUI.askopenfiles = askopenfiles
print("Teardown:")
try:
self.root.destroy()
except:
...
for filename in self.test_files_names:
print(f" Deleting: {filename}") # , self._testMethodName)
try:
os.remove(filename)
except Exception as e:
print(e)
def test_one_field_empty_should_not_be_overwritten_by_data_from_other_cinfo_with_field_filled(self):
# TEST DATA
cinfo1_series = "This series from file 1 should be kept and not be applied to cinfo 2"
cinfo2_series = ""
self.root = app = self.GUI()
app.title("test_one_field_empty_should_not_be_overwritten_by_data_from_other_cinfo_with_field_filled")
# Create metadata objects
cinfo_1 = ComicInfo()
cinfo_1.series = cinfo1_series
cinfo_2 = ComicInfo()
cinfo_2.series = cinfo2_series
# Created loaded metadata objects
metadata_1 = LoadedComicInfo(self.test_files_names[0], comicinfo=cinfo_1)
metadata_2 = LoadedComicInfo(self.test_files_names[1], comicinfo=cinfo_2)
app.loaded_cinfo_list = [metadata_1, metadata_2]
# app.loaded_cinfo_list_to_process = app.loaded_cinfo_list
# There is no edited comicinfo, it should fail
new_cinfo = ComicInfo()
app.new_edited_cinfo = new_cinfo
app._serialize_cinfolist_to_gui()
app._serialize_gui_to_cinfo()
print("Assert original values will be kept")
self.assertEqual(app.MULTIPLE_VALUES_CONFLICT, app.new_edited_cinfo.series)
# self.assertEqual(cinfo1_series, metadata_2.cinfo_object.series)
app.selected_files_path = self.test_files_names
app.pre_process()
# print("Assert final values match original")
# self.assertEqual(app.MULTIPLE_VALUES_CONFLICT, app.new_edited_cinfo.series)
app.destroy()
@parameterized_class(('GUI',), loaded_layouts)
class BulkLoadingTest(TKinterTestCase):
def setUp(self) -> None:
self.GUI.is_test = True
leftover_files = [listed for listed in os.listdir() if listed.startswith("Test__") and listed.endswith(".cbz")]
for file in leftover_files:
os.remove(file)
self.test_files_names = create_test_cbz(4, 3)
def tearDown(self) -> None:
MetadataManagerGUI.askopenfiles = askopenfiles
print("Teardown:")
try:
self.root.destroy()
except:
...
for filename in self.test_files_names:
print(f" Deleting: {filename}") # , self._testMethodName)
try:
os.remove(filename)
except Exception as e:
print(e)
def test_bulk_selection(self):
"""
This tests the flow of loading multiple file and selecting a single file.
It's expected that the merged comicinfo has the right data
:return:
"""
def custom_askopenfiles(*_, **__):
return [open(filename, "r") for filename in self.test_files_names]
# MetadataManagerGUI.askopenfiles = custom_askopenfiles
self.root = app = self.GUI()
app.is_test = True
app.title("test_bulk_selection")
self.pump_events()
for i, filepath in enumerate(self.test_files_names):
cinfo = ComicInfo()
cinfo.set_by_tag_name("Series", f"Series_sample - {i}")
loaded_cinfo = LoadedComicInfo(filepath, comicinfo=cinfo).load_metadata()
app.loaded_cinfo_list.append(loaded_cinfo)
app.on_item_loaded(loaded_cinfo)
self.pump_events()
app.focus_set()
app.selected_files_treeview.selection_set(app.selected_files_treeview.get_children()[1])
self.pump_events()
app.focus_set()
self.assertFalse(any([True for lcinfo in app.loaded_cinfo_list if lcinfo.has_changes]))
@parameterized_class(('GUI',), loaded_layouts)
class GenericUITest(TKinterTestCase):
def setUp(self):
self.GUI.is_test = True
super().setUp()
def test_settings_window_correctly_displayed(self):
self.root = app = self.GUI()
app.show_settings()
================================================
FILE: MangaManager/tests/MetadataManagerTests/GUI/test_dinamic_layouts.py
================================================
import glob
import importlib
import os
from src.MetadataManager import MetadataManagerGUI
from tests.common import create_dummy_files, TKinterTestCase, parameterized_class
layouts_path = os.path.abspath("src/Layouts")
modules = glob.glob(os.path.join(layouts_path, "*.py"))
extensions = [os.path.basename(f)[:-3] for f in modules if os.path.isfile(f) and not f.endswith('__init__.py')]
loaded_layouts = []
for ext in extensions:
loaded_layouts.append([importlib.import_module(f'.{ext}',
package="src"
".Layouts").Layout])
@parameterized_class(('GUI',), loaded_layouts)
class DinamicLayoutTests(TKinterTestCase):
test_files_names = None
def setUp(self) -> None:
leftover_files = [listed for listed in os.listdir() if listed.startswith("Test__") and listed.endswith(".cbz")]
for file in leftover_files:
os.remove(file)
self.test_files_names = create_dummy_files(2)
def custom_askopenfiles(*_, **__):
return [open(filename, "r") for filename in self.test_files_names]
MetadataManagerGUI.askopenfiles = custom_askopenfiles
def tearDown(self) -> None:
print("Teardown:")
try:
self.root.destroy()
except:
...
for filename in self.test_files_names:
print(f" Deleting: {filename}") # , self._testMethodName)
try:
os.remove(filename)
except Exception as e:
print(e)
def test_all_fields_are_populated(self):
self.root = self.GUI()
app: MetadataManagerGUI.GUIApp = self.root
app.is_test = True
app.title(f"test_all_fields_are_populated_{app.name}")
print("Assert all fields are registered")
self.assertTrue(app.widget_mngr.get_tags())
self.pump_events()
================================================
FILE: MangaManager/tests/MetadataManagerTests/GUI/test_fetch_metadata.py
================================================
import glob
import importlib
import os
from logging_setup import add_trace_level
from src.MetadataManager.MetadataManagerGUI import GUIApp
from tests.common import TKinterTestCase, parameterized_class
add_trace_level()
layouts_path = os.path.abspath("src/Layouts")
print(layouts_path)
modules = glob.glob(os.path.join(layouts_path, "*.py"))
print(f"Found modules: [{', '.join(modules)}]")
extensions = [os.path.basename(f)[:-3] for f in modules if os.path.isfile(f) and not f.endswith('__init__.py')]
print(f"Found extensions: [{', '.join(extensions)}]")
loaded_layouts = []
# Note: Layout is the class
for ext in extensions:
loaded_layouts.append([importlib.import_module(f'.{ext}', package="src.Layouts").Layout])
@parameterized_class(('GUI',), loaded_layouts)
class FetchMetadataFlowTest(TKinterTestCase):
def test_fetch_online_button_flow(self):
self.root = app = self.GUI()
app: GUIApp
app.is_test = True
app.title("test_fetch_online_button_flow")
# Set series name in series widget
app.widget_mngr.get_widget("Series").set("tensei shitara datta ken")
app.process_fetch_online()
print("Assert series name matches")
self.assertEqual("Tensei Shitara Slime Datta Ken", app.widget_mngr.get_widget("Series").get())
print("Assert loc series name matches")
self.assertEqual("That Time I Got Reincarnated as a Slime", app.widget_mngr.get_widget("LocalizedSeries").get())
app.destroy()
================================================
FILE: MangaManager/tests/MetadataManagerTests/__init__.py
================================================
================================================
FILE: MangaManager/tests/MetadataManagerTests/test_MetadataEditorCore.py
================================================
import os
import unittest
import zipfile
from unittest.mock import patch, MagicMock
import src.Common.LoadedComicInfo.LoadedComicInfo
from common.models import ComicInfo
from logging_setup import add_trace_level
from src.Common.LoadedComicInfo.LoadedComicInfo import LoadedComicInfo
from src.Common.errors import CorruptedComicInfo, NoComicInfoLoaded
from src.Common.errors import EditedCinfoNotSet, BadZipFile
from src.MetadataManager import MetadataManagerLib
from tests.common import create_dummy_files
add_trace_level()
class CoreTesting(unittest.TestCase):
test_files_names = None
@patch.multiple(MetadataManagerLib.MetadataManagerLib, __abstractmethods__=set())
def setUp(self) -> None:
self.instance = MetadataManagerLib.MetadataManagerLib()
leftover_files = [listed for listed in os.listdir() if listed.startswith("Test__") and listed.endswith(".cbz")]
for file in leftover_files:
os.remove(file)
def tearDown(self) -> None:
# Some cases patch LoadedComicInfo. patchin back just in case
src.Common.LoadedComicInfo.LoadedComicInfo.LoadedComicInfo = LoadedComicInfo
print("Teardown:")
for filename in self.test_files_names:
print(f" Deleting: {filename}") # , self._testMethodName)
try:
os.remove(filename)
except Exception as e:
print(e)
def test(self):
out_tmp_zipname = f"random_image_1_not_image.ext.cbz"
out_tmp_zipname2 = f"random_image_1_not_image.ext.cbz"
self.test_files_names = []
self.len_file_1 = 5
with zipfile.ZipFile(out_tmp_zipname, "w") as zf:
zf.writestr("Dummyfile.ext", "Dummy")
zf.writestr("Dummyfile1.ext", "Dummy")
zf.writestr("Dummyfile2.ext", "Dummy")
zf.writestr("Dummyfile3.ext", "Dummy")
zf.writestr("Dummyfile4.ext", "Dummy")
self.test_files_names.append(out_tmp_zipname)
self.len_file_2 = 5
with zipfile.ZipFile(out_tmp_zipname2, "w") as zf:
zf.writestr("Dummyfile.ext", "Dummy")
zf.writestr("Dummyfile1.ext", "Dummy")
zf.writestr("Dummyfile2.ext", "Dummy")
zf.writestr("Dummyfile3.ext", "Dummy")
zf.writestr("Dummyfile4.ext", "Dummy")
print(f" Creating: {out_tmp_zipname2}") # , self._testMethodName)
self.test_files_names.append(out_tmp_zipname2)
# Create a random int so the values in the cinfo are unique each test
# Create metadata objects
cinfo_1 = ComicInfo()
cinfo_1.series = "This series from file 1 should be kept"
cinfo_1.writer = "This writer from file 1 should NOT be kept"
cinfo_2 = ComicInfo()
cinfo_2.series = "This series from file 2 should be kept"
cinfo_2.writer = "This writer from file 2 should NOT be kept"
# Created loaded metadata objects
metadata_1 = LoadedComicInfo(out_tmp_zipname, comicinfo=cinfo_1)
metadata_2 = LoadedComicInfo(out_tmp_zipname2, comicinfo=cinfo_2)
self.instance.loaded_cinfo_list = [metadata_1, metadata_2]
# There is no edited comicinfo, it should fail
with self.assertRaises(EditedCinfoNotSet):
self.instance.merge_changed_metadata(self.instance.loaded_cinfo_list)
new_cinfo = ComicInfo()
new_cinfo.series = self.instance.MULTIPLE_VALUES_CONFLICT
new_cinfo.writer = "This is the new writer for both cinfo"
self.instance.new_edited_cinfo = new_cinfo
self.instance.merge_changed_metadata(self.instance.loaded_cinfo_list)
print("Assert values are kept")
self.assertEqual("This series from file 1 should be kept", metadata_1.cinfo_object.series)
self.assertEqual("This series from file 2 should be kept", metadata_2.cinfo_object.series)
print("Assert values are overwritten")
self.assertEqual("This is the new writer for both cinfo", metadata_1.cinfo_object.writer)
self.assertEqual("This is the new writer for both cinfo", metadata_2.cinfo_object.writer)
def test_selected_files_loaded(self):
# Setup
self.test_files_names = create_dummy_files(2)
self.instance.selected_files_path = self.test_files_names
self.instance.open_cinfo_list(lambda : False)
self.assertEqual(2, len(self.instance.loaded_cinfo_list))
def test_process_should_raise_exception_if_no_new_cinfo(self):
self.test_files_names = create_dummy_files(2)
self.instance.selected_files_path = self.test_files_names
self.assertRaises(NoComicInfoLoaded, self.instance.process)
class ErrorHandlingTests(unittest.TestCase):
"""
This should test that all functions in the methods in MetadataManagerLib._IMetadataManagerLib interface are called
"""
test_files_names = None
def setUp(self) -> None:
leftover_files = [listed for listed in os.listdir() if
listed.startswith("Test__") and listed.endswith(".cbz")]
for file in leftover_files:
os.remove(file)
def tearDown(self) -> None:
# Some cases patch LoadedComicInfo. patchin back just in case
src.Common.LoadedComicInfo.LoadedComicInfo.LoadedComicInfo = LoadedComicInfo
print("Teardown:")
for filename in self.test_files_names:
print(f" Deleting: {filename}") # , self._testMethodName)
try:
os.remove(filename)
except Exception as e:
print(e)
@unittest.skip("Broken test")
@patch.multiple(MetadataManagerLib.MetadataManagerLib, __abstractmethods__=set())
def test_load_files_should_handle_broken_zipfile(self):
self.instance = MetadataManagerLib.MetadataManagerLib()
class RaiseBadZip:
...
def raise_badzip(*_, **__):
raise BadZipFile()
RaiseBadZip.__init__ = raise_badzip
src.Common.LoadedComicInfo.LoadedComicInfo.LoadedComicInfo = RaiseBadZip
self.instance.selected_files_path = self.test_files_names = create_dummy_files(2)
self.instance.on_badzipfile_error = MagicMock()
self.instance.open_cinfo_list()
self.instance.on_badzipfile_error.assert_called()
@unittest.skip("Broken test")
@patch.multiple(MetadataManagerLib.MetadataManagerLib, __abstractmethods__=set())
def test_on_badzipfile_error(self):
self.instance = MetadataManagerLib.MetadataManagerLib()
class RaiseCorruptedMeta:
...
def raise_badzip(*_, **__):
# Exception raised but then we create a new object with a brand new comicinfo.
# Fix back patched class and raise exception
src.Common.LoadedComicInfo.LoadedComicInfo.LoadedComicInfo = LoadedComicInfo
raise CorruptedComicInfo("")
RaiseCorruptedMeta.__init__ = raise_badzip
src.Common.LoadedComicInfo.LoadedComicInfo.LoadedComicInfo = RaiseCorruptedMeta
self.instance.selected_files_path = self.test_files_names = create_dummy_files(2)
self.instance.on_corruped_metadata_error = MagicMock()
self.instance.open_cinfo_list()
self.instance.on_corruped_metadata_error.assert_called()
@patch.multiple(MetadataManagerLib.MetadataManagerLib, __abstractmethods__=set())
def test_on_writing_error(self):
self.instance = MetadataManagerLib.MetadataManagerLib()
called = False
class RaisePermissionError(LoadedComicInfo):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.has_changes = True
def write_metadata(self, auto_unmark_changes=False):
if not called:
raise PermissionError("This exception is raised as part of one unit test. Safe to ignore")
else:
super().write_metadata(auto_unmark_changes)
src.Common.LoadedComicInfo.LoadedComicInfo.LoadedComicInfo = RaisePermissionError
self.instance.selected_files_path = self.test_files_names = create_dummy_files(2)
self.instance.loaded_cinfo_list = [RaisePermissionError(path) for path in self.test_files_names]
self.instance.new_edited_cinfo = ComicInfo()
self.instance.on_writing_error = MagicMock()
self.instance.process()
self.instance.on_writing_error.assert_called()
@patch.multiple(MetadataManagerLib.MetadataManagerLib, __abstractmethods__=set())
def test_on_writing_exception(self):
self.instance = MetadataManagerLib.MetadataManagerLib()
class RaisePermissionError(LoadedComicInfo):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.has_changes = True
def write_metadata(self, auto_unmark_changes=False):
raise Exception("Exception. This exception is raised as part of one unit test. Safe to ignore")
src.Common.LoadedComicInfo.LoadedComicInfo.LoadedComicInfo = RaisePermissionError
self.instance.selected_files_path = self.test_files_names = create_dummy_files(2)
self.instance.loaded_cinfo_list = [RaisePermissionError(path) for path in self.test_files_names]
self.instance.new_edited_cinfo = ComicInfo()
self.instance.on_writing_exception = MagicMock()
self.instance.process()
self.instance.on_writing_exception.assert_called()
================================================
FILE: MangaManager/tests/Settings/__init__.py
================================================
================================================
FILE: MangaManager/tests/Settings/test_Settings.py
================================================
import os.path
import unittest
from src.Settings import SettingHeading, Settings
class SettingsTest(unittest.TestCase):
def tearDown(self):
if os.path.exists('settings.ini'):
print('Cleaning up created settings.ini')
os.remove('settings.ini')
def test_Settings_will_create_if_nothing_on_disk(self):
s = Settings()
self.assertTrue(os.path.exists(s.config_file))
def test_Settings_will_set_values(self):
s = Settings()
s._load_test()
self.assertEqual(s.get(SettingHeading.Main, 'library_path'), '')
s.set(SettingHeading.Main, 'library_path', 'test_dir')
self.assertEqual(s.get(SettingHeading.Main, 'library_path'), 'test_dir')
def test_Settings_will_write_default_tag_if_not_exists(self):
s = Settings()
self.assertNotEqual(s.get(SettingHeading.ExternalSources, 'default_metadata_source'), '')
if __name__ == '__main__':
unittest.main()
================================================
FILE: MangaManager/tests/__init__.py
================================================
from logging_setup import add_trace_level
add_trace_level()
================================================
FILE: MangaManager/tests/common.py
================================================
import configparser
import io
import os
import random
import sys
import unittest
import warnings
import zipfile
import _tkinter
from PIL import Image
from lxml import etree
from src.Common.LoadedComicInfo.LoadedComicInfo import COMICINFO_FILE, LoadedComicInfo
def create_dummy_files(nfiles):
test_files_names = []
for i in range(nfiles):
out_tmp_zipname = f"random_image_{i}_not_image.ext.cbz"
test_files_names.append(out_tmp_zipname)
with zipfile.ZipFile(out_tmp_zipname, "w") as zf:
zf.writestr("Dummyfile.ext", "Dummy")
return test_files_names
def create_test_cbz(nfiles, nimages=4, loaded_cinfo: LoadedComicInfo = None) -> list[str]:
image = Image.new('RGB', (100, 100), 'white')
buffer = io.BytesIO()
image.save(buffer, 'JPEG')
test_files_names = []
for i in range(nfiles):
out_tmp_zipname = f"Test__{i}_Generated{random.randint(1, 6000)}.cbz"
test_files_names.append(out_tmp_zipname)
with zipfile.ZipFile(out_tmp_zipname, "w") as zf:
if loaded_cinfo is not None:
# noinspection PyProtectedMember
zf.writestr(COMICINFO_FILE, loaded_cinfo._export_metadata())
for j in range(nimages):
zf.writestr(f"{str(j).zfill(3)}.png", buffer.getvalue())
return test_files_names
class CBZManipulationTests(unittest.TestCase):
test_files_names = []
root = None
def setUp(self) -> None:
print("Super setup")
leftover_files = [listed for listed in os.listdir() if listed.startswith("Test__") and listed.endswith(".cbz")]
for file in leftover_files:
os.remove(file)
def tearDown(self) -> None:
print("Super Teardown:")
try:
self.root.destroy()
except:
pass
for filename in self.test_files_names:
print(f" Deleting: {filename}") # , self._testMethodName)
try:
os.remove(filename)
except Exception as e:
print(e)
class TKinterTestCase(unittest.TestCase):
"""These methods are going to be the same for every GUI test,
so refactored them into a separate class
"""
root = None
def setUp(self):
...
def tearDown(self):
if self.root:
try:
self.root.destroy()
self.pump_events()
except:
pass
def pump_events(self):
while self.root.dooneevent(_tkinter.ALL_EVENTS | _tkinter.DONT_WAIT):
pass
def parameterized_class(attrs, input_values=None, classname_func=None, **__):
""" Parameterizes a test class by setting attributes on the class.
Can be used in two ways:
1) With a list of dictionaries containing attributes to override::
@parameterized_class([
{ "username": "foo" },
{ "username": "bar", "access_level": 2 },
])
class TestUserAccessLevel(TestCase):
...
2) With a tuple of attributes, then a list of tuples of values:
@parameterized_class(("username", "access_level"), [
("foo", 1),
("bar", 2)
])
class TestUserAccessLevel(TestCase):
...
"""
if isinstance(attrs, str):
attrs = [attrs]
input_dicts = (
attrs if input_values is None else
[dict(zip(attrs, vals)) for vals in input_values]
)
if classname_func:
warnings.warn(
"classname_func= is deprecated; use class_name_func= instead. "
"See: https://github.com/wolever/parameterized/pull/74#issuecomment-613577057",
DeprecationWarning,
stacklevel=2,
)
def decorator(base_class):
test_class_module = sys.modules[base_class.__module__].__dict__
for idx, input_dict in enumerate(input_dicts):
test_class_dict = dict(base_class.__dict__)
test_class_dict.update(input_dict)
name = base_class.__name__ + " Layout=" + input_dict.get("GUI").name
test_class_module[name] = type(name, (base_class,), test_class_dict)
# We need to leave the base class in place (see issue #73), but if we
# leave the test_ methods in place, the test runner will try to pick
# them up and run them... which doesn't make sense, since no parameters
# will have been applied.
# Address this by iterating over the base class and remove all test
# methods.
for method_name in list(base_class.__dict__):
if method_name.startswith("test"):
delattr(base_class, method_name)
return base_class
return decorator
# configparser patch stuff
def custom_get_item(key):
if key == 'DynamicProgramingParamaters':
return {'wealth_state_total': 'Just a test 3!'}
else:
raise KeyError(str(key))
class CustomConfigParser1(configparser.ConfigParser):
def __getitem__(self, key):
if key == 'DynamicProgramingParamaters':
return {'wealth_state_total': 'Just a test 4!'}
else:
raise KeyError(str(key))
class CustomConfigParser2(configparser.ConfigParser):
def read(self, filenames, *args, **kwargs):
# Intercept the calls to configparser -> read and replace it to read from your test data
if './path' == filenames:
# Option 1: If you want to manually write the configuration here
self.read_string("[DynamicProgramingParamaters]\nwealth_state_total = Just a test 5!")
# Option 2: If you have a test configuration file
# super().read("./test_path")
else:
super().read(filenames, *args, **kwargs)
def is_valid_xml(xml:str) -> bool:
# Load the XML file and XSD schema
try:
xml_file = etree.fromstring(xml.encode("utf-8"),parser=etree.XMLParser(encoding='utf-8'))
except ValueError:
print("dasd")
xsd_schema = etree.parse('common/models/ComicInfo.xds')
# Create a validator object
xml_validator = etree.XMLSchema(xsd_schema)
# Validate the XML file against the XSD schema
is_valid = xml_validator.validate(xml_file)
if not is_valid:
print(xml)
for error in xml_validator.error_log:
print(f'{error.message} (line {error.line}, column {error.column})')
return is_valid
================================================
FILE: MangaManager/tests/data/test.py
================================================
from src.Common.LoadedComicInfo.ArchiveFile import ArchiveFile
if __name__ == '__main__':
with ArchiveFile("!00_SAMPLE_FILE.rar", "r") as rfile:
print(rfile.namelist())
print(rfile.read("ComicInfo.xml"))
with ArchiveFile("!00_SAMPLE_FILE.CBZ", "r") as rfile:
print(rfile.namelist())
print(rfile.read("ComicInfo.xml"))
================================================
FILE: MangaManager/tests/test_comicinfo.py
================================================
import unittest
from common.models import AgeRating, Manga, YesNo, Formats
class LoadedCInfo_Utils(unittest.TestCase):
def test_ComicInfo_ToList_methods_work(self):
classes_to_test_list_implementation = (AgeRating, Manga, YesNo)
for class_ in classes_to_test_list_implementation:
with self.subTest(f"Testing {class_} has list method"):
self.assertTrue(len(class_.list()) > 1)
with self.subTest("Testing format_list is populated"):
self.assertTrue(len(Formats) > 1)
================================================
FILE: README.md
================================================
[](https://github.com/ThePromidius/Manga-Manager/actions/workflows/Run_Tests.yml)
[](https://sonarcloud.io/summary/new_code?id=ThePromidius_Manga-Manager)
[](https://sonarcloud.io/summary/new_code?id=ThePromidius_Manga-Manager)
# Welcome to the Manga Manager rework
The current version in this branch is pretty much a brand new MangaManager. The old code was very limited due to how limited its original purpose was.
With a lot of effort and support from the community, the rework was born.
Please note that you are accessing the Beta Version of MangaManger which is in the process of being developed with all it's features before its official release. The sole purpose of this BETA Version is to conduct testing and obtain feedback.
All releases are tested but if you happen to find an error or a bug please report them with the label "Rework Issue".
## What is Manga Manager
Manga Manager is an all-in-one tool to make managing your manga library easy.
Has a built-in metadata editor as well as cover and back cover editor.
# Key Features:
* Select multiple files and preview the covers of each file
* Bulk edit metadata for all files at once
* Changes are kept in one session, allowing flexible editing
* Apply all changes (save to file) at once when done editing
* Edit cover or back cover from the metadata view itself
* Cover manager for batch editing of covers
* Online metadata scraping
* Webp converter
* Error and warning log within the UI itself
* Terminal interface (supports ssh)
### Does it work for comics?
Yes! MangaManager works on any .cbz file!
# Donate
If you enjoy using MangaManager, consider making a voluntary donation as a show of support. Your donation is greatly appreciated and will help fuel the continued development of the software.
You can donate through Ko-fi. Thank you for your generosity and for being a part of the MangaManager community.
[](https://ko-fi.com/U7U4IC14H)
## Docker
MangaManager provides a convenient solution for remote access to the software through a web browser.
A docker container with a remote desktop is available. It's important to expose port 3000 and mount the volumes for `/manga` and `/covers`. For detailed instructions, refer to the [docker-compose.yml template at the wiki](https://github.com/ThePromidius/Manga-Manager/wiki/Docker#docker-composeyml).
The stable releases are built from the master branch, while nightly builds are generated from the develop branch.

### Art attribution
Wallpaper Photo by [Ian Valerio](https://unsplash.com/@iangvalerio?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText) on [Unsplash](https://unsplash.com/s/photos/anime?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)
## Bare metal requirements
Min required version: Python 3.10.8
Requirements in requirements.txt
#### Additional for Linux
- tkinter
- idlelib
## FAQ
### No rar tools detected on windows.
Download [unrar](https://www.winrar.es/descargas/unrar), execute it and select a place to decompress the contents. A file `unrar.exe` will be decompressed and you can move so it sits alongside MangaManager.exe file.
================================================
FILE: docker-compose.yml
================================================
# This Docker is based on Linuxserver.io's Webtop Base Image.
# For information about more available options to use, please
# refer to this page: https://docs.linuxserver.io/images/docker-webtop
#
# SECURITY ADVISORY: Do not expose this passwordless sudo container
# to the public. If you want to use this remotely use a VPN.
# Should you need to login at any point, the default user's credentials are:
# USER: abc PASS: abc
version: "3.4"
services:
manga-manager:
image: thepromidius/manga-manager:latest
ports:
# Web UI
- "3000:3000"
# OPTIONAL: RDP Port for browser-less connection
# - "3389:3389"
volumes:
- /your/manga/directory:/manga
- /your/covers_images/directory:/covers
environment:
- PUID=1000
- PGID=1000
# Background Stuff:
- TITLE="Manga Manager"
# OPTIONAL:
- UMASK=022
- TZ=Europe/Berlin
- KEYBOARD=en-us-qwerty
# Specify a subfolder to use with reverse proxies, i.e.: /subfolder/
- SUBFOLDER=/
# You need this setting if your Docker version is below 20.10.10
# See: https://docs.linuxserver.io/faq#jammy
# security_opt:
# - seccomp=unconfined
================================================
FILE: docker-root/config/.config/xfce4/panel/launcher-7/MM Launcher.desktop
================================================
[Desktop Entry]
Version=1.0
Type=Application
# Exec=python /app/main.py
Exec=/config/Desktop/MangaManager_23_02_02_Beta_linux_01
Icon=
StartupNotify=true
Terminal=False
Categories=Utility;X-XFCE;X-Xfce-Toplevel;
OnlyShowIn=XFCE;
Name=Manga Manager
Comment=Manga Manager Launcher
Keywords=run;command;application;program;finder;search;launcher;everything;spotlight;sherlock;applesearch;unity dash;krunner;synapse;ulauncher;launchy;gnome do;pie;apwal;recoll;alfred;quicksilver;tracker;beagle;kupfer;
X-XFCE-Source=file:///usr/share/applications/xfce4-run.desktop
Path=/app
================================================
FILE: docker-root/config/.config/xfce4/xfconf/xfce-perchannel-xml/xfce4-desktop.xml
================================================
================================================
FILE: docker-root/config/.config/xfce4/xfconf/xfce-perchannel-xml/xfce4-panel.xml
================================================
================================================
FILE: docker-root/config/.config/xfce4/xfconf/xfce-perchannel-xml/xsettings.xml
================================================
================================================
FILE: docker-root/config/Desktop/MangaManager_23_02_02_Beta_linux_01
================================================
[File too large to display: 24.6 MB]
================================================
FILE: docker-root/config/Desktop/covers-folder-link.desktop
================================================
[Desktop Entry]
Version=1.0
Type=Application
Name=Covers Folder
Comment=Open /covers
Exec=/usr/bin/Thunar
Icon=folder-pictures
Path=/covers
Terminal=false
StartupNotify=false
================================================
FILE: docker-root/config/Desktop/manga-folder-link.desktop
================================================
[Desktop Entry]
Version=1.0
Type=Application
Name=Manga Folder
Comment=Open /manga
Exec=/usr/bin/Thunar
Icon=applications-libraries
Path=/manga
Terminal=false
StartupNotify=false
================================================
FILE: docker-root/config/Desktop/manga-manager-link.desktop
================================================
[Desktop Entry]
Version=1.0
Type=Application
Name=Manga Manager
Comment=
# Exec=python /app/main.py
Exec=/config/Desktop/MangaManager_23_02_02_Beta_linux_01
Icon=
Path=/app
Terminal=false
StartupNotify=false
================================================
FILE: docker-root/config/custom-cont-init.d/prepare-app-permissions.sh
================================================
#!/bin/bash
echo "Preparing permissions for /app folder"
chown -R abc:abc /app
================================================
FILE: docker-root/defaults/autostart
================================================
startxfce4
MangaManager_23_02_02_Beta_linux_01
================================================
FILE: docker-root/defaults/startwm.sh
================================================
#!/bin/bash
/startpulse.sh &
/usr/bin/startxfce4 > /dev/null 2>&1
python /app/main.py
================================================
FILE: requirements.txt
================================================
prompt_toolkit>=3.0.31
pillow>=9.4.0
Pillow
natsort>=8.2.0
lxml >= 4.9.1
six >= 1.16.0
requests >= 2.31.0
anytree~=2.8.0
numpy~=1.24.2
rarfile
tkinterdnd2
================================================
FILE: sonar-project.properties
================================================
sonar.projectKey=ThePromidius_Manga-Manager
sonar.organization=thepromidius
# This is the name and version displayed in the SonarCloud UI.
#sonar.projectName=Manga-Manager
#sonar.projectVersion=1.0
sonar.python.version=3.10
# Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows.
# Define separate root directories for sources and tests
sonar.sources=MangaManager/src/
sonar.tests=MangaManager/tests/
# Exclude test subdirectories from source scope
# Exclude Comicinfo.py because its generated code and "backwards" compatible to python 2 which we don't use but haven't rewritten the class yet
sonar.exclusions=MangaManager/src/**/comicinfo.py
sonar.cpd.exclusions = MangaManager/tests/**/*test*
sonar.test.exclusions=MangaManager/test*
sonar.python.file.suffixes=py
# Encoding of the source code. Default is default system encoding
#sonar.sourceEncoding=UTF-8