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 ================================================ [![Python tests](https://github.com/ThePromidius/Manga-Manager/actions/workflows/Run_Tests.yml/badge.svg)](https://github.com/ThePromidius/Manga-Manager/actions/workflows/Run_Tests.yml) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=ThePromidius_Manga-Manager&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=ThePromidius_Manga-Manager) [![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=ThePromidius_Manga-Manager&metric=ncloc)](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. [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](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. ![Screenshot-1](/project-docs/Screenshot_1.png) ### 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