[
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\n\ngithub: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]\npatreon: # Replace with a single Patreon username\nopen_collective: # Replace with a single Open Collective username\nko_fi: thepromidius\ntidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel\ncommunity_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry\nliberapay: # Replace with a single Liberapay username\nissuehunt: # Replace with a single IssueHunt username\notechie: # Replace with a single Otechie username\nlfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry\ncustom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Describe the bug**\nA clear and concise description of what the bug is.\n\n**To Reproduce**\nSteps to reproduce the behavior:\n1. Go to '...'\n2. Click on '....'\n3. Scroll down to '....'\n4. See error\n\n**Expected behavior**\nA clear and concise description of what you expected to happen.\n\n**Screenshots**\nIf applicable, add screenshots to help explain your problem.\n\n**Desktop (please complete the following information):**\n - OS: [e.g. iOS]\n - Browser [e.g. chrome, safari]\n - Version [e.g. 22]\n\n**Smartphone (please complete the following information):**\n - Device: [e.g. iPhone6]\n - OS: [e.g. iOS8.1]\n - Browser [e.g. stock browser, safari]\n - Version [e.g. 22]\n\n**Additional context**\nAdd any other context about the problem here."
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: \"[Feature Request]\"\nlabels: Feature Request\nassignees: ''\n\n---\n\n**Is your feature request related to a problem? Please describe.**\nA clear and concise description of what the problem is. Ex. I'm always frustrated when [...]\n\n**Describe the solution you'd like**\nA clear and concise description of what you want to happen.\n\n**Describe alternatives you've considered**\nA clear and concise description of any alternative solutions or features you've considered.\n\n**Additional context**\nAdd any other context or screenshots about the feature request here."
  },
  {
    "path": ".github/workflows/Run_Tests.yml",
    "content": "# This workflow file will install Python dependencies,\n# create a desktop, and test the application's GUI on multiple versions of Python\nname: Python tests & Build\n\non:\n  - push\n  - pull_request\nenv:\n  $$_ENV_DEVELOPMENT_MM_$$: true\n  IMAGE_NAME: \"thepromidius/manga-manager\"\njobs:\n  test_linux:\n    env:\n      DISPLAY: \":99.0\"\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        python-version: [ '3.11' ]\n    name: Python ${{ matrix.python-version }} - Linux\n    steps:\n      -\n        uses: actions/checkout@v3\n      -\n        uses: actions/setup-python@v4\n        with:\n          python-version: ${{ matrix.python-version }}\n          cache: 'pip'\n      -\n        run: sudo apt install xvfb\n      -\n        run: pip install -r requirements.txt\n      -\n        name: Start xvfb\n        run: |\n          Xvfb :99 -screen 0 1920x1080x24 &disown\n      -\n        name: Run the tests\n        run: |\n          cd MangaManager\n          python -m unittest discover -s tests -t .\n  \n  \n\n#  test_windows:\n#    env:\n#      DISPLAY: \":99.0\"\n#    runs-on: windows-latest\n#    if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/test' }}\n#    strategy:\n#      matrix:\n#        python-version: [ '3.11' ]\n#    name: Python ${{ matrix.python-version }} - Windows\n#    steps:\n#      -\n#        uses: actions/checkout@v3\n#      -\n#        uses: actions/setup-python@v4\n#        with:\n#          python-version: ${{ matrix.python-version }}\n#          cache: 'pip'\n#      -\n#        run: pip install -r requirements.txt\n#      -\n#        name: Run the tests\n#        run: |\n#          cd MangaManager\n#          python -m unittest discover -s tests -t .\n  sonarcloud:\n    name: SonarCloud\n    runs-on: ubuntu-latest\n    steps:\n      -\n        uses: actions/checkout@v3\n        with:\n            fetch-depth: 0  # Shallow clones should be disabled for a better relevancy of analysis\n\n  parse_versions:\n    name: Parse Versions Env\n    runs-on: ubuntu-latest\n    outputs:\n      latest_commit: ${{ steps.latest_commit.outputs.latest_commit }}\n      release_commit: ${{ steps.release_commit.outputs.release_commit }}\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v3\n\n      - name: Set latest release commit hash (latest)\n        id: release_commit\n        run: |\n          release_url=$(curl -s https://api.github.com/repos/${{ github.repository }}/releases/latest)\n          tag_name=$(echo $release_url | jq -r '.tag_name')\n          tag_url=$(curl -s https://api.github.com/repos/${{ github.repository }}/git/ref/tags/$tag_name)\n          release_commit=$(echo $tag_url | jq -r '.object.sha')\n          short_release_commit=$(git rev-parse --short $release_commit)\n          echo \"release_commit=$short_release_commit\" >> $GITHUB_OUTPUT\n\n      - name: Set latest commit hash (develop)\n        id: latest_commit\n        run: |\n          latest_commit=$(git rev-parse --short HEAD)\n          echo \"latest_commit=$latest_commit\" >> $GITHUB_OUTPUT\n\n  docker_test:\n    name: Test and Build - Test Version\n    needs: [test_linux, sonarcloud, parse_versions]\n    runs-on: ubuntu-latest\n    if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/test' }}\n    steps:\n      -\n        name: Checkout\n        uses: actions/checkout@v3\n      -\n        name: Replace \"nightly\" with commit hash\n        run: |\n          file_contents=$(head -n 1 MangaManager/src/__version__.py)\n          new_contents=\"${file_contents/nightly/nightly:${{ needs.parse_versions.outputs.latest_commit }}}\"\n          echo \"$new_contents\" > MangaManager/src/__version__.py\n      -\n        name: Set up QEMU\n        uses: docker/setup-qemu-action@v2\n      -\n        name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v2\n      -\n        name: Login to DockerHub\n        uses: docker/login-action@v2\n        with:\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_TOKEN }}\n      -\n        name: Build and push\n        uses: docker/build-push-action@v4\n        with:\n          context: .\n          push: true\n          tags: ${{ env.IMAGE_NAME }}:test\n\n  docker_nightly:\n    name: Test and Build - Nightly Version\n    needs: [test_linux, sonarcloud, parse_versions ]\n    runs-on: ubuntu-latest\n    if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }}\n    steps:\n      -\n        name: Checkout\n        uses: actions/checkout@v3\n      -\n        name: Replace \"nightly\" with commit hash\n        run: |\n          file_contents=$(head -n 1 MangaManager/src/__version__.py)\n          new_contents=\"${file_contents/nightly/nightly:${{ needs.parse_versions.outputs.latest_commit }}}\"\n          echo \"$new_contents\" > MangaManager/src/__version__.py\n      -\n        name: Set up QEMU\n        uses: docker/setup-qemu-action@v2\n      -\n        name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v2\n      -\n        name: Login to DockerHub\n        uses: docker/login-action@v2\n        with:\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_TOKEN }}\n      -\n        name: Build and push\n        uses: docker/build-push-action@v4\n        with:\n          context: .\n          push: true\n          tags: ${{ env.IMAGE_NAME }}:nightly\n\n  docker_stable:\n    name: Stable Build\n    needs: [test_linux, sonarcloud ]\n    runs-on: ubuntu-latest\n    if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}\n    steps:\n      -\n        name: Set up QEMU\n        uses: docker/setup-qemu-action@v2\n      -\n        name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v2\n      -\n        name: Login to DockerHub\n        uses: docker/login-action@v2\n        with:\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_TOKEN }}\n      -\n        name: Build and push\n        uses: docker/build-push-action@v3\n        with:\n          push: true\n          tags: ${{ env.IMAGE_NAME }}:latest\n"
  },
  {
    "path": ".gitignore",
    "content": "# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n*$py.class\n\n# C extensions\n*.so\n\n# Distribution / packaging\n.Python\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\nlib/\nlib64/\nparts/\nsdist/\nvar/\nwheels/\nshare/python-wheels/\n*.egg-info/\n.installed.cfg\n*.egg\nMANIFEST\n\n# PyInstaller\n#  Usually these files are written by a python script from a template\n#  before PyInstaller builds the exe, so as to inject date/other infos into it.\n*.manifest\n*.spec\n\n# Installer logs\npip-log.txt\npip-delete-this-directory.txt\n\n# Unit test / coverage reports\nhtmlcov/\n.tox/\n.nox/\n.coverage\n.coverage.*\n.cache\nnosetests.xml\ncoverage.xml\n*.cover\n*.py,cover\n.hypothesis/\n.pytest_cache/\ncover/\n\n# Translations\n*.mo\n*.pot\n\n# Django stuff:\n*.log\nlocal_settings.py\ndb.sqlite3\ndb.sqlite3-journal\n\n# Flask stuff:\ninstance/\n.webassets-cache\n\n# Scrapy stuff:\n.scrapy\n\n# Sphinx documentation\ndocs/_build/\n\n# PyBuilder\n.pybuilder/\ntarget/\n\n# Jupyter Notebook\n.ipynb_checkpoints\n\n# IPython\nprofile_default/\nipython_config.py\n\n# pyenv\n#   For a library or package, you might want to ignore these files since the code is\n#   intended to run in multiple environments; otherwise, check them in:\n# .python-version\n\n# pipenv\n#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.\n#   However, in case of collaboration, if having platform-specific dependencies or dependencies\n#   having no cross-platform support, pipenv may install dependencies that don't work, or not\n#   install all needed dependencies.\n#Pipfile.lock\n\n# poetry\n#   Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.\n#   This is especially recommended for binary packages to ensure reproducibility, and is more\n#   commonly ignored for libraries.\n#   https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control\n#poetry.lock\n\n# pdm\n#   Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.\n#pdm.lock\n#   pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it\n#   in version control.\n#   https://pdm.fming.dev/#use-with-ide\n.pdm.toml\n\n# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm\n__pypackages__/\n\n# Celery stuff\ncelerybeat-schedule\ncelerybeat.pid\n\n# SageMath parsed files\n*.sage.py\n\n# Environments\n.env\n.venv\nenv/\nvenv/\nENV/\nenv.bak/\nvenv.bak/\n\n# Spyder project settings\n.spyderproject\n.spyproject\n\n# Rope project settings\n.ropeproject\n\n# mkdocs documentation\n/site\n\n# mypy\n.mypy_cache/\n.dmypy.json\ndmypy.json\n\n# Pyre type checker\n.pyre/\n\n# pytype static type analyzer\n.pytype/\n\n# Cython debug symbols\ncython_debug/\n\n# PyCharm\n#  JetBrains specific template is maintained in a separate JetBrains.gitignore that can\n#  be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore\n#  and can be added to the global gitignore or merged into this file.  For a more nuclear\n#  option (not recommended) you can uncomment the following to ignore the entire idea folder.\n#.idea/\n# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider\n# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839\n\n# User-specific stuff\n.idea/**/workspace.xml\n.idea/**/tasks.xml\n.idea/**/usage.statistics.xml\n.idea/**/dictionaries\n.idea/**/shelf\n\n# AWS User-specific\n.idea/**/aws.xml\n\n# Generated files\n.idea/**/contentModel.xml\n\n# Sensitive or high-churn files\n.idea/**/dataSources/\n.idea/**/dataSources.ids\n.idea/**/dataSources.local.xml\n.idea/**/sqlDataSources.xml\n.idea/**/dynamic.xml\n.idea/**/uiDesigner.xml\n.idea/**/dbnavigator.xml\n\n# Gradle\n.idea/**/gradle.xml\n.idea/**/libraries\n\n# Gradle and Maven with auto-import\n# When using Gradle or Maven with auto-import, you should exclude module files,\n# since they will be recreated, and may cause churn.  Uncomment if using\n# auto-import.\n# .idea/artifacts\n# .idea/compiler.xml\n# .idea/jarRepositories.xml\n# .idea/modules.xml\n# .idea/*.iml\n# .idea/modules\n# *.iml\n# *.ipr\n\n# CMake\ncmake-build-*/\n\n# Mongo Explorer plugin\n.idea/**/mongoSettings.xml\n\n# File-based project format\n*.iws\n\n# IntelliJ\nout/\n\n# mpeltonen/sbt-idea plugin\n.idea_modules/\n\n# JIRA plugin\natlassian-ide-plugin.xml\n\n# Cursive Clojure plugin\n.idea/replstate.xml\n\n# SonarLint plugin\n.idea/sonarlint\n\n# Crashlytics plugin (for Android Studio and IntelliJ)\ncom_crashlytics_export_strings.xml\ncrashlytics.properties\ncrashlytics-build.properties\nfabric.properties\n\n# Editor-based Rest Client\n.idea/httpRequests\n\n# Android studio 3.1+ serialized cache file\n.idea/caches/build_file_checksums.ser\n\n\n\n\n## Application settings\nsettings.ini\n/.coverage\n/build/**/*\n/dist/**/*\n/config.ini\n/**/*.log*\n*.pdf\n/instructions_benchmarks.txt\n/.idea/sonarlint/\n/MangaManager/build\n/MangaManager/dist\n\n# From Tests\n/MangaManager/*.cbz\n/.idea\n/PYTHON_MANGA_MANAGER_WORKFLOW.vsdx\n/.idea/sonarlint/\n"
  },
  {
    "path": "BUILD.ps1",
    "content": "##$repoName = \"Manga-Manager\"\n##$ownerName = \"MangaManagerOrg\"\n### Get the latest release\n##$latestRelease = Invoke-RestMethod -Uri \"https://api.github.com/repos/$ownerName/$repoName/releases/latest\"\n### Get the branch or tag name of the commit where the latest release is tied to\n##$latestReleaseBranchOrTagName = $latestRelease.target_commitish\n### Get the short hash of the commit where the latest release is tied to\n##$latestReleaseCommitHash = git rev-parse --short $latestReleaseBranchOrTagName\n### Get the short hash of the latest commit in the develop branch\n##$latestDevelopHash = git rev-parse --short develop\n##\n##$content = Get-Content .\\MangaManager\\src\\__version__.py\n##if ($content -match '(?<=__version__ = \")[^:\"]+') {\n##    $newContent = $content -replace '__version__ = \".*\"', \"__version__ = `\"$versionNumber:nightly--$latestReleaseCommitHash->$latestDevelopHash`\"\"\n##    $newContent | Set-Content .\\MangaManager\\src\\__version__.py\n##}\n##Write-Output $newContent\n#$repoName = \"Manga-Manager\"\n#$ownerName = \"MangaManagerOrg\"\n## Get the latest release\n#$latestRelease = Invoke-RestMethod -Uri \"https://api.github.com/repos/$ownerName/$repoName/releases/latest\"\n## Get the branch or tag name of the commit where the latest release is tied to\n#$latestReleaseBranchOrTagName = $latestRelease.target_commitish\n## Get the short hash of the commit where the latest release is tied to\n#$latestReleaseCommitHash = git rev-parse --short $latestReleaseBranchOrTagName\n## Get the short hash of the latest commit in the develop branch\n#$latestDevelopHash = git rev-parse --short develop\n#\n#$content = Get-Content .\\MangaManager\\src\\__version__.py\n#$versionRegex = '(?<=__version__ = \")[^:\"]+'\n#if ($content -match $versionRegex) {\n#    $versionNumber = $matches[0]\n#    $newContent = $content -replace '__version__ = \".*\"', \"__version__ = `\"$versionNumber:nightly--$latestReleaseCommitHash->$latestDevelopHash`\"\"\n#    $newContent | Set-Content .\\MangaManager\\src\\__version__.py\n#}\n#Write-Output $newContent\n$repoName = \"Manga-Manager\"\n$ownerName = \"MangaManagerOrg\"\n# Get the latest release\n$latestRelease = Invoke-RestMethod -Uri \"https://api.github.com/repos/$ownerName/$repoName/releases/latest\"\n# Get the branch or tag name of the commit where the latest release is tied to\n$latestReleaseBranchOrTagName = $latestRelease.target_commitish\n# Get the short hash of the commit where the latest release is tied to\n$latestReleaseCommitHash = git rev-parse --short $latestReleaseBranchOrTagName\n# Get the short hash of the latest commit in the develop branch\n$latestDevelopHash = git rev-parse --short develop\n\n$content = Get-Content .\\MangaManager\\src\\__version__.py\n$versionFile = \".\\MangaManager\\src\\__version__.py\"\n\n# Read the current contents of the version file\n$content = Get-Content $versionFile\n\n# Update the commit hashes in the version file\n$content | ForEach-Object {\n    if ($_ -match \"^__version__ = '.*:stable$'\") {\n        $_ -replace \"(?<=[^-])-?[0-9a-f]{7,}?(?=-|->)\", $latestReleaseCommitHash\n    } elseif ($_ -match \"^__version__ = '.*:nightly$'\") {\n        $_ -replace \"(?<=[^-])-?[0-9a-f]{7,}?(?=-|->)\", $latestDevelopHash\n    } else {\n        $_\n    }\n} | Set-Content $versionFile\nWrite-Output $content"
  },
  {
    "path": "CHANGELOG.md",
    "content": "## 1.0.0-beta.1\n\n### Features\n\n* Select multiple files and preview the covers of each file.\n* Select a folder and open files recursively \n* Choose the files you want to change metadata (without needing to open again) Just click and edit!\n* Bulk edit metadata for all files at once\n* 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.\n* Apply all changes (save to file) all at once when you are done editing.\n* Edit cover or backcover from the metadata view itself. Append, replace, or delete.\n* Cover manager to batch edit covers (the old cover manager but improved significantly)\n* Online Metadata scraping\n* Webp converter\n* Errors and warnings log inside the UI itself!\n\n\n## 0.4.6\n\n### Features\n\n* Added settings button in main menu\n* Added .bmp support to webp converter (previously it skipped it)\n\n### Fixed\n\n* A bug in docker that wouldn't select files\n* Handled exception when no cover is selected and process is clicked\n* Reencode to UTF-8 if file encoding is wrong reading ComicInfo.xml\n* Keep original file if recompressed one is bigger. Closes #106\n* Skip image if recompress fails. Closes #115"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nWe as members, contributors, and leaders pledge to make participation in our\ncommunity a harassment-free experience for everyone, regardless of age, body\nsize, visible or invisible disability, ethnicity, sex characteristics, gender\nidentity and expression, level of experience, education, socio-economic status,\nnationality, personal appearance, race, religion, or sexual identity\nand orientation.\n\nWe pledge to act and interact in ways that contribute to an open, welcoming,\ndiverse, inclusive, and healthy community.\n\n## Our Standards\n\nExamples of behavior that contributes to a positive environment for our\ncommunity include:\n\n* Demonstrating empathy and kindness toward other people\n* Being respectful of differing opinions, viewpoints, and experiences\n* Giving and gracefully accepting constructive feedback\n* Accepting responsibility and apologizing to those affected by our mistakes,\n  and learning from the experience\n* Focusing on what is best not just for us as individuals, but for the\n  overall community\n\nExamples of unacceptable behavior include:\n\n* The use of sexualized language or imagery, and sexual attention or\n  advances of any kind\n* Trolling, insulting or derogatory comments, and personal or political attacks\n* Public or private harassment\n* Publishing others' private information, such as a physical or email\n  address, without their explicit permission\n* Other conduct which could reasonably be considered inappropriate in a\n  professional setting\n\n## Enforcement Responsibilities\n\nCommunity leaders are responsible for clarifying and enforcing our standards of\nacceptable behavior and will take appropriate and fair corrective action in\nresponse to any behavior that they deem inappropriate, threatening, offensive,\nor harmful.\n\nCommunity leaders have the right and responsibility to remove, edit, or reject\ncomments, commits, code, wiki edits, issues, and other contributions that are\nnot aligned to this Code of Conduct, and will communicate reasons for moderation\ndecisions when appropriate.\n\n## Scope\n\nThis Code of Conduct applies within all community spaces, and also applies when\nan individual is officially representing the community in public spaces.\nExamples of representing our community include using an official e-mail address,\nposting via an official social media account, or acting as an appointed\nrepresentative at an online or offline event.\n\n## Enforcement\n\nInstances of abusive, harassing, or otherwise unacceptable behavior may be\nreported to the community leaders responsible for enforcement at\nthepromidiusyt@gmail.com.\nAll complaints will be reviewed and investigated promptly and fairly.\n\nAll community leaders are obligated to respect the privacy and security of the\nreporter of any incident.\n\n## Enforcement Guidelines\n\nCommunity leaders will follow these Community Impact Guidelines in determining\nthe consequences for any action they deem in violation of this Code of Conduct:\n\n### 1. Correction\n\n**Community Impact**: Use of inappropriate language or other behavior deemed\nunprofessional or unwelcome in the community.\n\n**Consequence**: A private, written warning from community leaders, providing\nclarity around the nature of the violation and an explanation of why the\nbehavior was inappropriate. A public apology may be requested.\n\n### 2. Warning\n\n**Community Impact**: A violation through a single incident or series\nof actions.\n\n**Consequence**: A warning with consequences for continued behavior. No\ninteraction with the people involved, including unsolicited interaction with\nthose enforcing the Code of Conduct, for a specified period of time. This\nincludes avoiding interactions in community spaces as well as external channels\nlike social media. Violating these terms may lead to a temporary or\npermanent ban.\n\n### 3. Temporary Ban\n\n**Community Impact**: A serious violation of community standards, including\nsustained inappropriate behavior.\n\n**Consequence**: A temporary ban from any sort of interaction or public\ncommunication with the community for a specified period of time. No public or\nprivate interaction with the people involved, including unsolicited interaction\nwith those enforcing the Code of Conduct, is allowed during this period.\nViolating these terms may lead to a permanent ban.\n\n### 4. Permanent Ban\n\n**Community Impact**: Demonstrating a pattern of violation of community\nstandards, including sustained inappropriate behavior,  harassment of an\nindividual, or aggression toward or disparagement of classes of individuals.\n\n**Consequence**: A permanent ban from any sort of public interaction within\nthe community.\n\n## Attribution\n\nThis Code of Conduct is adapted from the [Contributor Covenant][homepage],\nversion 2.0, available at\nhttps://www.contributor-covenant.org/version/2/0/code_of_conduct.html.\n\nCommunity Impact Guidelines were inspired by [Mozilla's code of conduct\nenforcement ladder](https://github.com/mozilla/diversity).\n\n[homepage]: https://www.contributor-covenant.org\n\nFor answers to common questions about this code of conduct, see the FAQ at\nhttps://www.contributor-covenant.org/faq. Translations are available at\nhttps://www.contributor-covenant.org/translations."
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing guidelines\n\nWe 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).\n\nA contribution can be one of the following cases:\n\n1. you have a question;\n2. you think you may have found a bug (including unexpected behavior);\n3. 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).\n\nThe sections below outline the steps in each case.\n\n## You have a question\n\n1. use the search functionality [here](https://github.com/MangaManagerORG/Manga-Manager/issues) to see if someone already filed the same issue;\n2. if your issue search did not yield any relevant results, make a new issue;\n3. apply the \"Question\" label; apply other labels when relevant.\n\n## You think you may have found a bug\n\n1. use the search functionality [here](https://github.com/MangaManagerORG/Manga-Manager/issues) to see if someone already filed the same issue;\n2. 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:\n    - the [SHA hashcode](https://help.github.com/articles/autolinked-references-and-urls/#commit-shas) of the commit that is causing your problem;\n    - some identifying information (name and version number) for dependencies you're using;\n    - information about the operating system;\n3. apply relevant labels to the newly created issue.\n\n## You want to make some kind of change to the code base\n\n1. (**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;\n2. (**important**) wait until some kind of consensus is reached about your idea being a good idea;\n3. 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/));\n4. Install dependencies with `pip3 install -r requirements.txt`;\n5. 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;\n6. add your own tests (if necessary);\n7. update or expand the documentation;\n8. push your feature branch to (your fork of) the Python Template repository on GitHub;\n9. create the pull request, e.g. following the instructions [here](https://help.github.com/articles/creating-a-pull-request/).\n\nIn 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."
  },
  {
    "path": "DEVELOPMENT.MD",
    "content": "\n## Versioning and building\nWhen 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.\n\nAfter that you can now create the build with the command below\n\n## How to build:\n`python -m PyInstaller .\\MangaManager.spec --clean`\n\n## Errors building with pyinstaller\nIf you can not run the build make sure all requirements are installed.\nPyinstaller does not use the virtual env requirements. So make sure the base python has them installed\nSome of the requirements that gave me issues are:\n- `chardet`"
  },
  {
    "path": "Dockerfile",
    "content": "FROM ghcr.io/linuxserver/baseimage-rdesktop-web:jammy\n\nENV DEBIAN_FRONTEND=noninteractive\nENV UID=1000\nENV GID=1000\nENV NO_UPDATE_NOTIFIER=true\nENV GUIAUTOSTART=true\n\nWORKDIR /tmp\nCOPY requirements.txt /tmp/\n\n# Copy App\nCOPY --chown=$UID:$GID [ \"/MangaManager\", \"/app\" ]\n\n# Setup Dependencies\nRUN apt-get update && \\\n    apt-get install -y --no-install-recommends \\\n    # Desktop Environment\n    mousepad \\\n    xfce4-terminal \\\n    xfce4 \\\n    xubuntu-default-settings \\\n    xubuntu-icon-theme \\\n    unrar\\\n    # Python \\\n    idle-python3.11 \\\n    python3-tk \\\n    python3-pip && \\\n    # Manga Manager Dependencies\n    python3.11 -m pip install -r requirements.txt && \\\n    # Cleanup\n    apt-get autoclean && \\\n    rm -rf \\\n    /var/lib/apt/lists/* \\\n    /var/tmp/* \\\n    /tmp/* && \\\n    # Try making python3 callable by just running \"python\" on Ubuntu :) (optional)\n    ln -s /usr/bin/python3.11 /usr/bin/python || true && \\\n    chmod -R +x /app\n\n# Setup environment & branding/customization\nCOPY /docker-root /\nRUN \\\n    chmod -R +x /config/Desktop && \\\n    chmod -R +x /config/.config/xfce4/panel\n\n\nWORKDIR /app\n\nEXPOSE 3000\nVOLUME /manga\nVOLUME /covers\n"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU GENERAL PUBLIC LICENSE\n                       Version 3, 29 June 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU General Public License is a free, copyleft license for\nsoftware and other kinds of works.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nthe GNU General Public License is intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.  We, the Free Software Foundation, use the\nGNU General Public License for most of our software; it applies also to\nany other work released this way by its authors.  You can apply it to\nyour programs, too.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  To protect your rights, we need to prevent others from denying you\nthese rights or asking you to surrender the rights.  Therefore, you have\ncertain responsibilities if you distribute copies of the software, or if\nyou modify it: responsibilities to respect the freedom of others.\n\n  For example, if you distribute copies of such a program, whether\ngratis or for a fee, you must pass on to the recipients the same\nfreedoms that you received.  You must make sure that they, too, receive\nor can get the source code.  And you must show them these terms so they\nknow their rights.\n\n  Developers that use the GNU GPL protect your rights with two steps:\n(1) assert copyright on the software, and (2) offer you this License\ngiving you legal permission to copy, distribute and/or modify it.\n\n  For the developers' and authors' protection, the GPL clearly explains\nthat there is no warranty for this free software.  For both users' and\nauthors' sake, the GPL requires that modified versions be marked as\nchanged, so that their problems will not be attributed erroneously to\nauthors of previous versions.\n\n  Some devices are designed to deny users access to install or run\nmodified versions of the software inside them, although the manufacturer\ncan do so.  This is fundamentally incompatible with the aim of\nprotecting users' freedom to change the software.  The systematic\npattern of such abuse occurs in the area of products for individuals to\nuse, which is precisely where it is most unacceptable.  Therefore, we\nhave designed this version of the GPL to prohibit the practice for those\nproducts.  If such problems arise substantially in other domains, we\nstand ready to extend this provision to those domains in future versions\nof the GPL, as needed to protect the freedom of users.\n\n  Finally, every program is threatened constantly by software patents.\nStates should not allow patents to restrict development and use of\nsoftware on general-purpose computers, but in those that do, we wish to\navoid the special danger that patents applied to a free program could\nmake it effectively proprietary.  To prevent this, the GPL assures that\npatents cannot be used to render the program non-free.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Use with the GNU Affero General Public License.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU Affero General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the special requirements of the GNU Affero General Public License,\nsection 13, concerning interaction through a network will apply to the\ncombination as such.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU General Public License from time to time.  Such new versions will\nbe similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU General Public License as published by\n    the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU General Public License for more details.\n\n    You should have received a copy of the GNU General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If the program does terminal interaction, make it output a short\nnotice like this when it starts in an interactive mode:\n\n    <program>  Copyright (C) <year>  <name of author>\n    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.\n    This is free software, and you are welcome to redistribute it\n    under certain conditions; type `show c' for details.\n\nThe hypothetical commands `show w' and `show c' should show the appropriate\nparts of the General Public License.  Of course, your program's commands\nmight be different; for a GUI interface, you would use an \"about box\".\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU GPL, see\n<https://www.gnu.org/licenses/>.\n\n  The GNU General Public License does not permit incorporating your program\ninto proprietary programs.  If your program is a subroutine library, you\nmay consider it more useful to permit linking proprietary applications with\nthe library.  If this is what you want to do, use the GNU Lesser General\nPublic License instead of this License.  But first, please read\n<https://www.gnu.org/licenses/why-not-lgpl.html>.\n"
  },
  {
    "path": "MangaManager/Extensions/CoverDownloader/CoverDownloader.py",
    "content": "from tkinter import Label, Frame, Entry\n\n\n\ndef get_cover_from_source_dummy() -> list:\n    ...\n\n\nclass CoverDownloader():#IExtensionApp):\n    name = \"Cover Downloader\"\n\n    def serve_gui(self):\n        if not self.master:\n            return Exception(\"Tried to initialize ui with no master window\")\n\n        frame = Frame(self.master)\n        frame.pack()\n\n        Label(frame, text=\"Manga identifier\").pack()\n        Entry(frame).pack()\n        # Combobox(frame, state=\"readonly\",values=sources_factory[\"CoverSources\"]).pack()\n\n        covers = get_cover_from_source_dummy()\n\n\n\n\n"
  },
  {
    "path": "MangaManager/Extensions/CoverDownloader/__init__.py",
    "content": ""
  },
  {
    "path": "MangaManager/Extensions/IExtensionApp.py",
    "content": "import abc\nimport tkinter\nfrom typing import final\n\n\nclass IExtensionApp(tkinter.Toplevel, metaclass=abc.ABCMeta):\n    \"\"\"\n        \n    \"\"\"\n    name = None\n    embedded_ui = False\n    master_frame = None\n    master = None\n    _super = None\n\n    @final\n    def __init__(self, master, super_=None, **kwargs):\n        \"\"\"\n        Initializes the toplevel window but hides the window.\n        \"\"\"\n        if self.name is None:  # Check if the \"name\" attribute has been set\n            raise ValueError(f\"Error initializing the {self.__class__.__name__} Extension. The 'name' attribute must be set in the ExtensionApp class.\")\n        # if self.embedded_ui:\n        super().__init__(master=master,**kwargs)\n        self.title(self.__class__.name)\n        self.master = self.master_frame = self\n        if super_ is not None:\n            self._super = super_\n        # else:\n        #     frame = tkinter.Frame()\n        #     self.master_frame = frame\n\n        self.serve_gui()\n        # self.withdraw()  # Hide the window\n\n    def _initialize(self):\n        ...\n    #     \"\"\"\n    #     Sets the new master window and displays the Toplevel window\n    #     :param master:\n    #     :return:\n    #     \"\"\"\n    #     self.master = master\n    #     self.deiconify()\n    #     self.serve_gui()\n\n    @abc.abstractmethod\n    def serve_gui(self):\n        ...\n\n\n"
  },
  {
    "path": "MangaManager/Extensions/Template.py",
    "content": "import logging\n\nfrom Extensions.IExtensionApp import IExtensionApp\n\nlogger = logging.getLogger()\n\n\nclass ExtensionTemplate(IExtensionApp):\n    name = \"Webp Converter\"\n\n    def serve_gui(self):\n        if not self.master:\n            return Exception(\"Tried to initialize ui with no master window\")"
  },
  {
    "path": "MangaManager/Extensions/WebpConverter/WebpConverter.py",
    "content": "from __future__ import annotations\n\nimport glob\nimport logging\nimport os\nimport pathlib\nimport threading\nimport tkinter\nimport tkinter.ttk as ttk\nfrom tkinter import filedialog\n\nfrom Extensions.IExtensionApp import IExtensionApp\nfrom src.Common.LoadedComicInfo.LoadedComicInfo import LoadedComicInfo\nfrom src.Common.utils import ShowPathTreeAsDict\nfrom src.MetadataManager.GUI.widgets import ScrolledFrameWidget, ProgressBarWidget\nfrom src.Settings.Settings import Settings\n\nlogger = logging.getLogger()\n\ndef start_processing(_selected_files,_progress_bar):\n    processing_thread = threading.Thread(target=_run_process, args=(_selected_files,_progress_bar))\n    processing_thread.start()\ndef _run_process(list_of_files,progress_bar:ProgressBarWidget):\n\n    for file in list_of_files:\n\n        logger.info(f\"[Extension][WebpConvert] Processing file\",\n                    extra={\"processed_filename\":file})\n        try:\n            # time.sleep(20)\n            LoadedComicInfo(file, load_default_metadata=False).convert_to_webp()\n            progress_bar.increase_processed()\n        except Exception:\n            logger.exception(f\"Failed to convert to webp '{file}'\")\n            progress_bar.increase_failed()\n    progress_bar.running = False\n\n\nclass WebpConverter(IExtensionApp):\n    name = \"Webp Converter\"\n    embedded_ui = True\n\n    base_path: str = \"\"\n    glob: str = \"**/*.cbz\"\n    _selected_files: list[str | pathlib.Path] = []\n    treeview_frame: ScrolledFrameWidget = None\n    nodes: dict\n    _progress_bar: ProgressBarWidget\n\n    def pb_update(self):\n        if self._progress_bar.running:\n            self._progress_bar._update()\n            self.after(20, self.pb_update)\n\n    @property\n    def selected_files(self):\n        self._set_input()\n        return self._selected_files\n\n    def process(self):\n        if not self._selected_files:\n            return\n        self._progress_bar.start(len(self._selected_files))\n        self._progress_bar.running = True\n        self.pb_update()\n        self.after(0, self.pb_update)\n        start_processing(self._selected_files,self._progress_bar)\n\n\n\n\n    def select_base(self):\n        self.base_path = filedialog.askdirectory(parent=self)  # select directory\n        self.selected_base_path.set(str(self.base_path))\n\n    def _on_file(self, parent, file):\n        self.tree.insert(self.nodes.get(str(parent.get(\"current\"))), 'end', text=file, open=True)\n\n    def _on_folder(self, parent_dic, folder):\n        parent_path = str(pathlib.Path(parent_dic.get(\"current\")))\n        node = self.tree.insert(self.nodes[parent_path], 'end', text=folder, open=True)\n        self.nodes[str(pathlib.Path(parent_path, folder))] = node\n\n    def _clear(self):\n        self.tree.delete(*self.tree.get_children())\n        self.nodes = dict()\n\n    def _set_input(self):\n        self.glob = self.path_glob.get() or \"*.cbz\"\n        os.chdir(self.base_path)\n        logger.debug(f\"Looking up files for glob: '{self.glob}'\")\n        self._selected_files = [\n            pathlib.Path(self.base_path, globbed_file) for globbed_file in glob.glob(self.glob, recursive=True)]\n        logger.debug(f\"Found {len(self._selected_files)} files\")\n\n    def preview(self):\n        if not self.base_path:\n            return\n        self._clear()\n        self._set_input()\n        abspath = os.path.abspath(self.base_path)\n        node = self.tree.insert(\"\", 'end', abspath, text=self.base_path, open=True)\n        self.nodes[abspath] = node\n        treeview = ShowPathTreeAsDict\n        treeview.on_file = self._on_file\n        treeview.on_subfolder = self._on_folder\n        self.treeview_files = treeview(base_path=self.base_path, paths=self.selected_files).get()\n\n    def serve_gui(self):\n        self.geometry(\"300x400\")\n\n        frame = tkinter.Frame(self.master)\n        frame.pack(fill=\"both\", expand=True, padx=20, pady=20)\n        default_base_setting = Settings().get('Webp Converter', 'default_base_path')\n        self.selected_base_path = tkinter.StringVar(None, value=default_base_setting)\n        self.base_path = default_base_setting\n        tkinter.Button(frame, text=\"Select Base Directory\", command=self.select_base, width=50).pack()\n        self.base_path_entry = tkinter.Entry(frame, state=\"readonly\", textvariable=self.selected_base_path, width=50)\n        self.base_path_entry.pack()\n        tkinter.Label(frame, text=\"Glob to apply:\", width=50).pack(side=\"top\")\n        self.path_glob = tkinter.Entry(frame, width=50)\n        self.path_glob.pack()\n        #\n        tkinter.Button(frame, text=\"Preview selected files\", pady=6, command=self.preview, width=50).pack(side=\"top\", pady=10)\n        tkinter.Button(frame, text=\"Process\", command=self.process, pady=6, width=50).pack(side=\"top\", pady=10)\n        pb_frame = tkinter.Frame(frame, pady=10, width=60)\n        pb_frame.pack()\n        self._progress_bar = ProgressBarWidget(pb_frame)\n\n        self.tree = ttk.Treeview(frame)\n        self.tree.heading('#0', text='Project tree', anchor='n')\n\n        self.tree.pack(expand=True, fill=\"both\", side=\"top\")\n"
  },
  {
    "path": "MangaManager/Extensions/WebpConverter/__init__.py",
    "content": ""
  },
  {
    "path": "MangaManager/Extensions/__init__.py",
    "content": ""
  },
  {
    "path": "MangaManager/ExternalSources/CoverSources/MangaDex/MangaDex.py",
    "content": "import logging\nimport os\nimport urllib\nfrom pathlib import Path\n\nimport requests\n\nfrom src.Common.utils import clean_filename\nfrom src.DynamicLibController.models.CoverSourceInterface import ICoverSource, Cover\nfrom src.Settings import SettingHeading\nfrom src.Settings.Settings import Settings\n\nlogger = logging.getLogger()\n\n\nclass MangaDex(ICoverSource):\n    name = \"MangaDex\"\n\n    @staticmethod\n    def parse_identifier(identifier) -> str:\n        ...\n\n    @classmethod\n    def get_covers(cls, identifier: str) -> list[Cover]:\n        \"\"\"\n        Downloads the covers from manga_id from mangadex.\n        If the cover is already downloaded it won't re-download\n\n        :param identifier: The manga identifier only.\n        \"\"\"\n\n        manga_id = cls.parse_identifier(identifier)\n\n\n\n        data = {\"manga[]\": [manga_id], \"includes[]\": [\"manga\"], \"limit\": 50}\n        # Request the list of covers in the provided manga\n        r = requests.get(f\"https://api.mangadex.org/cover\", params=data)\n\n        if r.status_code == 400:\n            logger.error(\"MangaDex api returned 400 for \")\n            raise Exception(\"status code 400\")\n\n        r_json = r.json()\n        cover_attributes = r_json.get(\"data\")[0].get(\"relationships\")[0].get(\"attributes\")\n\n        ja_title = list(filter(lambda p: p.get(\"ja-ro\"),\n                               cover_attributes.get(\"altTitles\")))\n        if ja_title:\n            ja_title = ja_title[0].get(\"ja-ro\")\n\n        normalized_manga_name = (ja_title or cover_attributes.get(\"title\").get(\"en\"))\n\n        destination_dirpath = Path(Settings().get(SettingHeading.Main, 'covers_folder_path'), clean_filename(\n            normalized_manga_name))  # The covers get stored in their own series folder inside the covers directory\n        total = len(r_json.get(\"data\"))\n        # Todo: Implement progress bar\n        for i, cover_data in enumerate(r_json.get(\"data\")):\n            try:\n\n                cover_filename = cover_data.get(\"attributes\").get(\"fileName\")\n                filename, file_extension = os.path.splitext(cover_filename)\n\n                cover_volume = cover_data.get(\"attributes\").get(\"volume\")\n                cover_loc = cover_data.get(\"attributes\").get(\"locale\")\n\n                destination_filename = f\"Cover_Vol.{str(cover_volume).zfill(2)}_{cover_loc}{file_extension}\"\n                destination_filepath = Path(destination_dirpath, destination_filename)\n                if (not destination_filepath.exists() or force_overwrite) and not test_run:\n                    image_url = f\"https://mangadex.org/covers/{manga_id}/{cover_filename}\"\n                    urllib.request.urlretrieve(image_url, destination_filepath)\n                    logger.debug(f\"Downloaded {destination_filename}\")\n                elif test_run:\n                    image_url = f\"https://mangadex.org/covers/{manga_id}/{cover_filename}\"\n                    print(f\"Asserting if valid url: '{image_url}' \")\n                    return check_url_isImage(image_url)\n                else:\n                    logger.info(f\"Skipped 'https://mangadex.org/covers/{manga_id}/{cover_filename}' -> Already exists\")\n            except Exception as e:\n                logger.error(e)\n        logger.info(f\"Files saved to: '{destination_dirpath}'\")\n\n    @classmethod\n    def parse_input(cls, value) -> str:\n        \"\"\"\n        Accepts a mangadex id or URL\n        :param value:\n        :return: mangadex managa id\n        \"\"\"\n\n        if \"https://mangadex.org/title/\" in value:\n            value = value.replace(\"https://mangadex.org/title/\", \"\").split(\"/\")[0]\n        return value\n\n"
  },
  {
    "path": "MangaManager/ExternalSources/CoverSources/MangaDex/__init__.py",
    "content": ""
  },
  {
    "path": "MangaManager/ExternalSources/CoverSources/__init__.py",
    "content": ""
  },
  {
    "path": "MangaManager/ExternalSources/MetadataSources/MetadataSourceFactory.py",
    "content": "import logging\n\n# Import all the scrapers here to ensure globals() has the key in it for dynamic instantiation\nfrom .Providers.AniList import AniList\nfrom .Providers.ComicVine import ComicVine\nfrom .Providers.MangaUpdates import MangaUpdates\n\nlogger = logging.getLogger()\n\n# Avoid IDE cleaning imports\nMangaUpdates.__dont_clean = \"\"\nAniList.__dont_clean = \"\"\nComicVine.__dont_clean = \"\"\n\n# NOTE: This is a stopgap solution until dynamic loader is implemented\nclass ScraperFactory:\n    \"\"\" Singleton Factory of metadata providers. Pass in the name defined in the provider .name() and an instance will be returned. \"\"\"\n    __instance = None\n    providers = {}\n\n    def __new__(cls):\n        if ScraperFactory.__instance is None:\n            ScraperFactory.__instance = object.__new__(cls)\n\n        return ScraperFactory.__instance\n\n    def __init__(self):\n        pass\n\n    def get_scraper(self, setting_name):\n        if setting_name not in self.providers:\n            try:\n                cls = globals()[setting_name]\n            except KeyError:\n                logger.exception(f\"Failed to load setting name '{setting_name}'\")\n                return\n            self.providers[setting_name] = cls()\n        return self.providers[setting_name]\n"
  },
  {
    "path": "MangaManager/ExternalSources/MetadataSources/Providers/AniList.py",
    "content": "from __future__ import annotations\n\nimport logging\nimport re\nimport requests\nfrom enum import StrEnum\nfrom typing import Optional\n\nfrom common import get_invalid_person_tag\nfrom common.models import ComicInfo\nfrom src.Common.errors import MangaNotFoundError\nfrom src.DynamicLibController.models.IMetadataSource import IMetadataSource\nfrom src.Settings.SettingControl import SettingControl\nfrom src.Settings.SettingControlType import SettingControlType\nfrom src.Settings.SettingSection import SettingSection\nfrom src.Settings.Settings import Settings\n\npattern = r\"anilist.com/manga/(\\d+)\"\n\nclass AniListPerson(StrEnum):\n    OriginalStory = \"original_story\",  # Original Story\n    CharacterDesign = \"character_design\",  # Character Design\n    Story = \"story\",  # Story\n    Art = \"art\",  # Art\n    Assistant = \"assistant\",  # Assistant\n\n\nclass AniListSetting(StrEnum):\n    SeriesTitleLanguage = \"series_title_language\",\n\n\nclass AniList(IMetadataSource):\n    name = \"AniList\"\n    _log = logging.getLogger()\n    # Map the Role from API to the ComicInfo tags to write\n    person_mapper = {}\n    _HOW_METADATA_MAPS_TOOLTIP = \"How metadata field will map to ComicInfo fields\"\n    romaji_as_series = True\n\n    def init_settings(self):\n        self.settings = [\n            SettingSection(self.name, self.name, [\n                SettingControl(key=AniListSetting.SeriesTitleLanguage, name=\"Prefer Romaji Series Title Language\",\n                               control_type=SettingControlType.Bool, value=True,\n                               tooltip=\"How metadata field will map to Series and LocalizedSeries fields\\n\"\n                                       \"true: Romaji->Series, English->LocalizedSeries\\n\"\n                                       \"false: English->Series, Romaji->LocalizedSeries\\n\"\n                                       \"Always Romaji->Series when no English\"),\n                SettingControl(key=AniListPerson.OriginalStory, name=\"Original Story\",\n                               control_type=SettingControlType.Text, value=\"Writer\",\n                               tooltip=self._HOW_METADATA_MAPS_TOOLTIP,\n                               validate=self.is_valid_person_tag, format_value=self.trim),\n                SettingControl(key=AniListPerson.CharacterDesign, name=\"Character Design\",\n                               control_type=SettingControlType.Text, value=\"Penciller\",\n                               tooltip=self._HOW_METADATA_MAPS_TOOLTIP,\n                               validate=self.is_valid_person_tag, format_value=self.trim),\n                SettingControl(key=AniListPerson.Story, name=\"Story\",\n                               control_type=SettingControlType.Text, value=\"Writer\",\n                               tooltip=self._HOW_METADATA_MAPS_TOOLTIP,\n                               validate=self.is_valid_person_tag, format_value=self.trim),\n                SettingControl(key=AniListPerson.Art, name=\"Art\",\n                               control_type=SettingControlType.Text, value=\"Penciller, Inker, CoverArtist\",\n                               tooltip=self._HOW_METADATA_MAPS_TOOLTIP,\n                               validate=self.is_valid_person_tag, format_value=self.trim),\n                SettingControl(key=AniListPerson.Assistant, name=\"Assistant\",\n                               control_type=SettingControlType.Text, value=\"\",\n                               tooltip=self._HOW_METADATA_MAPS_TOOLTIP,\n                               validate=self.is_valid_person_tag, format_value=self.trim),\n            ])\n        ]\n        super().init_settings()\n\n    def save_settings(self):\n        self.romaji_as_series = Settings().get(self.name, AniListSetting.SeriesTitleLanguage)\n        self.person_mapper[\"Original Story\"] = Settings().get(self.name, AniListPerson.OriginalStory).split(',')\n        self.person_mapper[\"Original Creator\"] = Settings().get(self.name, AniListPerson.OriginalStory).split(',')\n        self.person_mapper[\"Character Design\"] = Settings().get(self.name, AniListPerson.CharacterDesign).split(',')\n        self.person_mapper[\"Story\"] = Settings().get(self.name, AniListPerson.Story).split(',')\n        self.person_mapper[\"Art\"] = Settings().get(self.name, AniListPerson.Art).split(',')\n        self.person_mapper[\"Story & Art\"] = Settings().get(self.name, AniListPerson.Story).split(',') + Settings().get(\n            self.name, AniListPerson.Art).split(',')\n        self.person_mapper[\"Assistant\"] = Settings().get(self.name, AniListPerson.Assistant).split(',')\n\n    @staticmethod\n    def is_valid_person_tag(key, value):\n        invalid_people = get_invalid_person_tag(value)\n\n        if len(invalid_people) == 0:\n            return \"\"\n        return \", \".join(invalid_people) + \" are not a valid tags\"\n    @staticmethod\n    def get_manga_id_from_url(url):\n        pattern = r\"https:\\/\\/anilist\\.co\\/manga\\/(\\d+)\"\n        match = re.search(pattern, url)\n        if match:\n            return match.group(1)\n        return None\n    @classmethod\n    def _get_id_from_series(cls, cinfo: ComicInfo) -> Optional[int]:\n\n        manga_id = cls.get_manga_id_from_url(cinfo.web)\n        if manga_id is not None:\n            return manga_id\n\n        try:\n            content = cls._search_for_manga_title_by_manga_title(cinfo.series, \"MANGA\", {})\n        except MangaNotFoundError:\n            content = cls.search_for_manga_title_by_manga_title_with_adult(cinfo.series, \"MANGA\", {})\n\n        if content is None:\n            return None\n        return content.get(\"id\")\n\n    @classmethod\n    def get_cinfo(cls, comic_info_from_ui: ComicInfo) -> ComicInfo | None:\n        comicinfo = ComicInfo()\n        serie_id = cls._get_id_from_series(comic_info_from_ui)\n        if serie_id is None:\n            return None\n        data = cls._search_details_by_series_id(serie_id, \"MANGA\", {})\n\n        startdate = data.get(\"startDate\")\n        comicinfo.day = startdate.get(\"day\")\n        comicinfo.month = startdate.get(\"month\")\n        comicinfo.year = startdate.get(\"year\")\n        comicinfo.genre = \", \".join(data.get(\"genres\")).strip()\n        comicinfo.web = data.get(\"siteUrl\").strip()\n        if data.get(\"volumes\"):\n            comicinfo.count = data.get(\"volumes\")\n\n        # Title (Series & LocalizedSeries)\n        title = data.get(\"title\")\n        cls._log.info(\"[AniList] Fetch Data found title \" + str(title) + \" for \" + comic_info_from_ui.series)\n        title_english = (data.get(\"title\").get(\"english\") or \"\").strip()\n        title_romaji = (data.get(\"title\").get(\"romaji\") or \"\").strip()\n        if cls.romaji_as_series:\n            comicinfo.series = title_romaji\n            if title_romaji != title_english:\n                comicinfo.localized_series = title_english\n        else:\n            comicinfo.series = title_english\n            if title_romaji != title_english:\n                comicinfo.localized_series = title_romaji\n\n        # Summary\n        comicinfo.summary = IMetadataSource.clean_description(data.get(\"description\"), remove_source=True)\n\n        # People\n        cls.update_people_from_mapping(data[\"staff\"][\"edges\"], cls.person_mapper, comicinfo,\n                                       lambda item: item[\"node\"][\"name\"][\"full\"],\n                                       lambda item: item[\"role\"])\n\n        return comicinfo\n\n    @classmethod\n    def _post(cls, query, variables, logging_info):\n        try:\n            response = requests.post('https://graphql.anilist.co', json={'query': query, 'variables': variables})\n            if response.status_code == 429:  # Anilist rate-limit code\n                raise AniListRateLimit()\n        except AniListRateLimit:\n            cls._log.exception(\"Hitted anilist ratelimit\")\n            return None\n        except Exception:\n            cls._log.exception(\"Unhandled exception making the request to anilist\")\n            return None\n\n        cls._log.debug(f'Query: {query}')\n        cls._log.debug(f'Variables: {variables}')\n        # self.logger.debug(f'Response JSON: {response.json()}')\n        try:\n            return response.json()['data']['Media']\n        except TypeError:\n            cls._log.exception(\"Wrong data format recieved when parsing response json\")\n            return None\n\n    @classmethod\n    def _search_for_manga_title_by_id(cls, manga_id, logging_info):\n        query = '''\n            query search_for_manga_title_by_id ($manga_id: Int) {\n              Media (id: $manga_id, type: MANGA) {\n                id\n                title {\n                  romaji\n                  english\n                  native\n                }\n                synonyms\n              }\n            }\n            '''\n\n        variables = {\n            'manga_id': manga_id,\n        }\n\n        return cls._post(query, variables, logging_info)\n\n    @classmethod\n    def _search_for_manga_title_by_manga_title(cls, manga_title, format_, logging_info):\n        query = '''\n            query search_manga_by_manga_title ($manga_title: String, $format: MediaFormat) {\n              Media (search: $manga_title, type: MANGA, format: $format, isAdult: false) {\n                id\n                title {\n                  romaji\n                  english\n                  native\n                }\n                synonyms\n              }\n            }\n            '''\n\n        variables = {\n            'manga_title': manga_title,\n            'format': format_\n        }\n\n        ret = cls._post(query, variables, logging_info)\n        if ret is None:\n            raise MangaNotFoundError(\"AniList\", manga_title)\n        return ret\n\n    @classmethod\n    def search_for_manga_title_by_manga_title_with_adult(cls, manga_title, format_, logging_info):\n        query = '''\n            query search_manga_by_manga_title ($manga_title: String, $format: MediaFormat) {\n              Media (search: $manga_title, type: MANGA, format: $format) {\n                id\n                title {\n                  romaji\n                  english\n                  native\n                }\n                synonyms\n              }\n            }\n            '''\n\n        variables = {\n            'manga_title': manga_title,\n            'format': format_\n        }\n\n        return cls._post(query, variables, logging_info)\n\n    @classmethod\n    def _search_details_by_series_id(cls, series_id, format_, logging_info):\n        query = '''\n            query search_details_by_series_id ($series_id: Int, $format: MediaFormat) {\n              Media (id: $series_id, type: MANGA, format: $format) {\n                id\n                status\n                volumes\n                siteUrl\n                title {\n                  romaji\n                  english\n                  native\n                }\n                type\n                genres\n                synonyms\n                startDate {\n                  day\n                  month\n                  year\n                }\n                coverImage {\n                  extraLarge\n                }\n                staff {\n                  edges {\n                    node{\n                      name {\n                        first\n                        last\n                        full\n                        alternative\n                      }\n                      siteUrl\n                    }\n                    role\n                  }\n                }\n                description\n              }\n            }\n            '''\n\n        variables = {\n            'series_id': series_id,\n            'format': format_\n        }\n\n        return cls._post(query, variables, logging_info)\n\n\nclass AniListRateLimit(Exception):\n    \"\"\"\n    Exception raised when AniList rate-limit is breached.\n    \"\"\"\n"
  },
  {
    "path": "MangaManager/ExternalSources/MetadataSources/Providers/ComicVine.py",
    "content": "import logging\nfrom abc import ABC\n\nimport requests\n\nfrom common.models import ComicInfo\nfrom src.Common.errors import MangaNotFoundError\nfrom src.DynamicLibController.models.IMetadataSource import IMetadataSource\nfrom src.Settings import SettingSection, SettingControl, SettingControlType, Settings\n\n\nclass ComicVine(IMetadataSource, ABC):\n    name = 'ComicVine'\n    _log = logging.getLogger()\n\n    def __init__(self):\n        self.settings = [\n            SettingSection(self.name, self.name, [\n                SettingControl('api_key', \"API Key\", SettingControlType.Text, \"\",\n                               \"API Key to communicate with ComicVine. This is required for the source\"),\n\n            ])\n        ]\n\n        super(ComicVine, self).__init__()\n        self._log = logging.getLogger(f'{self.__module__}.{self.name}')\n\n    def save_settings(self):\n        pass\n\n    def get_cinfo(self, comic_info_from_ui: ComicInfo) -> ComicInfo | None:\n        comicinfo = ComicInfo()\n        try:\n            content = self._search_by_title(comic_info_from_ui.series)\n        except MangaNotFoundError:\n            content = self._search_by_issue(comic_info_from_ui.series)\n\n        if content is None:\n            return None\n        # content = content.get(\"id\")\n        # data = self._search_details_by_series_id(content, \"MANGA\", {})\n        #\n        # startdate = data.get(\"startDate\")\n        # comicinfo.summary = data.get(\"description\").strip()\n        # comicinfo.day = startdate.get(\"day\")\n        # comicinfo.month = startdate.get(\"month\")\n        # comicinfo.year = startdate.get(\"year\")\n        # comicinfo.series = data.get(\"title\").get(\"romaji\").strip()\n        # comicinfo.genre = \", \".join(data.get(\"genres\")).strip()\n        # comicinfo.web = data.get(\"siteUrl\").strip()\n\n        # People\n        # self.update_people_from_mapping(data[\"staff\"][\"edges\"], self.person_mapper, comicinfo,\n        #                                lambda item: item[\"node\"][\"name\"][\"full\"],\n        #                                lambda item: item[\"role\"])\n\n        return comicinfo\n\n    def _search_by_title(self, series_name, publish_year=\"\"):\n        url = f\"{self._build_url_base('series')}&name={series_name}\"\n        try:\n            response = requests.get(url)\n            # if response.status_code == 429:  # Anilist rate-limit code\n            #     raise AniListRateLimit()\n        except Exception as e:\n            self._log.warning('Manga Manager is unfamiliar with this error. Please log an issue for investigation.', e)\n            return None\n\n        self._log.debug(f'Query: {url}')\n        try:\n            return response.json()['results']\n        except TypeError:\n            return None\n        pass\n\n    def _search_by_issue(self, series_name, issue_number):\n        pass\n\n    def _build_url_base(self, entity):\n        return f\"http://api.comicvine.com/{entity}/?api_key={Settings().get(self.name, 'API Key')}&format=json\"\n"
  },
  {
    "path": "MangaManager/ExternalSources/MetadataSources/Providers/MangaUpdates.py",
    "content": "import logging\nfrom enum import StrEnum\n\nimport requests\n\nfrom common import get_invalid_person_tag\nfrom common.models import ComicInfo\nfrom src.Common.errors import MangaNotFoundError\nfrom src.DynamicLibController.models.IMetadataSource import IMetadataSource\nfrom src.Settings.SettingControl import SettingControl\nfrom src.Settings.SettingControlType import SettingControlType\nfrom src.Settings.SettingSection import SettingSection\nfrom src.Settings.Settings import Settings\n\n\nclass MangaUpdatesPerson(StrEnum):\n    Author = \"author\",\n    Artist = \"artist\",\n\n\nclass MangaUpdates(IMetadataSource):\n    name = \"MangaUpdates\"\n    _log = logging.getLogger()\n    person_mapper = {\n        \"Author\": [\n            \"Writer\"\n        ],\n        \"Artist\": [\n            \"Penciller\",\n            \"Inker\",\n            \"CoverArtist\"\n        ]\n    }\n\n    def init_settings(self):\n        self.settings = [\n            SettingSection(self.name, self.name, [\n                SettingControl(MangaUpdatesPerson.Author, \"Author\", SettingControlType.Text, \"Writer\",\n                               \"How metadata field will map to ComicInfo fields\", self.is_valid_person_tag, self.trim),\n                SettingControl(MangaUpdatesPerson.Artist, \"Artist\", SettingControlType.Text,\n                               \"Penciller, Inker, CoverArtist\", \"How metadata field will map to ComicInfo fields\",\n                               self.is_valid_person_tag, self.trim),\n            ])\n        ]\n\n    def save_settings(self):\n        # Update person_mapper when this is called as it indicates the settings for the provider might have changed\n        self.person_mapper[MangaUpdatesPerson.Author] = Settings().get(self.name, MangaUpdatesPerson.Author).split(',')\n        self.person_mapper[MangaUpdatesPerson.Artist] = Settings().get(self.name, MangaUpdatesPerson.Artist).split(',')\n\n    @staticmethod\n    def is_valid_person_tag(key, value):\n        invalid_people = get_invalid_person_tag(value)\n\n        if len(invalid_people) == 0:\n            return \"\"\n        return \", \".join(invalid_people) + \" are not a valid tags\"\n\n    @classmethod\n    def get_cinfo(cls, comic_info_from_ui) -> ComicInfo | None:\n        # We need to take what's already in the UI and allow fetching to merge the data in\n        comicinfo = ComicInfo()\n        \n        data = cls._get_series_details(comic_info_from_ui.series, {})\n\n        # Basic Info\n        comicinfo.series = data[\"title\"].strip()\n        comicinfo.summary = IMetadataSource.clean_description(data.get(\"description\"), remove_source=True)\n        comicinfo.genre = \", \".join([i[\"genre\"] for i in data[\"genres\"]]).strip()\n        comicinfo.tags = \", \".join([i[\"category\"] for i in data[\"categories\"]])\n        comicinfo.web = data[\"url\"].strip()\n        comicinfo.manga = \"Yes\" if data[\"type\"] == \"Manga\" else \"No\"\n        comicinfo.year = data[\"year\"]\n\n        # People Info\n        cls.update_people_from_mapping(cls, data[\"authors\"], cls.person_mapper, comicinfo,\n                                   lambda item: item[\"name\"],\n                                   lambda item: item[\"type\"])\n\n        comicinfo.publisher = (\", \".join([ i[\"publisher_name\"] for i in data[\"publishers\"] ]))\n\n        # Extended\n        comicinfo.community_rating = round(data[\"bayesian_rating\"]/2, 1)\n\n        return comicinfo\n\n    @classmethod\n    def _get_series_id(cls, search_params, logging_info):\n        try:\n            response = requests.post('https://api.mangaupdates.com/v1/series/search', json=search_params)\n        except Exception as e:\n            cls._log.exception(e, extra=logging_info)\n            cls._log.warning('Manga Manager is unfamiliar with this error. Please log an issue for investigation.',\n                             extra=logging_info)\n            return None\n\n        cls._log.debug(f'Search Params: {search_params}')\n        # cls.logger.debug(f'Response JSON: {response.json()}')\n\n        if len(response.json()['results']) == 0:\n            raise MangaNotFoundError(\"MangaUpdates\",search_params['search'])\n        try:\n            return response.json()['results'][0]['record']['series_id']\n        except TypeError:\n            return None\n\n    @classmethod\n    def _get_series_details(cls, manga_title, logging_info):\n        search_params = {\n            \"search\": manga_title,\n            \"page\": 1,\n            \"per_page\": 1\n        }\n\n        try:\n            series_details = requests.get('https://api.mangaupdates.com/v1/series/' + str(cls._get_series_id(search_params, {})))\n        except Exception as e:\n            cls._log.exception(e, extra=logging_info)\n            cls._log.warning('Manga Manager is unfamiliar with this error. Please log an issue for investigation.',\n                             extra=logging_info)\n            return None\n            \n        return series_details.json()\n"
  },
  {
    "path": "MangaManager/ExternalSources/MetadataSources/Providers/__init__.py",
    "content": ""
  },
  {
    "path": "MangaManager/ExternalSources/MetadataSources/__init__.py",
    "content": "\nfrom .MetadataSourceFactory import ScraperFactory\nfrom .Providers.AniList import AniList\nfrom .Providers.MangaUpdates import MangaUpdates\n\nprint(\"MetadataSources module loaded\")"
  },
  {
    "path": "MangaManager/ExternalSources/__init__.py",
    "content": "\n"
  },
  {
    "path": "MangaManager/common/__init__.py",
    "content": "from .models import PeopleTags\n\n\ndef get_invalid_person_tag(people: str):\n    \"\"\" Validates that a common separated list or single person is a valid Person tag\"\"\"\n    invalid_people = []\n    for person in [p.strip() for p in people.split(\",\") if p != \"\"]:\n        if person not in list(PeopleTags):\n            invalid_people.append(person)\n    return invalid_people\n"
  },
  {
    "path": "MangaManager/common/models/AgeRating.py",
    "content": "from enum import Enum\n\n# Keep this ordered in terms of progressing ratings, rather than alphabetical\nclass AgeRating(str, Enum):\n    UNKNOWN = 'Unknown'\n    RATING_PENDING = 'Rating Pending'\n    EARLY_CHILDHOOD = 'Early Childhood'\n    EVERYONE = 'Everyone'\n    G = 'G'\n    EVERYONE_10 = 'Everyone 10+'\n    PG = 'PG'\n    KIDSTO_ADULTS = 'Kids to Adults'\n    TEEN = 'Teen'\n    MA_15 = 'MA15+'\n    MATURE_17 = 'Mature 17+'\n    M = 'M'\n    R_18 = 'R18+'\n    ADULTS_ONLY_18 = 'Adults Only 18+'\n    X_18 = 'X18+'\n\n    @classmethod\n    def list(cls):\n        return list(map(lambda c: c.value, cls))\n"
  },
  {
    "path": "MangaManager/common/models/ComicInfo.py",
    "content": "from io import BytesIO\nfrom xml.etree import ElementTree as ET\n\ncomic_info_tag_map = {\n    \"series\": \"Series\",\n    \"localized_series\": \"LocalizedSeries\",\n    \"series_sort\": \"SeriesSort\",\n    \"count\": \"Count\",\n    \"writer\": \"Writer\",\n    \"penciller\": \"Penciller\",\n    \"inker\": \"Inker\",\n    \"colorist\": \"Colorist\",\n    \"letterer\": \"Letterer\",\n    \"cover_artist\": \"CoverArtist\",\n    \"editor\": \"Editor\",\n    \"translator\": \"Translator\",\n    \"publisher\": \"Publisher\",\n    \"imprint\": \"Imprint\",\n    \"characters\": \"Characters\",\n    \"teams\": \"Teams\",\n    \"locations\": \"Locations\",\n    \"main_character_or_team\": \"MainCharacterOrTeam\",\n    \"other\": \"Other\",\n    \"genre\": \"Genre\",\n    \"age_rating\": \"AgeRating\",\n    \"series_group\": \"SeriesGroup\",\n    \"alternate_series\": \"AlternateSeries\",\n    \"story_arc\": \"StoryArc\",\n    \"story_arc_number\": \"StoryArcNumber\",\n    \"alternate_count\": \"AlternateCount\",\n    \"alternate_number\": \"AlternateNumber\",\n    \"title\": \"Title\",\n    \"summary\": \"Summary\",\n    \"review\": \"Review\",\n    \"tags\": \"Tags\",\n    \"web\": \"Web\",\n    \"number\": \"Number\",\n    \"volume\": \"Volume\",\n    \"format\": \"Format\",\n    \"manga\": \"Manga\",\n    \"year\": \"Year\",\n    \"month\": \"Month\",\n    \"day\": \"Day\",\n    \"language_iso\": \"LanguageISO\",\n    \"notes\": \"Notes\",\n    \"community_rating\": \"CommunityRating\",\n    \"black_and_white\": \"BlackAndWhite\",\n    \"page_count\": \"PageCount\",\n    \"scan_information\": \"ScanInformation\",\n    \"gtin\": \"GTIN\"\n}\n\n\nclass ComicInfo:\n    series = \"\"\n    localized_series = \"\"\n    count = \"\"\n    writer = \"\"\n    penciller = \"\"\n    inker = \"\"\n    colorist = \"\"\n    letterer = \"\"\n    cover_artist = \"\"\n    editor = \"\"\n    translator = \"\"\n    publisher = \"\"\n    imprint = \"\"\n    characters = \"\"\n    teams = \"\"\n    locations = \"\"\n    main_character_or_team = \"\"\n    genre = \"\"\n    age_rating = \"\"\n    series_sort = \"\"\n    series_group = \"\"\n    alternate_series = \"\"\n    story_arc = \"\"\n    story_arc_number = \"\"\n    alternate_count = \"\"\n    alternate_number = \"\"\n    title = \"\"\n    summary = \"\"\n    review = \"\"\n    tags = \"\"\n    web = \"\"\n    number = \"\"\n    volume = \"\"\n    format = \"\"\n    manga = \"\"\n    year = \"\"\n    month = \"\"\n    day = \"\"\n    language_iso = \"\"\n    notes = \"\"\n    community_rating = \"\"\n    black_and_white = \"\"\n    page_count = \"\"\n    scan_information = \"\"\n    other = \"\"\n    gtin = \"\"\n\n    def __init__(self):\n        pass\n\n    def set_by_tag_name(self, tag, value):\n        for key, v in comic_info_tag_map.items():\n            if tag == v:\n                if value is None:\n                    value = \"\"\n                self.__setattr__(key, value)\n\n    def get_by_tag_name(self, name) -> str:\n        for key, value in comic_info_tag_map.items():\n            if name == value:\n                ret = getattr(self, key)\n                if ret is None:\n                    return \"\"\n                return ret\n        return \"\"\n\n    @classmethod\n    def from_xml(cls, xml_string):\n        root = ET.ElementTree(ET.fromstring(xml_string.encode(\"utf-8\"), parser=ET.XMLParser(encoding='utf-8')))\n        comic_info = cls()\n        for prop in [a for a in dir(comic_info) if not a.startswith('__') and not callable(getattr(comic_info, a))]:\n            comic_info.__setattr__(prop, root.findtext(comic_info_tag_map[prop]))\n\n        return comic_info\n\n    def to_xml(self):\n        root = ET.Element(\"ComicInfo\")\n        for key, mapped_key in comic_info_tag_map.items():\n            value = str(self.get_by_tag_name(mapped_key))\n            if value:\n                ET.SubElement(root, mapped_key).text = value\n\n        # prevent creation of self-closing tags\n        for node in root.iter():\n            if node.text is None:\n                node.text = \"\"\n        f = BytesIO()\n        et = ET.ElementTree(root)\n        ET.indent(et)\n        et.write(f, encoding='utf-8', xml_declaration=True)\n        ret_xml = f.getvalue()\n        return str(ret_xml, encoding=\"utf-8\")\n        # print(f.getvalue())  # your XML file, encoded as UTF-8\n        # output_xml = ET.tostring(root, encoding=\"UTF-8\", xml_declaration=True, method='xml').decode(\"utf8\")\n        # return output_xml\n\n    \"\"\"Returns TRUE if it has changes\"\"\"\n    def has_changes(self, other):\n        for key in comic_info_tag_map.keys():\n            if getattr(self, key) != getattr(other, key):\n                return True\n        return False"
  },
  {
    "path": "MangaManager/common/models/ComicInfo.xds",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<xs:schema elementFormDefault=\"qualified\" xmlns:xs=\"http://www.w3.org/2001/XMLSchema\">\n    <xs:element name=\"ComicInfo\" nillable=\"true\" type=\"ComicInfo\" />\n    <xs:complexType name=\"ComicInfo\">\n        <xs:sequence>\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"\" name=\"Title\" type=\"xs:string\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"\" name=\"Series\" type=\"xs:string\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"\" name=\"LocalizedSeries\" type=\"xs:string\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"\" name=\"SeriesSort\" type=\"xs:string\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"\" name=\"Number\" type=\"xs:string\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"-1\" name=\"Count\" type=\"xs:int\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"-1\" name=\"Volume\" type=\"xs:int\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"\" name=\"AlternateSeries\" type=\"xs:string\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"\" name=\"AlternateNumber\" type=\"xs:string\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"-1\" name=\"AlternateCount\" type=\"xs:int\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"\" name=\"Summary\" type=\"xs:string\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"\" name=\"Notes\" type=\"xs:string\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"-1\" name=\"Year\" type=\"xs:int\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"-1\" name=\"Month\" type=\"xs:int\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"-1\" name=\"Day\" type=\"xs:int\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"\" name=\"Writer\" type=\"xs:string\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"\" name=\"Penciller\" type=\"xs:string\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"\" name=\"Inker\" type=\"xs:string\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"\" name=\"Colorist\" type=\"xs:string\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"\" name=\"Letterer\" type=\"xs:string\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"\" name=\"CoverArtist\" type=\"xs:string\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"\" name=\"Editor\" type=\"xs:string\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"\" name=\"Translator\" type=\"xs:string\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"\" name=\"Publisher\" type=\"xs:string\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"\" name=\"Imprint\" type=\"xs:string\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"\" name=\"Genre\" type=\"xs:string\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"\" name=\"Tags\" type=\"xs:string\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"\" name=\"Web\" type=\"xs:string\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"0\" name=\"PageCount\" type=\"xs:int\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"\" name=\"LanguageISO\" type=\"xs:string\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"\" name=\"Format\" type=\"xs:string\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"Unknown\" name=\"BlackAndWhite\" type=\"YesNo\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"Unknown\" name=\"Manga\" type=\"Manga\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"\" name=\"Characters\" type=\"xs:string\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"\" name=\"Teams\" type=\"xs:string\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"\" name=\"Locations\" type=\"xs:string\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"\" name=\"ScanInformation\" type=\"xs:string\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"\" name=\"StoryArc\" type=\"xs:string\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"\" name=\"StoryArcNumber\" type=\"xs:string\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"\" name=\"SeriesGroup\" type=\"xs:string\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"Unknown\" name=\"AgeRating\" type=\"AgeRating\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" name=\"Pages\" type=\"ArrayOfComicPageInfo\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" name=\"CommunityRating\" type=\"Rating\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"\" name=\"Other\" type=\"xs:string\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"\" name=\"MainCharacterOrTeam\" type=\"xs:string\" />\n            <xs:element minOccurs=\"0\" maxOccurs=\"1\" default=\"\" name=\"Review\" type=\"xs:string\" />\n\n        </xs:sequence>\n    </xs:complexType>\n    <xs:simpleType name=\"YesNo\">\n        <xs:restriction base=\"xs:string\">\n            <xs:enumeration value=\"Unknown\" />\n            <xs:enumeration value=\"No\" />\n            <xs:enumeration value=\"Yes\" />\n        </xs:restriction>\n    </xs:simpleType>\n    <xs:simpleType name=\"Manga\">\n        <xs:restriction base=\"xs:string\">\n            <xs:enumeration value=\"Unknown\" />\n            <xs:enumeration value=\"No\" />\n            <xs:enumeration value=\"Yes\" />\n            <xs:enumeration value=\"YesAndRightToLeft\" />\n        </xs:restriction>\n    </xs:simpleType>\n    <xs:simpleType name=\"Rating\">\n        <xs:restriction base=\"xs:decimal\">\n            <xs:minInclusive value=\"0\"/>\n            <xs:maxInclusive value=\"5\"/>\n            <xs:fractionDigits value=\"1\"/>\n        </xs:restriction>\n    </xs:simpleType>\n    <xs:simpleType name=\"AgeRating\">\n        <xs:restriction base=\"xs:string\">\n            <xs:enumeration value=\"Unknown\" />\n            <xs:enumeration value=\"Adults Only 18+\" />\n            <xs:enumeration value=\"Early Childhood\" />\n            <xs:enumeration value=\"Everyone\" />\n            <xs:enumeration value=\"Everyone 10+\" />\n            <xs:enumeration value=\"G\" />\n            <xs:enumeration value=\"Kids to Adults\" />\n            <xs:enumeration value=\"M\" />\n            <xs:enumeration value=\"MA15+\" />\n            <xs:enumeration value=\"Mature 17+\" />\n            <xs:enumeration value=\"PG\" />\n            <xs:enumeration value=\"R18+\" />\n            <xs:enumeration value=\"Rating Pending\" />\n            <xs:enumeration value=\"Teen\" />\n            <xs:enumeration value=\"X18+\" />\n        </xs:restriction>\n    </xs:simpleType>\n    <xs:complexType name=\"ArrayOfComicPageInfo\">\n        <xs:sequence>\n            <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" name=\"Page\" nillable=\"true\" type=\"ComicPageInfo\" />\n        </xs:sequence>\n    </xs:complexType>\n    <xs:complexType name=\"ComicPageInfo\">\n        <xs:attribute name=\"Image\" type=\"xs:int\" use=\"required\" />\n        <xs:attribute default=\"Story\" name=\"Type\" type=\"ComicPageType\" />\n        <xs:attribute default=\"false\" name=\"DoublePage\" type=\"xs:boolean\" />\n        <xs:attribute default=\"0\" name=\"ImageSize\" type=\"xs:long\" />\n        <xs:attribute default=\"\" name=\"Key\" type=\"xs:string\" />\n        <xs:attribute default=\"\" name=\"Bookmark\" type=\"xs:string\" />\n        <xs:attribute default=\"-1\" name=\"ImageWidth\" type=\"xs:int\" />\n        <xs:attribute default=\"-1\" name=\"ImageHeight\" type=\"xs:int\" />\n    </xs:complexType>\n    <xs:simpleType name=\"ComicPageType\">\n        <xs:list>\n            <xs:simpleType>\n                <xs:restriction base=\"xs:string\">\n                    <xs:enumeration value=\"FrontCover\" />\n                    <xs:enumeration value=\"InnerCover\" />\n                    <xs:enumeration value=\"Roundup\" />\n                    <xs:enumeration value=\"Story\" />\n                    <xs:enumeration value=\"Advertisement\" />\n                    <xs:enumeration value=\"Editorial\" />\n                    <xs:enumeration value=\"Letters\" />\n                    <xs:enumeration value=\"Preview\" />\n                    <xs:enumeration value=\"BackCover\" />\n                    <xs:enumeration value=\"Other\" />\n                    <xs:enumeration value=\"Deleted\" />\n                </xs:restriction>\n            </xs:simpleType>\n        </xs:list>\n    </xs:simpleType>\n</xs:schema>"
  },
  {
    "path": "MangaManager/common/models/ComicInfoTag.py",
    "content": ""
  },
  {
    "path": "MangaManager/common/models/ComicPageType.py",
    "content": "from enum import Enum\n\n\nclass ComicPageType(str, Enum):\n    FRONT_COVER = 'FrontCover'\n    INNER_COVER = 'InnerCover'\n    ROUNDUP = 'Roundup'\n    STORY = 'Story'\n    ADVERTISMENT = 'Advertisment'\n    EDITORIAL = 'Editorial'\n    LETTERS = 'Letters'\n    PREVIEW = 'Preview'\n    BACK_COVER = 'BackCover'\n    OTHER = 'Other'\n    DELETED = 'Deleted'\n\n    @classmethod\n    def list(cls):  # pragma: no cover\n        return list(map(lambda c: c.value, cls))"
  },
  {
    "path": "MangaManager/common/models/Manga.py",
    "content": "from enum import Enum\n\n\nclass Manga(str, Enum):\n    UNKNOWN = 'Unknown'\n    NO = 'No'\n    YES = 'Yes'\n    YES_AND_RIGHT_TO_LEFT = 'YesAndRightToLeft'\n\n    @classmethod\n    def list(cls):  # pragma: no cover\n        return list(map(lambda c: c.value, cls))\n"
  },
  {
    "path": "MangaManager/common/models/YesNo.py",
    "content": "from enum import Enum\n\n\nclass YesNo(str, Enum):\n    UNKNOWN = 'Unknown'\n    NO = 'No'\n    YES = 'Yes'\n\n    @classmethod\n    def list(cls):  # pragma: no cover\n        return list(map(lambda c: c.value, cls))"
  },
  {
    "path": "MangaManager/common/models/__init__.py",
    "content": "from .AgeRating import AgeRating\nfrom .ComicPageType import ComicPageType\nfrom .Manga import Manga\nfrom .YesNo import YesNo\n\n\nfrom .ComicInfo import ComicInfo\n\nFormats = (\n    \"\", \"Special\", \"Reference\", \"Director's Cut\", \"Box Set\", \"Annual\", \"Anthology\", \"Epilogue\", \"One-Shot\", \"Prologue\",\n    \"TPB\", \"Trade Paper Back\", \"Omnibus\", \"Compendium\", \"Absolute\", \"Graphic Novel\", \"GN\", \"FCB\"\n)\n\nPeopleTags = (\n    \"Writer\", \"Penciller\", \"Inker\", \"Letterer\", \"CoverArtist\", \"Editor\", \"Translator\",\n    \"Publisher\", \"Imprint\", \"Other\",\n)\n"
  },
  {
    "path": "MangaManager/logging_setup.py",
    "content": "import logging\nimport sys\nfrom logging.handlers import RotatingFileHandler\n\n\ndef trace(self, message, *args, **kws):\n    # Yes, logger takes its '*args' as 'args'.\n    self._log(logging.TRACE, message, args, **kws)\n\n\ndef add_trace_level():\n    logging.TRACE = 9\n    logging.addLevelName(logging.TRACE, \"TRACE\")\n\n    logging.Logger.trace = trace\n\nclass UmpumpedLogHandler(logging.Handler):\n    def emit(self, record):\n        logging.umpumped_events.append(record)\n        ei = record.exc_info\n\ndef setup_logging(LOGFILE_PATH,level=logging.DEBUG):\n\n    # Create our own implementation to have trace logging\n\n    # Setup Logger\n    logging.umpumped_events = []\n\n    umpumped_handler = logging.umpumped_handler = UmpumpedLogHandler(logging.INFO)\n\n    logging.getLogger('PIL').setLevel(logging.WARNING)\n\n    rotating_file_handler = RotatingFileHandler(LOGFILE_PATH, maxBytes=10_000_000,\n                                                backupCount=2)\n    rotating_file_handler.setLevel(level)\n\n    stream_handler = logging.StreamHandler(sys.stdout)\n    stream_handler.setLevel(level)\n    logging.basicConfig(level=logging.DEBUG,\n                        format='%(asctime)s - %(name)20s - %(levelname)8s - %(message)s',\n                        handlers=[stream_handler, rotating_file_handler, umpumped_handler]\n                        # filename='/tmp/myapp.log'\n                        )\n\n    logger = logging.getLogger()\n\n    logger.debug('DEBUG LEVEL - MAIN MODULE')\n    logger.info('INFO LEVEL - MAIN MODULE')\n    logger.trace('TRACE LEVEL - MAIN MODULE')"
  },
  {
    "path": "MangaManager/main.py",
    "content": "import argparse\nimport enum\nimport glob\nimport logging\nfrom pathlib import Path\n\nfrom logging_setup import add_trace_level, setup_logging\n\nadd_trace_level()\n\nparser = argparse.ArgumentParser()\n\n############\n# Logging arguments\n###########\nparser.add_argument(\n    '--debug',\n    help=\"Print lots of debugging statements\",\n    action=\"store_const\", dest=\"loglevel\", const=logging.DEBUG,\n    default=logging.INFO)\nparser.add_argument(\n    '--trace',\n    help=\"Prints INSANE ammount of debug statements\",\n    action=\"store_const\", dest=\"loglevel\", const=logging.TRACE,\n)\n\nparser.add_argument('-d', help=\"Debug Level\", action=\"store\", dest=\"selected_files_cli\",\n                    metavar=\"--cli <glob-like-path>\", required=False, default=False)\n\nparser.add_argument('--cli', help=\"Metadata Editor in CLI mode\", action=\"store\", dest=\"selected_files_cli\",\n                    metavar=\"--cli <glob-like-path>\", required=False, default=False)\n\nparser.add_argument('--webp', help=\"Webp converter in CLI mode\", action=\"store\", dest=\"selected_files_webp\",\n                    metavar=\"--webp <glob-like-path>\", required=False, default=False)\nargs = parser.parse_args()\n\n# Setup logger\nmm_path = Path(Path.home(), \"MangaManager\")\nmm_path.mkdir(exist_ok=True, parents=True)\nLOGS_PATH = Path(f\"{mm_path}/logs/\")\nLOGS_PATH.mkdir(parents=True, exist_ok=True)\nLOGFILE_PATH = Path(LOGS_PATH, \"MangaManager.log\")\nsetup_logging(LOGFILE_PATH, args.loglevel)\nlogger = logging.getLogger()\n\nfrom src.Settings.Settings import Settings\n# Create initial ini with defaults else load existing\nSettings().load()\nfrom src.Common.errors import NoFilesSelected\nfrom src.MetadataManager.MetadataManagerCLI import App as CLIMetadataApp\nfrom src.__version__ import __version__ as version\n\n\n\n# <Arguments parser>\n\n\nclass ToolS(enum.Enum):\n    NONE = 0\n    METADATA = 1\n    WEBP = 5\n\n    @classmethod\n    def list(cls):\n        return list(map(lambda c: c.name, cls))\n\n\ndef get_selected_files(glob_path) -> list[str]:\n    file_paths = glob.glob(glob_path)\n    if not file_paths:\n        raise NoFilesSelected()\n    return file_paths\n\n\n\n\nif __name__ == '__main__':\n    if args.selected_files_cli:\n        logger.info(f\"Starting: CLI Metadata app\")\n        selected_files = get_selected_files(args.selected_files_cli)\n        app = CLIMetadataApp(selected_files)\n    elif args.selected_files_webp:\n        logger.info(f\"Starting: CLI Webp converter app\")\n        # app = glob.glob(args.selected_files))\n        selected_files = get_selected_files(args.selected_files_cli)\n\n    else:\n        logger.info(f\"Starting: GUI Manga Manager v{version}. Welcome\")\n        from src.MetadataManager import execute_gui\n        execute_gui()\n"
  },
  {
    "path": "MangaManager/pyinstaller_hooks/hook-tkinterdnd2.py",
    "content": "from PyInstaller.utils.hooks import collect_data_files\n\ndatas = collect_data_files('tkinterdnd2')\n"
  },
  {
    "path": "MangaManager/res/languages.json",
    "content": "[{\n    \"isoCode\": \"aa\",\n    \"title\": \"Afar\"\n}, {\n    \"isoCode\": \"aa-DJ\",\n    \"title\": \"Afar (Djibouti)\"\n}, {\n    \"isoCode\": \"aa-ER\",\n    \"title\": \"Afar (Eritrea)\"\n}, {\n    \"isoCode\": \"aa-ET\",\n    \"title\": \"Afar (Ethiopia)\"\n}, {\n    \"isoCode\": \"af\",\n    \"title\": \"Afrikaans\"\n}, {\n    \"isoCode\": \"af-NA\",\n    \"title\": \"Afrikaans (Namibia)\"\n}, {\n    \"isoCode\": \"af-ZA\",\n    \"title\": \"Afrikaans (South Africa)\"\n}, {\n    \"isoCode\": \"agq\",\n    \"title\": \"Aghem\"\n}, {\n    \"isoCode\": \"agq-CM\",\n    \"title\": \"Aghem (Cameroon)\"\n}, {\n    \"isoCode\": \"ak\",\n    \"title\": \"Akan\"\n}, {\n    \"isoCode\": \"ak-GH\",\n    \"title\": \"Akan (Ghana)\"\n}, {\n    \"isoCode\": \"am\",\n    \"title\": \"Amharic\"\n}, {\n    \"isoCode\": \"am-ET\",\n    \"title\": \"Amharic (Ethiopia)\"\n}, {\n    \"isoCode\": \"ar\",\n    \"title\": \"Arabic\"\n}, {\n    \"isoCode\": \"ar-001\",\n    \"title\": \"Arabic (World)\"\n}, {\n    \"isoCode\": \"ar-AE\",\n    \"title\": \"Arabic (United Arab Emirates)\"\n}, {\n    \"isoCode\": \"ar-BH\",\n    \"title\": \"Arabic (Bahrain)\"\n}, {\n    \"isoCode\": \"ar-DJ\",\n    \"title\": \"Arabic (Djibouti)\"\n}, {\n    \"isoCode\": \"ar-DZ\",\n    \"title\": \"Arabic (Algeria)\"\n}, {\n    \"isoCode\": \"ar-EG\",\n    \"title\": \"Arabic (Egypt)\"\n}, {\n    \"isoCode\": \"ar-ER\",\n    \"title\": \"Arabic (Eritrea)\"\n}, {\n    \"isoCode\": \"ar-IL\",\n    \"title\": \"Arabic (Israel)\"\n}, {\n    \"isoCode\": \"ar-IQ\",\n    \"title\": \"Arabic (Iraq)\"\n}, {\n    \"isoCode\": \"ar-JO\",\n    \"title\": \"Arabic (Jordan)\"\n}, {\n    \"isoCode\": \"ar-KM\",\n    \"title\": \"Arabic (Comoros)\"\n}, {\n    \"isoCode\": \"ar-KW\",\n    \"title\": \"Arabic (Kuwait)\"\n}, {\n    \"isoCode\": \"ar-LB\",\n    \"title\": \"Arabic (Lebanon)\"\n}, {\n    \"isoCode\": \"ar-LY\",\n    \"title\": \"Arabic (Libya)\"\n}, {\n    \"isoCode\": \"ar-MA\",\n    \"title\": \"Arabic (Morocco)\"\n}, {\n    \"isoCode\": \"ar-MR\",\n    \"title\": \"Arabic (Mauritania)\"\n}, {\n    \"isoCode\": \"ar-OM\",\n    \"title\": \"Arabic (Oman)\"\n}, {\n    \"isoCode\": \"ar-PS\",\n    \"title\": \"Arabic (Palestinian Authority)\"\n}, {\n    \"isoCode\": \"ar-QA\",\n    \"title\": \"Arabic (Qatar)\"\n}, {\n    \"isoCode\": \"ar-SA\",\n    \"title\": \"Arabic (Saudi Arabia)\"\n}, {\n    \"isoCode\": \"ar-SD\",\n    \"title\": \"Arabic (Sudan)\"\n}, {\n    \"isoCode\": \"ar-SO\",\n    \"title\": \"Arabic (Somalia)\"\n}, {\n    \"isoCode\": \"ar-SS\",\n    \"title\": \"Arabic (South Sudan)\"\n}, {\n    \"isoCode\": \"ar-SY\",\n    \"title\": \"Arabic (Syria)\"\n}, {\n    \"isoCode\": \"ar-TD\",\n    \"title\": \"Arabic (Chad)\"\n}, {\n    \"isoCode\": \"ar-TN\",\n    \"title\": \"Arabic (Tunisia)\"\n}, {\n    \"isoCode\": \"ar-YE\",\n    \"title\": \"Arabic (Yemen)\"\n}, {\n    \"isoCode\": \"arn\",\n    \"title\": \"Mapuche\"\n}, {\n    \"isoCode\": \"arn-CL\",\n    \"title\": \"Mapuche (Chile)\"\n}, {\n    \"isoCode\": \"as\",\n    \"title\": \"Assamese\"\n}, {\n    \"isoCode\": \"as-IN\",\n    \"title\": \"Assamese (India)\"\n}, {\n    \"isoCode\": \"asa\",\n    \"title\": \"Asu\"\n}, {\n    \"isoCode\": \"asa-TZ\",\n    \"title\": \"Asu (Tanzania)\"\n}, {\n    \"isoCode\": \"ast\",\n    \"title\": \"Asturian\"\n}, {\n    \"isoCode\": \"ast-ES\",\n    \"title\": \"Asturian (Spain)\"\n}, {\n    \"isoCode\": \"az\",\n    \"title\": \"Azerbaijani\"\n}, {\n    \"isoCode\": \"az-Cyrl\",\n    \"title\": \"Azerbaijani (Cyrillic)\"\n}, {\n    \"isoCode\": \"az-Cyrl-AZ\",\n    \"title\": \"Azerbaijani (Cyrillic, Azerbaijan)\"\n}, {\n    \"isoCode\": \"az-Latn\",\n    \"title\": \"Azerbaijani (Latin)\"\n}, {\n    \"isoCode\": \"az-Latn-AZ\",\n    \"title\": \"Azerbaijani (Latin, Azerbaijan)\"\n}, {\n    \"isoCode\": \"ba\",\n    \"title\": \"Bashkir\"\n}, {\n    \"isoCode\": \"ba-RU\",\n    \"title\": \"Bashkir (Russia)\"\n}, {\n    \"isoCode\": \"bas\",\n    \"title\": \"Basaa\"\n}, {\n    \"isoCode\": \"bas-CM\",\n    \"title\": \"Basaa (Cameroon)\"\n}, {\n    \"isoCode\": \"be\",\n    \"title\": \"Belarusian\"\n}, {\n    \"isoCode\": \"be-BY\",\n    \"title\": \"Belarusian (Belarus)\"\n}, {\n    \"isoCode\": \"bem\",\n    \"title\": \"Bemba\"\n}, {\n    \"isoCode\": \"bem-ZM\",\n    \"title\": \"Bemba (Zambia)\"\n}, {\n    \"isoCode\": \"bez\",\n    \"title\": \"Bena\"\n}, {\n    \"isoCode\": \"bez-TZ\",\n    \"title\": \"Bena (Tanzania)\"\n}, {\n    \"isoCode\": \"bg\",\n    \"title\": \"Bulgarian\"\n}, {\n    \"isoCode\": \"bg-BG\",\n    \"title\": \"Bulgarian (Bulgaria)\"\n}, {\n    \"isoCode\": \"bm\",\n    \"title\": \"Bamanankan\"\n}, {\n    \"isoCode\": \"bm-ML\",\n    \"title\": \"Bamanankan (Mali)\"\n}, {\n    \"isoCode\": \"bn\",\n    \"title\": \"Bangla\"\n}, {\n    \"isoCode\": \"bn-BD\",\n    \"title\": \"Bangla (Bangladesh)\"\n}, {\n    \"isoCode\": \"bn-IN\",\n    \"title\": \"Bangla (India)\"\n}, {\n    \"isoCode\": \"bo\",\n    \"title\": \"Tibetan\"\n}, {\n    \"isoCode\": \"bo-CN\",\n    \"title\": \"Tibetan (China)\"\n}, {\n    \"isoCode\": \"bo-IN\",\n    \"title\": \"Tibetan (India)\"\n}, {\n    \"isoCode\": \"br\",\n    \"title\": \"Breton\"\n}, {\n    \"isoCode\": \"br-FR\",\n    \"title\": \"Breton (France)\"\n}, {\n    \"isoCode\": \"brx\",\n    \"title\": \"Bodo\"\n}, {\n    \"isoCode\": \"brx-IN\",\n    \"title\": \"Bodo (India)\"\n}, {\n    \"isoCode\": \"bs\",\n    \"title\": \"Bosnian\"\n}, {\n    \"isoCode\": \"bs-Cyrl\",\n    \"title\": \"Bosnian (Cyrillic)\"\n}, {\n    \"isoCode\": \"bs-Cyrl-BA\",\n    \"title\": \"Bosnian (Cyrillic, Bosnia & Herzegovina)\"\n}, {\n    \"isoCode\": \"bs-Latn\",\n    \"title\": \"Bosnian (Latin)\"\n}, {\n    \"isoCode\": \"bs-Latn-BA\",\n    \"title\": \"Bosnian (Latin, Bosnia & Herzegovina)\"\n}, {\n    \"isoCode\": \"byn\",\n    \"title\": \"Blin\"\n}, {\n    \"isoCode\": \"byn-ER\",\n    \"title\": \"Blin (Eritrea)\"\n}, {\n    \"isoCode\": \"ca\",\n    \"title\": \"Catalan\"\n}, {\n    \"isoCode\": \"ca-AD\",\n    \"title\": \"Catalan (Andorra)\"\n}, {\n    \"isoCode\": \"ca-ES\",\n    \"title\": \"Catalan (Spain)\"\n}, {\n    \"isoCode\": \"ca-ES-VALENCIA\",\n    \"title\": \"Catalan (Spain, Valencian)\"\n}, {\n    \"isoCode\": \"ca-FR\",\n    \"title\": \"Catalan (France)\"\n}, {\n    \"isoCode\": \"ca-IT\",\n    \"title\": \"Catalan (Italy)\"\n}, {\n    \"isoCode\": \"ccp\",\n    \"title\": \"Chakma\"\n}, {\n    \"isoCode\": \"ccp-BD\",\n    \"title\": \"Chakma (Bangladesh)\"\n}, {\n    \"isoCode\": \"ccp-IN\",\n    \"title\": \"Chakma (India)\"\n}, {\n    \"isoCode\": \"ce\",\n    \"title\": \"Chechen\"\n}, {\n    \"isoCode\": \"ce-RU\",\n    \"title\": \"Chechen (Russia)\"\n}, {\n    \"isoCode\": \"ceb\",\n    \"title\": \"Cebuano\"\n}, {\n    \"isoCode\": \"ceb-PH\",\n    \"title\": \"Cebuano (Philippines)\"\n}, {\n    \"isoCode\": \"cgg\",\n    \"title\": \"Chiga\"\n}, {\n    \"isoCode\": \"cgg-UG\",\n    \"title\": \"Chiga (Uganda)\"\n}, {\n    \"isoCode\": \"chr\",\n    \"title\": \"Cherokee\"\n}, {\n    \"isoCode\": \"chr-US\",\n    \"title\": \"Cherokee (United States)\"\n}, {\n    \"isoCode\": \"ckb\",\n    \"title\": \"Central Kurdish\"\n}, {\n    \"isoCode\": \"ckb-IQ\",\n    \"title\": \"Central Kurdish (Iraq)\"\n}, {\n    \"isoCode\": \"ckb-IR\",\n    \"title\": \"Central Kurdish (Iran)\"\n}, {\n    \"isoCode\": \"co\",\n    \"title\": \"Corsican\"\n}, {\n    \"isoCode\": \"co-FR\",\n    \"title\": \"Corsican (France)\"\n}, {\n    \"isoCode\": \"cs\",\n    \"title\": \"Czech\"\n}, {\n    \"isoCode\": \"cs-CZ\",\n    \"title\": \"Czech (Czechia)\"\n}, {\n    \"isoCode\": \"cu\",\n    \"title\": \"Church Slavic\"\n}, {\n    \"isoCode\": \"cu-RU\",\n    \"title\": \"Church Slavic (Russia)\"\n}, {\n    \"isoCode\": \"cy\",\n    \"title\": \"Welsh\"\n}, {\n    \"isoCode\": \"cy-GB\",\n    \"title\": \"Welsh (United Kingdom)\"\n}, {\n    \"isoCode\": \"da\",\n    \"title\": \"Danish\"\n}, {\n    \"isoCode\": \"da-DK\",\n    \"title\": \"Danish (Denmark)\"\n}, {\n    \"isoCode\": \"da-GL\",\n    \"title\": \"Danish (Greenland)\"\n}, {\n    \"isoCode\": \"dav\",\n    \"title\": \"Taita\"\n}, {\n    \"isoCode\": \"dav-KE\",\n    \"title\": \"Taita (Kenya)\"\n}, {\n    \"isoCode\": \"de\",\n    \"title\": \"German\"\n}, {\n    \"isoCode\": \"de-AT\",\n    \"title\": \"German (Austria)\"\n}, {\n    \"isoCode\": \"de-BE\",\n    \"title\": \"German (Belgium)\"\n}, {\n    \"isoCode\": \"de-CH\",\n    \"title\": \"German (Switzerland)\"\n}, {\n    \"isoCode\": \"de-DE\",\n    \"title\": \"German (Germany)\"\n}, {\n    \"isoCode\": \"de-IT\",\n    \"title\": \"German (Italy)\"\n}, {\n    \"isoCode\": \"de-LI\",\n    \"title\": \"German (Liechtenstein)\"\n}, {\n    \"isoCode\": \"de-LU\",\n    \"title\": \"German (Luxembourg)\"\n}, {\n    \"isoCode\": \"dje\",\n    \"title\": \"Zarma\"\n}, {\n    \"isoCode\": \"dje-NE\",\n    \"title\": \"Zarma (Niger)\"\n}, {\n    \"isoCode\": \"dsb\",\n    \"title\": \"Lower Sorbian\"\n}, {\n    \"isoCode\": \"dsb-DE\",\n    \"title\": \"Lower Sorbian (Germany)\"\n}, {\n    \"isoCode\": \"dua\",\n    \"title\": \"Duala\"\n}, {\n    \"isoCode\": \"dua-CM\",\n    \"title\": \"Duala (Cameroon)\"\n}, {\n    \"isoCode\": \"dv\",\n    \"title\": \"Divehi\"\n}, {\n    \"isoCode\": \"dv-MV\",\n    \"title\": \"Divehi (Maldives)\"\n}, {\n    \"isoCode\": \"dyo\",\n    \"title\": \"Jola-Fonyi\"\n}, {\n    \"isoCode\": \"dyo-SN\",\n    \"title\": \"Jola-Fonyi (Senegal)\"\n}, {\n    \"isoCode\": \"dz\",\n    \"title\": \"Dzongkha\"\n}, {\n    \"isoCode\": \"dz-BT\",\n    \"title\": \"Dzongkha (Bhutan)\"\n}, {\n    \"isoCode\": \"ebu\",\n    \"title\": \"Embu\"\n}, {\n    \"isoCode\": \"ebu-KE\",\n    \"title\": \"Embu (Kenya)\"\n}, {\n    \"isoCode\": \"ee\",\n    \"title\": \"Ewe\"\n}, {\n    \"isoCode\": \"ee-GH\",\n    \"title\": \"Ewe (Ghana)\"\n}, {\n    \"isoCode\": \"ee-TG\",\n    \"title\": \"Ewe (Togo)\"\n}, {\n    \"isoCode\": \"el\",\n    \"title\": \"Greek\"\n}, {\n    \"isoCode\": \"el-CY\",\n    \"title\": \"Greek (Cyprus)\"\n}, {\n    \"isoCode\": \"el-GR\",\n    \"title\": \"Greek (Greece)\"\n}, {\n    \"isoCode\": \"en\",\n    \"title\": \"English\"\n}, {\n    \"isoCode\": \"en-001\",\n    \"title\": \"English (World)\"\n}, {\n    \"isoCode\": \"en-150\",\n    \"title\": \"English (Europe)\"\n}, {\n    \"isoCode\": \"en-AE\",\n    \"title\": \"English (United Arab Emirates)\"\n}, {\n    \"isoCode\": \"en-AG\",\n    \"title\": \"English (Antigua & Barbuda)\"\n}, {\n    \"isoCode\": \"en-AI\",\n    \"title\": \"English (Anguilla)\"\n}, {\n    \"isoCode\": \"en-AS\",\n    \"title\": \"English (American Samoa)\"\n}, {\n    \"isoCode\": \"en-AT\",\n    \"title\": \"English (Austria)\"\n}, {\n    \"isoCode\": \"en-AU\",\n    \"title\": \"English (Australia)\"\n}, {\n    \"isoCode\": \"en-BB\",\n    \"title\": \"English (Barbados)\"\n}, {\n    \"isoCode\": \"en-BE\",\n    \"title\": \"English (Belgium)\"\n}, {\n    \"isoCode\": \"en-BI\",\n    \"title\": \"English (Burundi)\"\n}, {\n    \"isoCode\": \"en-BM\",\n    \"title\": \"English (Bermuda)\"\n}, {\n    \"isoCode\": \"en-BS\",\n    \"title\": \"English (Bahamas)\"\n}, {\n    \"isoCode\": \"en-BW\",\n    \"title\": \"English (Botswana)\"\n}, {\n    \"isoCode\": \"en-BZ\",\n    \"title\": \"English (Belize)\"\n}, {\n    \"isoCode\": \"en-CA\",\n    \"title\": \"English (Canada)\"\n}, {\n    \"isoCode\": \"en-CC\",\n    \"title\": \"English (Cocos [Keeling] Islands)\"\n}, {\n    \"isoCode\": \"en-CH\",\n    \"title\": \"English (Switzerland)\"\n}, {\n    \"isoCode\": \"en-CK\",\n    \"title\": \"English (Cook Islands)\"\n}, {\n    \"isoCode\": \"en-CM\",\n    \"title\": \"English (Cameroon)\"\n}, {\n    \"isoCode\": \"en-CX\",\n    \"title\": \"English (Christmas Island)\"\n}, {\n    \"isoCode\": \"en-CY\",\n    \"title\": \"English (Cyprus)\"\n}, {\n    \"isoCode\": \"en-DE\",\n    \"title\": \"English (Germany)\"\n}, {\n    \"isoCode\": \"en-DK\",\n    \"title\": \"English (Denmark)\"\n}, {\n    \"isoCode\": \"en-DM\",\n    \"title\": \"English (Dominica)\"\n}, {\n    \"isoCode\": \"en-ER\",\n    \"title\": \"English (Eritrea)\"\n}, {\n    \"isoCode\": \"en-FI\",\n    \"title\": \"English (Finland)\"\n}, {\n    \"isoCode\": \"en-FJ\",\n    \"title\": \"English (Fiji)\"\n}, {\n    \"isoCode\": \"en-FK\",\n    \"title\": \"English (Falkland Islands)\"\n}, {\n    \"isoCode\": \"en-FM\",\n    \"title\": \"English (Micronesia)\"\n}, {\n    \"isoCode\": \"en-GB\",\n    \"title\": \"English (United Kingdom)\"\n}, {\n    \"isoCode\": \"en-GD\",\n    \"title\": \"English (Grenada)\"\n}, {\n    \"isoCode\": \"en-GG\",\n    \"title\": \"English (Guernsey)\"\n}, {\n    \"isoCode\": \"en-GH\",\n    \"title\": \"English (Ghana)\"\n}, {\n    \"isoCode\": \"en-GI\",\n    \"title\": \"English (Gibraltar)\"\n}, {\n    \"isoCode\": \"en-GM\",\n    \"title\": \"English (Gambia)\"\n}, {\n    \"isoCode\": \"en-GU\",\n    \"title\": \"English (Guam)\"\n}, {\n    \"isoCode\": \"en-GY\",\n    \"title\": \"English (Guyana)\"\n}, {\n    \"isoCode\": \"en-HK\",\n    \"title\": \"English (Hong Kong SAR)\"\n}, {\n    \"isoCode\": \"en-IE\",\n    \"title\": \"English (Ireland)\"\n}, {\n    \"isoCode\": \"en-IL\",\n    \"title\": \"English (Israel)\"\n}, {\n    \"isoCode\": \"en-IM\",\n    \"title\": \"English (Isle of Man)\"\n}, {\n    \"isoCode\": \"en-IN\",\n    \"title\": \"English (India)\"\n}, {\n    \"isoCode\": \"en-IO\",\n    \"title\": \"English (British Indian Ocean Territory)\"\n}, {\n    \"isoCode\": \"en-JE\",\n    \"title\": \"English (Jersey)\"\n}, {\n    \"isoCode\": \"en-JM\",\n    \"title\": \"English (Jamaica)\"\n}, {\n    \"isoCode\": \"en-KE\",\n    \"title\": \"English (Kenya)\"\n}, {\n    \"isoCode\": \"en-KI\",\n    \"title\": \"English (Kiribati)\"\n}, {\n    \"isoCode\": \"en-KN\",\n    \"title\": \"English (St. Kitts & Nevis)\"\n}, {\n    \"isoCode\": \"en-KY\",\n    \"title\": \"English (Cayman Islands)\"\n}, {\n    \"isoCode\": \"en-LC\",\n    \"title\": \"English (St. Lucia)\"\n}, {\n    \"isoCode\": \"en-LR\",\n    \"title\": \"English (Liberia)\"\n}, {\n    \"isoCode\": \"en-LS\",\n    \"title\": \"English (Lesotho)\"\n}, {\n    \"isoCode\": \"en-MG\",\n    \"title\": \"English (Madagascar)\"\n}, {\n    \"isoCode\": \"en-MH\",\n    \"title\": \"English (Marshall Islands)\"\n}, {\n    \"isoCode\": \"en-MO\",\n    \"title\": \"English (Macao SAR)\"\n}, {\n    \"isoCode\": \"en-MP\",\n    \"title\": \"English (Northern Mariana Islands)\"\n}, {\n    \"isoCode\": \"en-MS\",\n    \"title\": \"English (Montserrat)\"\n}, {\n    \"isoCode\": \"en-MT\",\n    \"title\": \"English (Malta)\"\n}, {\n    \"isoCode\": \"en-MU\",\n    \"title\": \"English (Mauritius)\"\n}, {\n    \"isoCode\": \"en-MW\",\n    \"title\": \"English (Malawi)\"\n}, {\n    \"isoCode\": \"en-MY\",\n    \"title\": \"English (Malaysia)\"\n}, {\n    \"isoCode\": \"en-NA\",\n    \"title\": \"English (Namibia)\"\n}, {\n    \"isoCode\": \"en-NF\",\n    \"title\": \"English (Norfolk Island)\"\n}, {\n    \"isoCode\": \"en-NG\",\n    \"title\": \"English (Nigeria)\"\n}, {\n    \"isoCode\": \"en-NL\",\n    \"title\": \"English (Netherlands)\"\n}, {\n    \"isoCode\": \"en-NR\",\n    \"title\": \"English (Nauru)\"\n}, {\n    \"isoCode\": \"en-NU\",\n    \"title\": \"English (Niue)\"\n}, {\n    \"isoCode\": \"en-NZ\",\n    \"title\": \"English (New Zealand)\"\n}, {\n    \"isoCode\": \"en-PG\",\n    \"title\": \"English (Papua New Guinea)\"\n}, {\n    \"isoCode\": \"en-PH\",\n    \"title\": \"English (Philippines)\"\n}, {\n    \"isoCode\": \"en-PK\",\n    \"title\": \"English (Pakistan)\"\n}, {\n    \"isoCode\": \"en-PN\",\n    \"title\": \"English (Pitcairn Islands)\"\n}, {\n    \"isoCode\": \"en-PR\",\n    \"title\": \"English (Puerto Rico)\"\n}, {\n    \"isoCode\": \"en-PW\",\n    \"title\": \"English (Palau)\"\n}, {\n    \"isoCode\": \"en-RW\",\n    \"title\": \"English (Rwanda)\"\n}, {\n    \"isoCode\": \"en-SB\",\n    \"title\": \"English (Solomon Islands)\"\n}, {\n    \"isoCode\": \"en-SC\",\n    \"title\": \"English (Seychelles)\"\n}, {\n    \"isoCode\": \"en-SD\",\n    \"title\": \"English (Sudan)\"\n}, {\n    \"isoCode\": \"en-SE\",\n    \"title\": \"English (Sweden)\"\n}, {\n    \"isoCode\": \"en-SG\",\n    \"title\": \"English (Singapore)\"\n}, {\n    \"isoCode\": \"en-SH\",\n    \"title\": \"English (St Helena, Ascension, Tristan da Cunha)\"\n}, {\n    \"isoCode\": \"en-SI\",\n    \"title\": \"English (Slovenia)\"\n}, {\n    \"isoCode\": \"en-SL\",\n    \"title\": \"English (Sierra Leone)\"\n}, {\n    \"isoCode\": \"en-SS\",\n    \"title\": \"English (South Sudan)\"\n}, {\n    \"isoCode\": \"en-SX\",\n    \"title\": \"English (Sint Maarten)\"\n}, {\n    \"isoCode\": \"en-SZ\",\n    \"title\": \"English (Eswatini)\"\n}, {\n    \"isoCode\": \"en-TC\",\n    \"title\": \"English (Turks & Caicos Islands)\"\n}, {\n    \"isoCode\": \"en-TK\",\n    \"title\": \"English (Tokelau)\"\n}, {\n    \"isoCode\": \"en-TO\",\n    \"title\": \"English (Tonga)\"\n}, {\n    \"isoCode\": \"en-TT\",\n    \"title\": \"English (Trinidad & Tobago)\"\n}, {\n    \"isoCode\": \"en-TV\",\n    \"title\": \"English (Tuvalu)\"\n}, {\n    \"isoCode\": \"en-TZ\",\n    \"title\": \"English (Tanzania)\"\n}, {\n    \"isoCode\": \"en-UG\",\n    \"title\": \"English (Uganda)\"\n}, {\n    \"isoCode\": \"en-UM\",\n    \"title\": \"English (U.S. Outlying Islands)\"\n}, {\n    \"isoCode\": \"en-US\",\n    \"title\": \"English (United States)\"\n}, {\n    \"isoCode\": \"en-US-POSIX\",\n    \"title\": \"English (United States, Computer)\"\n}, {\n    \"isoCode\": \"en-VC\",\n    \"title\": \"English (St. Vincent & Grenadines)\"\n}, {\n    \"isoCode\": \"en-VG\",\n    \"title\": \"English (British Virgin Islands)\"\n}, {\n    \"isoCode\": \"en-VI\",\n    \"title\": \"English (U.S. Virgin Islands)\"\n}, {\n    \"isoCode\": \"en-VU\",\n    \"title\": \"English (Vanuatu)\"\n}, {\n    \"isoCode\": \"en-WS\",\n    \"title\": \"English (Samoa)\"\n}, {\n    \"isoCode\": \"en-ZA\",\n    \"title\": \"English (South Africa)\"\n}, {\n    \"isoCode\": \"en-ZM\",\n    \"title\": \"English (Zambia)\"\n}, {\n    \"isoCode\": \"en-ZW\",\n    \"title\": \"English (Zimbabwe)\"\n}, {\n    \"isoCode\": \"eo\",\n    \"title\": \"Esperanto\"\n}, {\n    \"isoCode\": \"eo-001\",\n    \"title\": \"Esperanto (World)\"\n}, {\n    \"isoCode\": \"es\",\n    \"title\": \"Spanish\"\n}, {\n    \"isoCode\": \"es-419\",\n    \"title\": \"Spanish (Latin America)\"\n}, {\n    \"isoCode\": \"es-AR\",\n    \"title\": \"Spanish (Argentina)\"\n}, {\n    \"isoCode\": \"es-BO\",\n    \"title\": \"Spanish (Bolivia)\"\n}, {\n    \"isoCode\": \"es-BR\",\n    \"title\": \"Spanish (Brazil)\"\n}, {\n    \"isoCode\": \"es-BZ\",\n    \"title\": \"Spanish (Belize)\"\n}, {\n    \"isoCode\": \"es-CL\",\n    \"title\": \"Spanish (Chile)\"\n}, {\n    \"isoCode\": \"es-CO\",\n    \"title\": \"Spanish (Colombia)\"\n}, {\n    \"isoCode\": \"es-CR\",\n    \"title\": \"Spanish (Costa Rica)\"\n}, {\n    \"isoCode\": \"es-CU\",\n    \"title\": \"Spanish (Cuba)\"\n}, {\n    \"isoCode\": \"es-DO\",\n    \"title\": \"Spanish (Dominican Republic)\"\n}, {\n    \"isoCode\": \"es-EC\",\n    \"title\": \"Spanish (Ecuador)\"\n}, {\n    \"isoCode\": \"es-ES\",\n    \"title\": \"Spanish (Spain)\"\n}, {\n    \"isoCode\": \"es-GQ\",\n    \"title\": \"Spanish (Equatorial Guinea)\"\n}, {\n    \"isoCode\": \"es-GT\",\n    \"title\": \"Spanish (Guatemala)\"\n}, {\n    \"isoCode\": \"es-HN\",\n    \"title\": \"Spanish (Honduras)\"\n}, {\n    \"isoCode\": \"es-MX\",\n    \"title\": \"Spanish (Mexico)\"\n}, {\n    \"isoCode\": \"es-NI\",\n    \"title\": \"Spanish (Nicaragua)\"\n}, {\n    \"isoCode\": \"es-PA\",\n    \"title\": \"Spanish (Panama)\"\n}, {\n    \"isoCode\": \"es-PE\",\n    \"title\": \"Spanish (Peru)\"\n}, {\n    \"isoCode\": \"es-PH\",\n    \"title\": \"Spanish (Philippines)\"\n}, {\n    \"isoCode\": \"es-PR\",\n    \"title\": \"Spanish (Puerto Rico)\"\n}, {\n    \"isoCode\": \"es-PY\",\n    \"title\": \"Spanish (Paraguay)\"\n}, {\n    \"isoCode\": \"es-SV\",\n    \"title\": \"Spanish (El Salvador)\"\n}, {\n    \"isoCode\": \"es-US\",\n    \"title\": \"Spanish (United States)\"\n}, {\n    \"isoCode\": \"es-UY\",\n    \"title\": \"Spanish (Uruguay)\"\n}, {\n    \"isoCode\": \"es-VE\",\n    \"title\": \"Spanish (Venezuela)\"\n}, {\n    \"isoCode\": \"et\",\n    \"title\": \"Estonian\"\n}, {\n    \"isoCode\": \"et-EE\",\n    \"title\": \"Estonian (Estonia)\"\n}, {\n    \"isoCode\": \"eu\",\n    \"title\": \"Basque\"\n}, {\n    \"isoCode\": \"eu-ES\",\n    \"title\": \"Basque (Spain)\"\n}, {\n    \"isoCode\": \"ewo\",\n    \"title\": \"Ewondo\"\n}, {\n    \"isoCode\": \"ewo-CM\",\n    \"title\": \"Ewondo (Cameroon)\"\n}, {\n    \"isoCode\": \"fa\",\n    \"title\": \"Persian\"\n}, {\n    \"isoCode\": \"fa-AF\",\n    \"title\": \"Persian (Afghanistan)\"\n}, {\n    \"isoCode\": \"fa-IR\",\n    \"title\": \"Persian (Iran)\"\n}, {\n    \"isoCode\": \"ff\",\n    \"title\": \"Fulah\"\n}, {\n    \"isoCode\": \"ff-Latn\",\n    \"title\": \"Fulah (Latin)\"\n}, {\n    \"isoCode\": \"ff-Latn-BF\",\n    \"title\": \"Fulah (Latin, Burkina Faso)\"\n}, {\n    \"isoCode\": \"ff-Latn-CM\",\n    \"title\": \"Fulah (Latin, Cameroon)\"\n}, {\n    \"isoCode\": \"ff-Latn-GH\",\n    \"title\": \"Fulah (Latin, Ghana)\"\n}, {\n    \"isoCode\": \"ff-Latn-GM\",\n    \"title\": \"Fulah (Latin, Gambia)\"\n}, {\n    \"isoCode\": \"ff-Latn-GN\",\n    \"title\": \"Fulah (Latin, Guinea)\"\n}, {\n    \"isoCode\": \"ff-Latn-GW\",\n    \"title\": \"Fulah (Latin, Guinea-Bissau)\"\n}, {\n    \"isoCode\": \"ff-Latn-LR\",\n    \"title\": \"Fulah (Latin, Liberia)\"\n}, {\n    \"isoCode\": \"ff-Latn-MR\",\n    \"title\": \"Fulah (Latin, Mauritania)\"\n}, {\n    \"isoCode\": \"ff-Latn-NE\",\n    \"title\": \"Fulah (Latin, Niger)\"\n}, {\n    \"isoCode\": \"ff-Latn-NG\",\n    \"title\": \"Fulah (Latin, Nigeria)\"\n}, {\n    \"isoCode\": \"ff-Latn-SL\",\n    \"title\": \"Fulah (Latin, Sierra Leone)\"\n}, {\n    \"isoCode\": \"ff-Latn-SN\",\n    \"title\": \"Fulah (Latin, Senegal)\"\n}, {\n    \"isoCode\": \"fi\",\n    \"title\": \"Finnish\"\n}, {\n    \"isoCode\": \"fi-FI\",\n    \"title\": \"Finnish (Finland)\"\n}, {\n    \"isoCode\": \"fil\",\n    \"title\": \"Filipino\"\n}, {\n    \"isoCode\": \"fil-PH\",\n    \"title\": \"Filipino (Philippines)\"\n}, {\n    \"isoCode\": \"fo\",\n    \"title\": \"Faroese\"\n}, {\n    \"isoCode\": \"fo-DK\",\n    \"title\": \"Faroese (Denmark)\"\n}, {\n    \"isoCode\": \"fo-FO\",\n    \"title\": \"Faroese (Faroe Islands)\"\n}, {\n    \"isoCode\": \"fr\",\n    \"title\": \"French\"\n}, {\n    \"isoCode\": \"fr-BE\",\n    \"title\": \"French (Belgium)\"\n}, {\n    \"isoCode\": \"fr-BF\",\n    \"title\": \"French (Burkina Faso)\"\n}, {\n    \"isoCode\": \"fr-BI\",\n    \"title\": \"French (Burundi)\"\n}, {\n    \"isoCode\": \"fr-BJ\",\n    \"title\": \"French (Benin)\"\n}, {\n    \"isoCode\": \"fr-BL\",\n    \"title\": \"French (St. Barthélemy)\"\n}, {\n    \"isoCode\": \"fr-CA\",\n    \"title\": \"French (Canada)\"\n}, {\n    \"isoCode\": \"fr-CD\",\n    \"title\": \"French (Congo [DRC])\"\n}, {\n    \"isoCode\": \"fr-CF\",\n    \"title\": \"French (Central African Republic)\"\n}, {\n    \"isoCode\": \"fr-CG\",\n    \"title\": \"French (Congo)\"\n}, {\n    \"isoCode\": \"fr-CH\",\n    \"title\": \"French (Switzerland)\"\n}, {\n    \"isoCode\": \"fr-CI\",\n    \"title\": \"French (Côte d’Ivoire)\"\n}, {\n    \"isoCode\": \"fr-CM\",\n    \"title\": \"French (Cameroon)\"\n}, {\n    \"isoCode\": \"fr-DJ\",\n    \"title\": \"French (Djibouti)\"\n}, {\n    \"isoCode\": \"fr-DZ\",\n    \"title\": \"French (Algeria)\"\n}, {\n    \"isoCode\": \"fr-FR\",\n    \"title\": \"French (France)\"\n}, {\n    \"isoCode\": \"fr-GA\",\n    \"title\": \"French (Gabon)\"\n}, {\n    \"isoCode\": \"fr-GF\",\n    \"title\": \"French (French Guiana)\"\n}, {\n    \"isoCode\": \"fr-GN\",\n    \"title\": \"French (Guinea)\"\n}, {\n    \"isoCode\": \"fr-GP\",\n    \"title\": \"French (Guadeloupe)\"\n}, {\n    \"isoCode\": \"fr-GQ\",\n    \"title\": \"French (Equatorial Guinea)\"\n}, {\n    \"isoCode\": \"fr-HT\",\n    \"title\": \"French (Haiti)\"\n}, {\n    \"isoCode\": \"fr-KM\",\n    \"title\": \"French (Comoros)\"\n}, {\n    \"isoCode\": \"fr-LU\",\n    \"title\": \"French (Luxembourg)\"\n}, {\n    \"isoCode\": \"fr-MA\",\n    \"title\": \"French (Morocco)\"\n}, {\n    \"isoCode\": \"fr-MC\",\n    \"title\": \"French (Monaco)\"\n}, {\n    \"isoCode\": \"fr-MF\",\n    \"title\": \"French (St. Martin)\"\n}, {\n    \"isoCode\": \"fr-MG\",\n    \"title\": \"French (Madagascar)\"\n}, {\n    \"isoCode\": \"fr-ML\",\n    \"title\": \"French (Mali)\"\n}, {\n    \"isoCode\": \"fr-MQ\",\n    \"title\": \"French (Martinique)\"\n}, {\n    \"isoCode\": \"fr-MR\",\n    \"title\": \"French (Mauritania)\"\n}, {\n    \"isoCode\": \"fr-MU\",\n    \"title\": \"French (Mauritius)\"\n}, {\n    \"isoCode\": \"fr-NC\",\n    \"title\": \"French (New Caledonia)\"\n}, {\n    \"isoCode\": \"fr-NE\",\n    \"title\": \"French (Niger)\"\n}, {\n    \"isoCode\": \"fr-PF\",\n    \"title\": \"French (French Polynesia)\"\n}, {\n    \"isoCode\": \"fr-PM\",\n    \"title\": \"French (St. Pierre & Miquelon)\"\n}, {\n    \"isoCode\": \"fr-RE\",\n    \"title\": \"French (Réunion)\"\n}, {\n    \"isoCode\": \"fr-RW\",\n    \"title\": \"French (Rwanda)\"\n}, {\n    \"isoCode\": \"fr-SC\",\n    \"title\": \"French (Seychelles)\"\n}, {\n    \"isoCode\": \"fr-SN\",\n    \"title\": \"French (Senegal)\"\n}, {\n    \"isoCode\": \"fr-SY\",\n    \"title\": \"French (Syria)\"\n}, {\n    \"isoCode\": \"fr-TD\",\n    \"title\": \"French (Chad)\"\n}, {\n    \"isoCode\": \"fr-TG\",\n    \"title\": \"French (Togo)\"\n}, {\n    \"isoCode\": \"fr-TN\",\n    \"title\": \"French (Tunisia)\"\n}, {\n    \"isoCode\": \"fr-VU\",\n    \"title\": \"French (Vanuatu)\"\n}, {\n    \"isoCode\": \"fr-WF\",\n    \"title\": \"French (Wallis & Futuna)\"\n}, {\n    \"isoCode\": \"fr-YT\",\n    \"title\": \"French (Mayotte)\"\n}, {\n    \"isoCode\": \"fur\",\n    \"title\": \"Friulian\"\n}, {\n    \"isoCode\": \"fur-IT\",\n    \"title\": \"Friulian (Italy)\"\n}, {\n    \"isoCode\": \"fy\",\n    \"title\": \"Western Frisian\"\n}, {\n    \"isoCode\": \"fy-NL\",\n    \"title\": \"Western Frisian (Netherlands)\"\n}, {\n    \"isoCode\": \"ga\",\n    \"title\": \"Irish\"\n}, {\n    \"isoCode\": \"ga-IE\",\n    \"title\": \"Irish (Ireland)\"\n}, {\n    \"isoCode\": \"gd\",\n    \"title\": \"Scottish Gaelic\"\n}, {\n    \"isoCode\": \"gd-GB\",\n    \"title\": \"Scottish Gaelic (United Kingdom)\"\n}, {\n    \"isoCode\": \"gl\",\n    \"title\": \"Galician\"\n}, {\n    \"isoCode\": \"gl-ES\",\n    \"title\": \"Galician (Spain)\"\n}, {\n    \"isoCode\": \"gn\",\n    \"title\": \"Guarani\"\n}, {\n    \"isoCode\": \"gn-PY\",\n    \"title\": \"Guarani (Paraguay)\"\n}, {\n    \"isoCode\": \"gsw\",\n    \"title\": \"Swiss German\"\n}, {\n    \"isoCode\": \"gsw-CH\",\n    \"title\": \"Swiss German (Switzerland)\"\n}, {\n    \"isoCode\": \"gsw-FR\",\n    \"title\": \"Swiss German (France)\"\n}, {\n    \"isoCode\": \"gsw-LI\",\n    \"title\": \"Swiss German (Liechtenstein)\"\n}, {\n    \"isoCode\": \"gu\",\n    \"title\": \"Gujarati\"\n}, {\n    \"isoCode\": \"gu-IN\",\n    \"title\": \"Gujarati (India)\"\n}, {\n    \"isoCode\": \"guz\",\n    \"title\": \"Gusii\"\n}, {\n    \"isoCode\": \"guz-KE\",\n    \"title\": \"Gusii (Kenya)\"\n}, {\n    \"isoCode\": \"gv\",\n    \"title\": \"Manx\"\n}, {\n    \"isoCode\": \"gv-IM\",\n    \"title\": \"Manx (Isle of Man)\"\n}, {\n    \"isoCode\": \"ha\",\n    \"title\": \"Hausa\"\n}, {\n    \"isoCode\": \"ha-GH\",\n    \"title\": \"Hausa (Ghana)\"\n}, {\n    \"isoCode\": \"ha-NE\",\n    \"title\": \"Hausa (Niger)\"\n}, {\n    \"isoCode\": \"ha-NG\",\n    \"title\": \"Hausa (Nigeria)\"\n}, {\n    \"isoCode\": \"haw\",\n    \"title\": \"Hawaiian\"\n}, {\n    \"isoCode\": \"haw-US\",\n    \"title\": \"Hawaiian (United States)\"\n}, {\n    \"isoCode\": \"he\",\n    \"title\": \"Hebrew\"\n}, {\n    \"isoCode\": \"he-IL\",\n    \"title\": \"Hebrew (Israel)\"\n}, {\n    \"isoCode\": \"hi\",\n    \"title\": \"Hindi\"\n}, {\n    \"isoCode\": \"hi-IN\",\n    \"title\": \"Hindi (India)\"\n}, {\n    \"isoCode\": \"hr\",\n    \"title\": \"Croatian\"\n}, {\n    \"isoCode\": \"hr-BA\",\n    \"title\": \"Croatian (Bosnia & Herzegovina)\"\n}, {\n    \"isoCode\": \"hr-HR\",\n    \"title\": \"Croatian (Croatia)\"\n}, {\n    \"isoCode\": \"hsb\",\n    \"title\": \"Upper Sorbian\"\n}, {\n    \"isoCode\": \"hsb-DE\",\n    \"title\": \"Upper Sorbian (Germany)\"\n}, {\n    \"isoCode\": \"hu\",\n    \"title\": \"Hungarian\"\n}, {\n    \"isoCode\": \"hu-HU\",\n    \"title\": \"Hungarian (Hungary)\"\n}, {\n    \"isoCode\": \"hy\",\n    \"title\": \"Armenian\"\n}, {\n    \"isoCode\": \"hy-AM\",\n    \"title\": \"Armenian (Armenia)\"\n}, {\n    \"isoCode\": \"ia\",\n    \"title\": \"Interlingua\"\n}, {\n    \"isoCode\": \"ia-001\",\n    \"title\": \"Interlingua (World)\"\n}, {\n    \"isoCode\": \"id\",\n    \"title\": \"Indonesian\"\n}, {\n    \"isoCode\": \"id-ID\",\n    \"title\": \"Indonesian (Indonesia)\"\n}, {\n    \"isoCode\": \"ig\",\n    \"title\": \"Igbo\"\n}, {\n    \"isoCode\": \"ig-NG\",\n    \"title\": \"Igbo (Nigeria)\"\n}, {\n    \"isoCode\": \"ii\",\n    \"title\": \"Yi\"\n}, {\n    \"isoCode\": \"ii-CN\",\n    \"title\": \"Yi (China)\"\n}, {\n    \"isoCode\": \"is\",\n    \"title\": \"Icelandic\"\n}, {\n    \"isoCode\": \"is-IS\",\n    \"title\": \"Icelandic (Iceland)\"\n}, {\n    \"isoCode\": \"it\",\n    \"title\": \"Italian\"\n}, {\n    \"isoCode\": \"it-CH\",\n    \"title\": \"Italian (Switzerland)\"\n}, {\n    \"isoCode\": \"it-IT\",\n    \"title\": \"Italian (Italy)\"\n}, {\n    \"isoCode\": \"it-SM\",\n    \"title\": \"Italian (San Marino)\"\n}, {\n    \"isoCode\": \"it-VA\",\n    \"title\": \"Italian (Vatican City)\"\n}, {\n    \"isoCode\": \"iu\",\n    \"title\": \"Inuktitut\"\n}, {\n    \"isoCode\": \"iu-CA\",\n    \"title\": \"Inuktitut (Canada)\"\n}, {\n    \"isoCode\": \"iu-Latn\",\n    \"title\": \"Inuktitut (Latin)\"\n}, {\n    \"isoCode\": \"iu-Latn-CA\",\n    \"title\": \"Inuktitut (Latin, Canada)\"\n}, {\n    \"isoCode\": \"ja\",\n    \"title\": \"Japanese\"\n}, {\n    \"isoCode\": \"ja-JP\",\n    \"title\": \"Japanese (Japan)\"\n}, {\n    \"isoCode\": \"jgo\",\n    \"title\": \"Ngomba\"\n}, {\n    \"isoCode\": \"jgo-CM\",\n    \"title\": \"Ngomba (Cameroon)\"\n}, {\n    \"isoCode\": \"jmc\",\n    \"title\": \"Machame\"\n}, {\n    \"isoCode\": \"jmc-TZ\",\n    \"title\": \"Machame (Tanzania)\"\n}, {\n    \"isoCode\": \"jv\",\n    \"title\": \"Javanese\"\n}, {\n    \"isoCode\": \"jv-ID\",\n    \"title\": \"Javanese (Indonesia)\"\n}, {\n    \"isoCode\": \"ka\",\n    \"title\": \"Georgian\"\n}, {\n    \"isoCode\": \"ka-GE\",\n    \"title\": \"Georgian (Georgia)\"\n}, {\n    \"isoCode\": \"kab\",\n    \"title\": \"Kabyle\"\n}, {\n    \"isoCode\": \"kab-DZ\",\n    \"title\": \"Kabyle (Algeria)\"\n}, {\n    \"isoCode\": \"kam\",\n    \"title\": \"Kamba\"\n}, {\n    \"isoCode\": \"kam-KE\",\n    \"title\": \"Kamba (Kenya)\"\n}, {\n    \"isoCode\": \"kde\",\n    \"title\": \"Makonde\"\n}, {\n    \"isoCode\": \"kde-TZ\",\n    \"title\": \"Makonde (Tanzania)\"\n}, {\n    \"isoCode\": \"kea\",\n    \"title\": \"Kabuverdianu\"\n}, {\n    \"isoCode\": \"kea-CV\",\n    \"title\": \"Kabuverdianu (Cabo Verde)\"\n}, {\n    \"isoCode\": \"khq\",\n    \"title\": \"Koyra Chiini\"\n}, {\n    \"isoCode\": \"khq-ML\",\n    \"title\": \"Koyra Chiini (Mali)\"\n}, {\n    \"isoCode\": \"ki\",\n    \"title\": \"Kikuyu\"\n}, {\n    \"isoCode\": \"ki-KE\",\n    \"title\": \"Kikuyu (Kenya)\"\n}, {\n    \"isoCode\": \"kk\",\n    \"title\": \"Kazakh\"\n}, {\n    \"isoCode\": \"kk-KZ\",\n    \"title\": \"Kazakh (Kazakhstan)\"\n}, {\n    \"isoCode\": \"kkj\",\n    \"title\": \"Kako\"\n}, {\n    \"isoCode\": \"kkj-CM\",\n    \"title\": \"Kako (Cameroon)\"\n}, {\n    \"isoCode\": \"kl\",\n    \"title\": \"Kalaallisut\"\n}, {\n    \"isoCode\": \"kl-GL\",\n    \"title\": \"Kalaallisut (Greenland)\"\n}, {\n    \"isoCode\": \"kln\",\n    \"title\": \"Kalenjin\"\n}, {\n    \"isoCode\": \"kln-KE\",\n    \"title\": \"Kalenjin (Kenya)\"\n}, {\n    \"isoCode\": \"km\",\n    \"title\": \"Khmer\"\n}, {\n    \"isoCode\": \"km-KH\",\n    \"title\": \"Khmer (Cambodia)\"\n}, {\n    \"isoCode\": \"kn\",\n    \"title\": \"Kannada\"\n}, {\n    \"isoCode\": \"kn-IN\",\n    \"title\": \"Kannada (India)\"\n}, {\n    \"isoCode\": \"ko\",\n    \"title\": \"Korean\"\n}, {\n    \"isoCode\": \"ko-KP\",\n    \"title\": \"Korean (North Korea)\"\n}, {\n    \"isoCode\": \"ko-KR\",\n    \"title\": \"Korean (Korea)\"\n}, {\n    \"isoCode\": \"kok\",\n    \"title\": \"Konkani\"\n}, {\n    \"isoCode\": \"kok-IN\",\n    \"title\": \"Konkani (India)\"\n}, {\n    \"isoCode\": \"ks\",\n    \"title\": \"Kashmiri\"\n}, {\n    \"isoCode\": \"ks-IN\",\n    \"title\": \"Kashmiri (India)\"\n}, {\n    \"isoCode\": \"ksb\",\n    \"title\": \"Shambala\"\n}, {\n    \"isoCode\": \"ksb-TZ\",\n    \"title\": \"Shambala (Tanzania)\"\n}, {\n    \"isoCode\": \"ksf\",\n    \"title\": \"Bafia\"\n}, {\n    \"isoCode\": \"ksf-CM\",\n    \"title\": \"Bafia (Cameroon)\"\n}, {\n    \"isoCode\": \"ksh\",\n    \"title\": \"Colognian\"\n}, {\n    \"isoCode\": \"ksh-DE\",\n    \"title\": \"Colognian (Germany)\"\n}, {\n    \"isoCode\": \"kw\",\n    \"title\": \"Cornish\"\n}, {\n    \"isoCode\": \"kw-GB\",\n    \"title\": \"Cornish (United Kingdom)\"\n}, {\n    \"isoCode\": \"ky\",\n    \"title\": \"Kyrgyz\"\n}, {\n    \"isoCode\": \"ky-KG\",\n    \"title\": \"Kyrgyz (Kyrgyzstan)\"\n}, {\n    \"isoCode\": \"lag\",\n    \"title\": \"Langi\"\n}, {\n    \"isoCode\": \"lag-TZ\",\n    \"title\": \"Langi (Tanzania)\"\n}, {\n    \"isoCode\": \"lb\",\n    \"title\": \"Luxembourgish\"\n}, {\n    \"isoCode\": \"lb-LU\",\n    \"title\": \"Luxembourgish (Luxembourg)\"\n}, {\n    \"isoCode\": \"lg\",\n    \"title\": \"Ganda\"\n}, {\n    \"isoCode\": \"lg-UG\",\n    \"title\": \"Ganda (Uganda)\"\n}, {\n    \"isoCode\": \"lkt\",\n    \"title\": \"Lakota\"\n}, {\n    \"isoCode\": \"lkt-US\",\n    \"title\": \"Lakota (United States)\"\n}, {\n    \"isoCode\": \"ln\",\n    \"title\": \"Lingala\"\n}, {\n    \"isoCode\": \"ln-AO\",\n    \"title\": \"Lingala (Angola)\"\n}, {\n    \"isoCode\": \"ln-CD\",\n    \"title\": \"Lingala (Congo [DRC])\"\n}, {\n    \"isoCode\": \"ln-CF\",\n    \"title\": \"Lingala (Central African Republic)\"\n}, {\n    \"isoCode\": \"ln-CG\",\n    \"title\": \"Lingala (Congo)\"\n}, {\n    \"isoCode\": \"lo\",\n    \"title\": \"Lao\"\n}, {\n    \"isoCode\": \"lo-LA\",\n    \"title\": \"Lao (Laos)\"\n}, {\n    \"isoCode\": \"lrc\",\n    \"title\": \"Northern Luri\"\n}, {\n    \"isoCode\": \"lrc-IQ\",\n    \"title\": \"Northern Luri (Iraq)\"\n}, {\n    \"isoCode\": \"lrc-IR\",\n    \"title\": \"Northern Luri (Iran)\"\n}, {\n    \"isoCode\": \"lt\",\n    \"title\": \"Lithuanian\"\n}, {\n    \"isoCode\": \"lt-LT\",\n    \"title\": \"Lithuanian (Lithuania)\"\n}, {\n    \"isoCode\": \"lu\",\n    \"title\": \"Luba-Katanga\"\n}, {\n    \"isoCode\": \"lu-CD\",\n    \"title\": \"Luba-Katanga (Congo [DRC])\"\n}, {\n    \"isoCode\": \"luo\",\n    \"title\": \"Luo\"\n}, {\n    \"isoCode\": \"luo-KE\",\n    \"title\": \"Luo (Kenya)\"\n}, {\n    \"isoCode\": \"luy\",\n    \"title\": \"Luyia\"\n}, {\n    \"isoCode\": \"luy-KE\",\n    \"title\": \"Luyia (Kenya)\"\n}, {\n    \"isoCode\": \"lv\",\n    \"title\": \"Latvian\"\n}, {\n    \"isoCode\": \"lv-LV\",\n    \"title\": \"Latvian (Latvia)\"\n}, {\n    \"isoCode\": \"mas\",\n    \"title\": \"Masai\"\n}, {\n    \"isoCode\": \"mas-KE\",\n    \"title\": \"Masai (Kenya)\"\n}, {\n    \"isoCode\": \"mas-TZ\",\n    \"title\": \"Masai (Tanzania)\"\n}, {\n    \"isoCode\": \"mer\",\n    \"title\": \"Meru\"\n}, {\n    \"isoCode\": \"mer-KE\",\n    \"title\": \"Meru (Kenya)\"\n}, {\n    \"isoCode\": \"mfe\",\n    \"title\": \"Morisyen\"\n}, {\n    \"isoCode\": \"mfe-MU\",\n    \"title\": \"Morisyen (Mauritius)\"\n}, {\n    \"isoCode\": \"mg\",\n    \"title\": \"Malagasy\"\n}, {\n    \"isoCode\": \"mg-MG\",\n    \"title\": \"Malagasy (Madagascar)\"\n}, {\n    \"isoCode\": \"mgh\",\n    \"title\": \"Makhuwa-Meetto\"\n}, {\n    \"isoCode\": \"mgh-MZ\",\n    \"title\": \"Makhuwa-Meetto (Mozambique)\"\n}, {\n    \"isoCode\": \"mgo\",\n    \"title\": \"Metaʼ\"\n}, {\n    \"isoCode\": \"mgo-CM\",\n    \"title\": \"Metaʼ (Cameroon)\"\n}, {\n    \"isoCode\": \"mi\",\n    \"title\": \"Maori\"\n}, {\n    \"isoCode\": \"mi-NZ\",\n    \"title\": \"Maori (New Zealand)\"\n}, {\n    \"isoCode\": \"mk\",\n    \"title\": \"Macedonian\"\n}, {\n    \"isoCode\": \"mk-MK\",\n    \"title\": \"Macedonian (North Macedonia)\"\n}, {\n    \"isoCode\": \"ml\",\n    \"title\": \"Malayalam\"\n}, {\n    \"isoCode\": \"ml-IN\",\n    \"title\": \"Malayalam (India)\"\n}, {\n    \"isoCode\": \"mn\",\n    \"title\": \"Mongolian\"\n}, {\n    \"isoCode\": \"mn-MN\",\n    \"title\": \"Mongolian (Mongolia)\"\n}, {\n    \"isoCode\": \"mn-Mong\",\n    \"title\": \"Mongolian (Mongolian)\"\n}, {\n    \"isoCode\": \"mn-Mong-CN\",\n    \"title\": \"Mongolian (Mongolian, China)\"\n}, {\n    \"isoCode\": \"mn-Mong-MN\",\n    \"title\": \"Mongolian (Mongolian, Mongolia)\"\n}, {\n    \"isoCode\": \"moh\",\n    \"title\": \"Mohawk\"\n}, {\n    \"isoCode\": \"moh-CA\",\n    \"title\": \"Mohawk (Canada)\"\n}, {\n    \"isoCode\": \"mr\",\n    \"title\": \"Marathi\"\n}, {\n    \"isoCode\": \"mr-IN\",\n    \"title\": \"Marathi (India)\"\n}, {\n    \"isoCode\": \"ms\",\n    \"title\": \"Malay\"\n}, {\n    \"isoCode\": \"ms-BN\",\n    \"title\": \"Malay (Brunei)\"\n}, {\n    \"isoCode\": \"ms-MY\",\n    \"title\": \"Malay (Malaysia)\"\n}, {\n    \"isoCode\": \"ms-SG\",\n    \"title\": \"Malay (Singapore)\"\n}, {\n    \"isoCode\": \"mt\",\n    \"title\": \"Maltese\"\n}, {\n    \"isoCode\": \"mt-MT\",\n    \"title\": \"Maltese (Malta)\"\n}, {\n    \"isoCode\": \"mua\",\n    \"title\": \"Mundang\"\n}, {\n    \"isoCode\": \"mua-CM\",\n    \"title\": \"Mundang (Cameroon)\"\n}, {\n    \"isoCode\": \"my\",\n    \"title\": \"Burmese\"\n}, {\n    \"isoCode\": \"my-MM\",\n    \"title\": \"Burmese (Myanmar)\"\n}, {\n    \"isoCode\": \"mzn\",\n    \"title\": \"Mazanderani\"\n}, {\n    \"isoCode\": \"mzn-IR\",\n    \"title\": \"Mazanderani (Iran)\"\n}, {\n    \"isoCode\": \"naq\",\n    \"title\": \"Nama\"\n}, {\n    \"isoCode\": \"naq-NA\",\n    \"title\": \"Nama (Namibia)\"\n}, {\n    \"isoCode\": \"nb\",\n    \"title\": \"Norwegian Bokmål\"\n}, {\n    \"isoCode\": \"nb-NO\",\n    \"title\": \"Norwegian Bokmål (Norway)\"\n}, {\n    \"isoCode\": \"nb-SJ\",\n    \"title\": \"Norwegian Bokmål (Svalbard & Jan Mayen)\"\n}, {\n    \"isoCode\": \"nd\",\n    \"title\": \"North Ndebele\"\n}, {\n    \"isoCode\": \"nd-ZW\",\n    \"title\": \"North Ndebele (Zimbabwe)\"\n}, {\n    \"isoCode\": \"nds\",\n    \"title\": \"Low German\"\n}, {\n    \"isoCode\": \"nds-DE\",\n    \"title\": \"Low German (Germany)\"\n}, {\n    \"isoCode\": \"nds-NL\",\n    \"title\": \"Low German (Netherlands)\"\n}, {\n    \"isoCode\": \"ne\",\n    \"title\": \"Nepali\"\n}, {\n    \"isoCode\": \"ne-IN\",\n    \"title\": \"Nepali (India)\"\n}, {\n    \"isoCode\": \"ne-NP\",\n    \"title\": \"Nepali (Nepal)\"\n}, {\n    \"isoCode\": \"nl\",\n    \"title\": \"Dutch\"\n}, {\n    \"isoCode\": \"nl-AW\",\n    \"title\": \"Dutch (Aruba)\"\n}, {\n    \"isoCode\": \"nl-BE\",\n    \"title\": \"Dutch (Belgium)\"\n}, {\n    \"isoCode\": \"nl-BQ\",\n    \"title\": \"Dutch (Bonaire, Sint Eustatius and Saba)\"\n}, {\n    \"isoCode\": \"nl-CW\",\n    \"title\": \"Dutch (Curaçao)\"\n}, {\n    \"isoCode\": \"nl-NL\",\n    \"title\": \"Dutch (Netherlands)\"\n}, {\n    \"isoCode\": \"nl-SR\",\n    \"title\": \"Dutch (Suriname)\"\n}, {\n    \"isoCode\": \"nl-SX\",\n    \"title\": \"Dutch (Sint Maarten)\"\n}, {\n    \"isoCode\": \"nmg\",\n    \"title\": \"Kwasio\"\n}, {\n    \"isoCode\": \"nmg-CM\",\n    \"title\": \"Kwasio (Cameroon)\"\n}, {\n    \"isoCode\": \"nn\",\n    \"title\": \"Norwegian Nynorsk\"\n}, {\n    \"isoCode\": \"nn-NO\",\n    \"title\": \"Norwegian Nynorsk (Norway)\"\n}, {\n    \"isoCode\": \"nnh\",\n    \"title\": \"Ngiemboon\"\n}, {\n    \"isoCode\": \"nnh-CM\",\n    \"title\": \"Ngiemboon (Cameroon)\"\n}, {\n    \"isoCode\": \"nqo\",\n    \"title\": \"N’Ko\"\n}, {\n    \"isoCode\": \"nqo-GN\",\n    \"title\": \"N’Ko (Guinea)\"\n}, {\n    \"isoCode\": \"nr\",\n    \"title\": \"South Ndebele\"\n}, {\n    \"isoCode\": \"nr-ZA\",\n    \"title\": \"South Ndebele (South Africa)\"\n}, {\n    \"isoCode\": \"nso\",\n    \"title\": \"Sesotho sa Leboa\"\n}, {\n    \"isoCode\": \"nso-ZA\",\n    \"title\": \"Sesotho sa Leboa (South Africa)\"\n}, {\n    \"isoCode\": \"nus\",\n    \"title\": \"Nuer\"\n}, {\n    \"isoCode\": \"nus-SS\",\n    \"title\": \"Nuer (South Sudan)\"\n}, {\n    \"isoCode\": \"nyn\",\n    \"title\": \"Nyankole\"\n}, {\n    \"isoCode\": \"nyn-UG\",\n    \"title\": \"Nyankole (Uganda)\"\n}, {\n    \"isoCode\": \"oc\",\n    \"title\": \"Occitan\"\n}, {\n    \"isoCode\": \"oc-FR\",\n    \"title\": \"Occitan (France)\"\n}, {\n    \"isoCode\": \"om\",\n    \"title\": \"Oromo\"\n}, {\n    \"isoCode\": \"om-ET\",\n    \"title\": \"Oromo (Ethiopia)\"\n}, {\n    \"isoCode\": \"om-KE\",\n    \"title\": \"Oromo (Kenya)\"\n}, {\n    \"isoCode\": \"or\",\n    \"title\": \"Odia\"\n}, {\n    \"isoCode\": \"or-IN\",\n    \"title\": \"Odia (India)\"\n}, {\n    \"isoCode\": \"os\",\n    \"title\": \"Ossetic\"\n}, {\n    \"isoCode\": \"os-GE\",\n    \"title\": \"Ossetic (Georgia)\"\n}, {\n    \"isoCode\": \"os-RU\",\n    \"title\": \"Ossetic (Russia)\"\n}, {\n    \"isoCode\": \"pa\",\n    \"title\": \"Punjabi\"\n}, {\n    \"isoCode\": \"pa-Arab\",\n    \"title\": \"Punjabi (Arabic)\"\n}, {\n    \"isoCode\": \"pa-Arab-PK\",\n    \"title\": \"Punjabi (Arabic, Pakistan)\"\n}, {\n    \"isoCode\": \"pa-Guru\",\n    \"title\": \"Punjabi (Gurmukhi)\"\n}, {\n    \"isoCode\": \"pa-Guru-IN\",\n    \"title\": \"Punjabi (Gurmukhi, India)\"\n}, {\n    \"isoCode\": \"pl\",\n    \"title\": \"Polish\"\n}, {\n    \"isoCode\": \"pl-PL\",\n    \"title\": \"Polish (Poland)\"\n}, {\n    \"isoCode\": \"prg\",\n    \"title\": \"Prussian\"\n}, {\n    \"isoCode\": \"prg-001\",\n    \"title\": \"Prussian (World)\"\n}, {\n    \"isoCode\": \"ps\",\n    \"title\": \"Pashto\"\n}, {\n    \"isoCode\": \"ps-AF\",\n    \"title\": \"Pashto (Afghanistan)\"\n}, {\n    \"isoCode\": \"ps-PK\",\n    \"title\": \"Pashto (Pakistan)\"\n}, {\n    \"isoCode\": \"pt\",\n    \"title\": \"Portuguese\"\n}, {\n    \"isoCode\": \"pt-AO\",\n    \"title\": \"Portuguese (Angola)\"\n}, {\n    \"isoCode\": \"pt-BR\",\n    \"title\": \"Portuguese (Brazil)\"\n}, {\n    \"isoCode\": \"pt-CH\",\n    \"title\": \"Portuguese (Switzerland)\"\n}, {\n    \"isoCode\": \"pt-CV\",\n    \"title\": \"Portuguese (Cabo Verde)\"\n}, {\n    \"isoCode\": \"pt-GQ\",\n    \"title\": \"Portuguese (Equatorial Guinea)\"\n}, {\n    \"isoCode\": \"pt-GW\",\n    \"title\": \"Portuguese (Guinea-Bissau)\"\n}, {\n    \"isoCode\": \"pt-LU\",\n    \"title\": \"Portuguese (Luxembourg)\"\n}, {\n    \"isoCode\": \"pt-MO\",\n    \"title\": \"Portuguese (Macao SAR)\"\n}, {\n    \"isoCode\": \"pt-MZ\",\n    \"title\": \"Portuguese (Mozambique)\"\n}, {\n    \"isoCode\": \"pt-PT\",\n    \"title\": \"Portuguese (Portugal)\"\n}, {\n    \"isoCode\": \"pt-ST\",\n    \"title\": \"Portuguese (São Tomé & Príncipe)\"\n}, {\n    \"isoCode\": \"pt-TL\",\n    \"title\": \"Portuguese (Timor-Leste)\"\n}, {\n    \"isoCode\": \"qu\",\n    \"title\": \"Quechua\"\n}, {\n    \"isoCode\": \"qu-BO\",\n    \"title\": \"Quechua (Bolivia)\"\n}, {\n    \"isoCode\": \"qu-EC\",\n    \"title\": \"Quechua (Ecuador)\"\n}, {\n    \"isoCode\": \"qu-PE\",\n    \"title\": \"Quechua (Peru)\"\n}, {\n    \"isoCode\": \"quc\",\n    \"title\": \"Kʼicheʼ\"\n}, {\n    \"isoCode\": \"quc-GT\",\n    \"title\": \"Kʼicheʼ (Guatemala)\"\n}, {\n    \"isoCode\": \"rm\",\n    \"title\": \"Romansh\"\n}, {\n    \"isoCode\": \"rm-CH\",\n    \"title\": \"Romansh (Switzerland)\"\n}, {\n    \"isoCode\": \"rn\",\n    \"title\": \"Rundi\"\n}, {\n    \"isoCode\": \"rn-BI\",\n    \"title\": \"Rundi (Burundi)\"\n}, {\n    \"isoCode\": \"ro\",\n    \"title\": \"Romanian\"\n}, {\n    \"isoCode\": \"ro-MD\",\n    \"title\": \"Romanian (Moldova)\"\n}, {\n    \"isoCode\": \"ro-RO\",\n    \"title\": \"Romanian (Romania)\"\n}, {\n    \"isoCode\": \"rof\",\n    \"title\": \"Rombo\"\n}, {\n    \"isoCode\": \"rof-TZ\",\n    \"title\": \"Rombo (Tanzania)\"\n}, {\n    \"isoCode\": \"ru\",\n    \"title\": \"Russian\"\n}, {\n    \"isoCode\": \"ru-BY\",\n    \"title\": \"Russian (Belarus)\"\n}, {\n    \"isoCode\": \"ru-KG\",\n    \"title\": \"Russian (Kyrgyzstan)\"\n}, {\n    \"isoCode\": \"ru-KZ\",\n    \"title\": \"Russian (Kazakhstan)\"\n}, {\n    \"isoCode\": \"ru-MD\",\n    \"title\": \"Russian (Moldova)\"\n}, {\n    \"isoCode\": \"ru-RU\",\n    \"title\": \"Russian (Russia)\"\n}, {\n    \"isoCode\": \"ru-UA\",\n    \"title\": \"Russian (Ukraine)\"\n}, {\n    \"isoCode\": \"rw\",\n    \"title\": \"Kinyarwanda\"\n}, {\n    \"isoCode\": \"rw-RW\",\n    \"title\": \"Kinyarwanda (Rwanda)\"\n}, {\n    \"isoCode\": \"rwk\",\n    \"title\": \"Rwa\"\n}, {\n    \"isoCode\": \"rwk-TZ\",\n    \"title\": \"Rwa (Tanzania)\"\n}, {\n    \"isoCode\": \"sa\",\n    \"title\": \"Sanskrit\"\n}, {\n    \"isoCode\": \"sa-IN\",\n    \"title\": \"Sanskrit (India)\"\n}, {\n    \"isoCode\": \"sah\",\n    \"title\": \"Sakha\"\n}, {\n    \"isoCode\": \"sah-RU\",\n    \"title\": \"Sakha (Russia)\"\n}, {\n    \"isoCode\": \"saq\",\n    \"title\": \"Samburu\"\n}, {\n    \"isoCode\": \"saq-KE\",\n    \"title\": \"Samburu (Kenya)\"\n}, {\n    \"isoCode\": \"sbp\",\n    \"title\": \"Sangu\"\n}, {\n    \"isoCode\": \"sbp-TZ\",\n    \"title\": \"Sangu (Tanzania)\"\n}, {\n    \"isoCode\": \"sd\",\n    \"title\": \"Sindhi\"\n}, {\n    \"isoCode\": \"sd-PK\",\n    \"title\": \"Sindhi (Pakistan)\"\n}, {\n    \"isoCode\": \"se\",\n    \"title\": \"Northern Sami\"\n}, {\n    \"isoCode\": \"se-FI\",\n    \"title\": \"Northern Sami (Finland)\"\n}, {\n    \"isoCode\": \"se-NO\",\n    \"title\": \"Northern Sami (Norway)\"\n}, {\n    \"isoCode\": \"se-SE\",\n    \"title\": \"Northern Sami (Sweden)\"\n}, {\n    \"isoCode\": \"seh\",\n    \"title\": \"Sena\"\n}, {\n    \"isoCode\": \"seh-MZ\",\n    \"title\": \"Sena (Mozambique)\"\n}, {\n    \"isoCode\": \"ses\",\n    \"title\": \"Koyraboro Senni\"\n}, {\n    \"isoCode\": \"ses-ML\",\n    \"title\": \"Koyraboro Senni (Mali)\"\n}, {\n    \"isoCode\": \"sg\",\n    \"title\": \"Sango\"\n}, {\n    \"isoCode\": \"sg-CF\",\n    \"title\": \"Sango (Central African Republic)\"\n}, {\n    \"isoCode\": \"shi\",\n    \"title\": \"Tachelhit\"\n}, {\n    \"isoCode\": \"shi-Latn\",\n    \"title\": \"Tachelhit (Latin)\"\n}, {\n    \"isoCode\": \"shi-Latn-MA\",\n    \"title\": \"Tachelhit (Latin, Morocco)\"\n}, {\n    \"isoCode\": \"shi-Tfng\",\n    \"title\": \"Tachelhit (Tifinagh)\"\n}, {\n    \"isoCode\": \"shi-Tfng-MA\",\n    \"title\": \"Tachelhit (Tifinagh, Morocco)\"\n}, {\n    \"isoCode\": \"si\",\n    \"title\": \"Sinhala\"\n}, {\n    \"isoCode\": \"si-LK\",\n    \"title\": \"Sinhala (Sri Lanka)\"\n}, {\n    \"isoCode\": \"sk\",\n    \"title\": \"Slovak\"\n}, {\n    \"isoCode\": \"sk-SK\",\n    \"title\": \"Slovak (Slovakia)\"\n}, {\n    \"isoCode\": \"sl\",\n    \"title\": \"Slovenian\"\n}, {\n    \"isoCode\": \"sl-SI\",\n    \"title\": \"Slovenian (Slovenia)\"\n}, {\n    \"isoCode\": \"sma\",\n    \"title\": \"Southern Sami\"\n}, {\n    \"isoCode\": \"sma-NO\",\n    \"title\": \"Southern Sami (Norway)\"\n}, {\n    \"isoCode\": \"sma-SE\",\n    \"title\": \"Southern Sami (Sweden)\"\n}, {\n    \"isoCode\": \"smj\",\n    \"title\": \"Lule Sami\"\n}, {\n    \"isoCode\": \"smj-NO\",\n    \"title\": \"Lule Sami (Norway)\"\n}, {\n    \"isoCode\": \"smj-SE\",\n    \"title\": \"Lule Sami (Sweden)\"\n}, {\n    \"isoCode\": \"smn\",\n    \"title\": \"Inari Sami\"\n}, {\n    \"isoCode\": \"smn-FI\",\n    \"title\": \"Inari Sami (Finland)\"\n}, {\n    \"isoCode\": \"sms\",\n    \"title\": \"Skolt Sami\"\n}, {\n    \"isoCode\": \"sms-FI\",\n    \"title\": \"Skolt Sami (Finland)\"\n}, {\n    \"isoCode\": \"sn\",\n    \"title\": \"Shona\"\n}, {\n    \"isoCode\": \"sn-ZW\",\n    \"title\": \"Shona (Zimbabwe)\"\n}, {\n    \"isoCode\": \"so\",\n    \"title\": \"Somali\"\n}, {\n    \"isoCode\": \"so-DJ\",\n    \"title\": \"Somali (Djibouti)\"\n}, {\n    \"isoCode\": \"so-ET\",\n    \"title\": \"Somali (Ethiopia)\"\n}, {\n    \"isoCode\": \"so-KE\",\n    \"title\": \"Somali (Kenya)\"\n}, {\n    \"isoCode\": \"so-SO\",\n    \"title\": \"Somali (Somalia)\"\n}, {\n    \"isoCode\": \"sq\",\n    \"title\": \"Albanian\"\n}, {\n    \"isoCode\": \"sq-AL\",\n    \"title\": \"Albanian (Albania)\"\n}, {\n    \"isoCode\": \"sq-MK\",\n    \"title\": \"Albanian (North Macedonia)\"\n}, {\n    \"isoCode\": \"sq-XK\",\n    \"title\": \"Albanian (Kosovo)\"\n}, {\n    \"isoCode\": \"sr\",\n    \"title\": \"Serbian\"\n}, {\n    \"isoCode\": \"sr-Cyrl\",\n    \"title\": \"Serbian (Cyrillic)\"\n}, {\n    \"isoCode\": \"sr-Cyrl-BA\",\n    \"title\": \"Serbian (Cyrillic, Bosnia & Herzegovina)\"\n}, {\n    \"isoCode\": \"sr-Cyrl-ME\",\n    \"title\": \"Serbian (Cyrillic, Montenegro)\"\n}, {\n    \"isoCode\": \"sr-Cyrl-RS\",\n    \"title\": \"Serbian (Cyrillic, Serbia)\"\n}, {\n    \"isoCode\": \"sr-Cyrl-XK\",\n    \"title\": \"Serbian (Cyrillic, Kosovo)\"\n}, {\n    \"isoCode\": \"sr-Latn\",\n    \"title\": \"Serbian (Latin)\"\n}, {\n    \"isoCode\": \"sr-Latn-BA\",\n    \"title\": \"Serbian (Latin, Bosnia & Herzegovina)\"\n}, {\n    \"isoCode\": \"sr-Latn-ME\",\n    \"title\": \"Serbian (Latin, Montenegro)\"\n}, {\n    \"isoCode\": \"sr-Latn-RS\",\n    \"title\": \"Serbian (Latin, Serbia)\"\n}, {\n    \"isoCode\": \"sr-Latn-XK\",\n    \"title\": \"Serbian (Latin, Kosovo)\"\n}, {\n    \"isoCode\": \"ss\",\n    \"title\": \"siSwati\"\n}, {\n    \"isoCode\": \"ss-SZ\",\n    \"title\": \"siSwati (Eswatini)\"\n}, {\n    \"isoCode\": \"ss-ZA\",\n    \"title\": \"siSwati (South Africa)\"\n}, {\n    \"isoCode\": \"ssy\",\n    \"title\": \"Saho\"\n}, {\n    \"isoCode\": \"ssy-ER\",\n    \"title\": \"Saho (Eritrea)\"\n}, {\n    \"isoCode\": \"st\",\n    \"title\": \"Sesotho\"\n}, {\n    \"isoCode\": \"st-LS\",\n    \"title\": \"Sesotho (Lesotho)\"\n}, {\n    \"isoCode\": \"st-ZA\",\n    \"title\": \"Sesotho (South Africa)\"\n}, {\n    \"isoCode\": \"sv\",\n    \"title\": \"Swedish\"\n}, {\n    \"isoCode\": \"sv-AX\",\n    \"title\": \"Swedish (Åland Islands)\"\n}, {\n    \"isoCode\": \"sv-FI\",\n    \"title\": \"Swedish (Finland)\"\n}, {\n    \"isoCode\": \"sv-SE\",\n    \"title\": \"Swedish (Sweden)\"\n}, {\n    \"isoCode\": \"sw\",\n    \"title\": \"Kiswahili\"\n}, {\n    \"isoCode\": \"sw-CD\",\n    \"title\": \"Kiswahili (Congo [DRC])\"\n}, {\n    \"isoCode\": \"sw-KE\",\n    \"title\": \"Kiswahili (Kenya)\"\n}, {\n    \"isoCode\": \"sw-TZ\",\n    \"title\": \"Kiswahili (Tanzania)\"\n}, {\n    \"isoCode\": \"sw-UG\",\n    \"title\": \"Kiswahili (Uganda)\"\n}, {\n    \"isoCode\": \"syr\",\n    \"title\": \"Syriac\"\n}, {\n    \"isoCode\": \"syr-SY\",\n    \"title\": \"Syriac (Syria)\"\n}, {\n    \"isoCode\": \"ta\",\n    \"title\": \"Tamil\"\n}, {\n    \"isoCode\": \"ta-IN\",\n    \"title\": \"Tamil (India)\"\n}, {\n    \"isoCode\": \"ta-LK\",\n    \"title\": \"Tamil (Sri Lanka)\"\n}, {\n    \"isoCode\": \"ta-MY\",\n    \"title\": \"Tamil (Malaysia)\"\n}, {\n    \"isoCode\": \"ta-SG\",\n    \"title\": \"Tamil (Singapore)\"\n}, {\n    \"isoCode\": \"te\",\n    \"title\": \"Telugu\"\n}, {\n    \"isoCode\": \"te-IN\",\n    \"title\": \"Telugu (India)\"\n}, {\n    \"isoCode\": \"teo\",\n    \"title\": \"Teso\"\n}, {\n    \"isoCode\": \"teo-KE\",\n    \"title\": \"Teso (Kenya)\"\n}, {\n    \"isoCode\": \"teo-UG\",\n    \"title\": \"Teso (Uganda)\"\n}, {\n    \"isoCode\": \"tg\",\n    \"title\": \"Tajik\"\n}, {\n    \"isoCode\": \"tg-TJ\",\n    \"title\": \"Tajik (Tajikistan)\"\n}, {\n    \"isoCode\": \"th\",\n    \"title\": \"Thai\"\n}, {\n    \"isoCode\": \"th-TH\",\n    \"title\": \"Thai (Thailand)\"\n}, {\n    \"isoCode\": \"ti\",\n    \"title\": \"Tigrinya\"\n}, {\n    \"isoCode\": \"ti-ER\",\n    \"title\": \"Tigrinya (Eritrea)\"\n}, {\n    \"isoCode\": \"ti-ET\",\n    \"title\": \"Tigrinya (Ethiopia)\"\n}, {\n    \"isoCode\": \"tig\",\n    \"title\": \"Tigre\"\n}, {\n    \"isoCode\": \"tig-ER\",\n    \"title\": \"Tigre (Eritrea)\"\n}, {\n    \"isoCode\": \"tk\",\n    \"title\": \"Turkmen\"\n}, {\n    \"isoCode\": \"tk-TM\",\n    \"title\": \"Turkmen (Turkmenistan)\"\n}, {\n    \"isoCode\": \"tn\",\n    \"title\": \"Setswana\"\n}, {\n    \"isoCode\": \"tn-BW\",\n    \"title\": \"Setswana (Botswana)\"\n}, {\n    \"isoCode\": \"tn-ZA\",\n    \"title\": \"Setswana (South Africa)\"\n}, {\n    \"isoCode\": \"to\",\n    \"title\": \"Tongan\"\n}, {\n    \"isoCode\": \"to-TO\",\n    \"title\": \"Tongan (Tonga)\"\n}, {\n    \"isoCode\": \"tr\",\n    \"title\": \"Turkish\"\n}, {\n    \"isoCode\": \"tr-CY\",\n    \"title\": \"Turkish (Cyprus)\"\n}, {\n    \"isoCode\": \"tr-TR\",\n    \"title\": \"Turkish (Turkey)\"\n}, {\n    \"isoCode\": \"ts\",\n    \"title\": \"Xitsonga\"\n}, {\n    \"isoCode\": \"ts-ZA\",\n    \"title\": \"Xitsonga (South Africa)\"\n}, {\n    \"isoCode\": \"tt\",\n    \"title\": \"Tatar\"\n}, {\n    \"isoCode\": \"tt-RU\",\n    \"title\": \"Tatar (Russia)\"\n}, {\n    \"isoCode\": \"twq\",\n    \"title\": \"Tasawaq\"\n}, {\n    \"isoCode\": \"twq-NE\",\n    \"title\": \"Tasawaq (Niger)\"\n}, {\n    \"isoCode\": \"tzm\",\n    \"title\": \"Central Atlas Tamazight\"\n}, {\n    \"isoCode\": \"tzm-MA\",\n    \"title\": \"Central Atlas Tamazight (Morocco)\"\n}, {\n    \"isoCode\": \"ug\",\n    \"title\": \"Uyghur\"\n}, {\n    \"isoCode\": \"ug-CN\",\n    \"title\": \"Uyghur (China)\"\n}, {\n    \"isoCode\": \"uk\",\n    \"title\": \"Ukrainian\"\n}, {\n    \"isoCode\": \"uk-UA\",\n    \"title\": \"Ukrainian (Ukraine)\"\n}, {\n    \"isoCode\": \"ur\",\n    \"title\": \"Urdu\"\n}, {\n    \"isoCode\": \"ur-IN\",\n    \"title\": \"Urdu (India)\"\n}, {\n    \"isoCode\": \"ur-PK\",\n    \"title\": \"Urdu (Pakistan)\"\n}, {\n    \"isoCode\": \"uz\",\n    \"title\": \"Uzbek\"\n}, {\n    \"isoCode\": \"uz-Arab\",\n    \"title\": \"Uzbek (Arabic)\"\n}, {\n    \"isoCode\": \"uz-Arab-AF\",\n    \"title\": \"Uzbek (Arabic, Afghanistan)\"\n}, {\n    \"isoCode\": \"uz-Cyrl\",\n    \"title\": \"Uzbek (Cyrillic)\"\n}, {\n    \"isoCode\": \"uz-Cyrl-UZ\",\n    \"title\": \"Uzbek (Cyrillic, Uzbekistan)\"\n}, {\n    \"isoCode\": \"uz-Latn\",\n    \"title\": \"Uzbek (Latin)\"\n}, {\n    \"isoCode\": \"uz-Latn-UZ\",\n    \"title\": \"Uzbek (Latin, Uzbekistan)\"\n}, {\n    \"isoCode\": \"vai\",\n    \"title\": \"Vai\"\n}, {\n    \"isoCode\": \"vai-Latn\",\n    \"title\": \"Vai (Latin)\"\n}, {\n    \"isoCode\": \"vai-Latn-LR\",\n    \"title\": \"Vai (Latin, Liberia)\"\n}, {\n    \"isoCode\": \"vai-Vaii\",\n    \"title\": \"Vai (Vai)\"\n}, {\n    \"isoCode\": \"vai-Vaii-LR\",\n    \"title\": \"Vai (Vai, Liberia)\"\n}, {\n    \"isoCode\": \"ve\",\n    \"title\": \"Venda\"\n}, {\n    \"isoCode\": \"ve-ZA\",\n    \"title\": \"Venda (South Africa)\"\n}, {\n    \"isoCode\": \"vi\",\n    \"title\": \"Vietnamese\"\n}, {\n    \"isoCode\": \"vi-VN\",\n    \"title\": \"Vietnamese (Vietnam)\"\n}, {\n    \"isoCode\": \"vo\",\n    \"title\": \"Volapük\"\n}, {\n    \"isoCode\": \"vo-001\",\n    \"title\": \"Volapük (World)\"\n}, {\n    \"isoCode\": \"vun\",\n    \"title\": \"Vunjo\"\n}, {\n    \"isoCode\": \"vun-TZ\",\n    \"title\": \"Vunjo (Tanzania)\"\n}, {\n    \"isoCode\": \"wae\",\n    \"title\": \"Walser\"\n}, {\n    \"isoCode\": \"wae-CH\",\n    \"title\": \"Walser (Switzerland)\"\n}, {\n    \"isoCode\": \"wal\",\n    \"title\": \"Wolaytta\"\n}, {\n    \"isoCode\": \"wal-ET\",\n    \"title\": \"Wolaytta (Ethiopia)\"\n}, {\n    \"isoCode\": \"wo\",\n    \"title\": \"Wolof\"\n}, {\n    \"isoCode\": \"wo-SN\",\n    \"title\": \"Wolof (Senegal)\"\n}, {\n    \"isoCode\": \"xh\",\n    \"title\": \"isiXhosa\"\n}, {\n    \"isoCode\": \"xh-ZA\",\n    \"title\": \"isiXhosa (South Africa)\"\n}, {\n    \"isoCode\": \"xog\",\n    \"title\": \"Soga\"\n}, {\n    \"isoCode\": \"xog-UG\",\n    \"title\": \"Soga (Uganda)\"\n}, {\n    \"isoCode\": \"yav\",\n    \"title\": \"Yangben\"\n}, {\n    \"isoCode\": \"yav-CM\",\n    \"title\": \"Yangben (Cameroon)\"\n}, {\n    \"isoCode\": \"yi\",\n    \"title\": \"Yiddish\"\n}, {\n    \"isoCode\": \"yi-001\",\n    \"title\": \"Yiddish (World)\"\n}, {\n    \"isoCode\": \"yo\",\n    \"title\": \"Yoruba\"\n}, {\n    \"isoCode\": \"yo-BJ\",\n    \"title\": \"Yoruba (Benin)\"\n}, {\n    \"isoCode\": \"yo-NG\",\n    \"title\": \"Yoruba (Nigeria)\"\n}, {\n    \"isoCode\": \"zgh\",\n    \"title\": \"Standard Moroccan Tamazight\"\n}, {\n    \"isoCode\": \"zgh-MA\",\n    \"title\": \"Standard Moroccan Tamazight (Morocco)\"\n}, {\n    \"isoCode\": \"zh\",\n    \"title\": \"Chinese\"\n}, {\n    \"isoCode\": \"zh-Hans\",\n    \"title\": \"Chinese (Simplified)\"\n}, {\n    \"isoCode\": \"zh-Hans-CN\",\n    \"title\": \"Chinese (Simplified, China)\"\n}, {\n    \"isoCode\": \"zh-Hans-HK\",\n    \"title\": \"Chinese (Simplified, Hong Kong SAR)\"\n}, {\n    \"isoCode\": \"zh-Hans-MO\",\n    \"title\": \"Chinese (Simplified, Macao SAR)\"\n}, {\n    \"isoCode\": \"zh-Hans-SG\",\n    \"title\": \"Chinese (Simplified, Singapore)\"\n}, {\n    \"isoCode\": \"zh-Hant\",\n    \"title\": \"Chinese (Traditional)\"\n}, {\n    \"isoCode\": \"zh-Hant-HK\",\n    \"title\": \"Chinese (Traditional, Hong Kong SAR)\"\n}, {\n    \"isoCode\": \"zh-Hant-MO\",\n    \"title\": \"Chinese (Traditional, Macao SAR)\"\n}, {\n    \"isoCode\": \"zh-Hant-TW\",\n    \"title\": \"Chinese (Traditional, Taiwan)\"\n}, {\n    \"isoCode\": \"zu\",\n    \"title\": \"isiZulu\"\n}, {\n    \"isoCode\": \"zu-ZA\",\n    \"title\": \"isiZulu (South Africa)\"\n}]"
  },
  {
    "path": "MangaManager/src/Common/LoadedComicInfo/ArchiveFile.py",
    "content": "import os\nimport zipfile\n\nimport rarfile\n\n\nclass ArchiveFile:\n    \"\"\"\n    A class that provides a unified interface to read and write archive files.\n    It automatically chooses between ZipFile and RarFile based on the\n    file extension.\n    \"\"\"\n    is_cbr = False\n\n    def __init__(self, filename, mode='r', password=None):\n        self.filename = filename\n        self.mode = mode\n        self.password = password\n        self.archive = None\n\n        ext = os.path.splitext(filename)[1].lower()\n        if ext in ('.cbz', '.zip'):\n            self.archive = zipfile.ZipFile(filename, mode)\n        elif ext in ('.cbr', '.rar'):\n            self.is_cbr = True\n            self.archive = rarfile.RarFile(filename, mode)\n            if password:\n                self.archive.setpassword(password)\n        else:\n            raise ValueError('Unsupported file type: %s' % ext)\n\n    def __enter__(self):\n        return self\n\n    def __exit__(self, exc_type, exc_value, traceback):\n        if self.archive is not None:\n            self.archive.close()\n\n    def namelist(self):\n        return self.archive.namelist()\n\n    def infolist(self):\n        return self.archive.infolist()\n\n    def getinfo(self, name):\n        return self.archive.getinfo(name)\n\n    def read(self, name):\n        return self.archive.read(name)\n\n    def open(self, name):\n        # if self.is_cbr:\n        #     return self.archive.read(name)\n        return self.archive.open(name)\n\n    def extract(self, member, path=None, password=None):\n        if password:\n            self.archive.setpassword(password)\n        self.archive.extract(member, path)\n\n    def extractall(self, path=None, members=None, password=None):\n        if password:\n            self.archive.setpassword(password)\n        self.archive.extractall(path, members)\n"
  },
  {
    "path": "MangaManager/src/Common/LoadedComicInfo/CoverActions.py",
    "content": "import enum\n\n\nclass CoverActions(enum.Enum):\n    RESET = 0  # Cancel current selected action\n    REPLACE = 1\n    DELETE = 2\n    APPEND = 3"
  },
  {
    "path": "MangaManager/src/Common/LoadedComicInfo/ILoadedComicInfo.py",
    "content": "class ILoadedComicInfo:\n    \"\"\"\n        Helper class that loads the info that is required by the tools\n\n        file_path : str\n            Path of the file\n        cinfo_object : ComicInfo\n            The class where the metadata is stored\n        cover_filename : str\n            The filename of the image that gets parsed as series cover\n        has_metadata : bool\n            If false, we only need to append metadata.\n            No need to back up ComicInfo.xml because it doesn't exist\n        volume : int\n            The volume from the metadata. If not set then it tries to parse from filename\n        chapter : str\n            The volume from the metadata. If not set then it tries to parse from filename\n        \"\"\"\n\n    file_path: str\n    file_name: str\n\n    has_metadata: bool = False\n    is_cinfo_at_root: bool = False\n\n    has_changes = False\n    changed_tags = []"
  },
  {
    "path": "MangaManager/src/Common/LoadedComicInfo/LoadedComicInfo.py",
    "content": "from __future__ import annotations\n\nimport copy\nimport io\nimport logging\nimport os\nimport tempfile\nimport zipfile\nfrom typing import IO\n\nfrom common.models import ComicInfo\nfrom src.Common.errors import BadZipFile\nfrom src.Common.utils import IS_IMAGE_PATTERN, get_new_webp_name, convert_to_webp\nfrom .ArchiveFile import ArchiveFile\nfrom .CoverActions import CoverActions\nfrom .ILoadedComicInfo import ILoadedComicInfo\nfrom .LoadedFileCoverData import LoadedFileCoverData\nfrom .LoadedFileMetadata import LoadedFileMetadata\nfrom ...Settings import Settings, SettingHeading\n\nlogger = logging.getLogger(\"LoadedCInfo\")\nCOMICINFO_FILE = 'ComicInfo.xml'\nCOMICINFO_FILE_BACKUP = 'Old_ComicInfo.xml.bak'\nCOVER_NAME = \"!0000_Cover\"\nBACKCOVER_NAME = \"9999_Back\"\n_LOG_TAG_WEBP = \"Convert Webp\"\n_LOG_TAG_WRITE_META = 'Write Meta'\n_LOG_TAG_RECOMPRESSING = \"Recompressing\"\nmove_to_value = \"\"\n\n\nclass LoadedComicInfo(LoadedFileMetadata, LoadedFileCoverData, ILoadedComicInfo):\n\n    @property\n    def _logging_extra(self):\n        return {\"processed_filename\": self.file_name}\n\n    def __init__(self, path, comicinfo: ComicInfo = None, load_default_metadata=True):\n        \"\"\"\n\n        :param path:\n        :param comicinfo: The data class to be applied\n        :raises BadZipFile: The file can't be read or is not a valid zip file\n        \"\"\"\n\n        self.file_path = path or None\n        self.file_name = None if path is None else os.path.basename(path)\n        logger.debug(f\"[{'Loading File':13s}] '{self.file_name}'\")\n        self.cinfo_object = comicinfo\n        if load_default_metadata:\n            self.load_metadata()\n\n    def get_template_values(self) -> dict:\n        \"\"\"\n        Returns a dict with predefined values to fill in string templates\n        :return:\n        \"\"\"\n        return {\n            \"filename\": self.file_name,\n            \"series\": self.cinfo_object.series or \"\",\n            \"series_sort\": self.cinfo_object.series_sort or \"\",\n            \"title\": self.cinfo_object.title or \"\",\n            \"chapter\": self.cinfo_object.number or \"\",\n            \"volume\": self.cinfo_object.volume or \"\",\n            \"publisher\": self.cinfo_object.publisher or \"\"\n        }\n\n    def get_template_filename(self, input_template: str) -> str|None:\n        \"\"\"\n        Fills the provided template with the available values in the comicinfo\n        :param input_template: A string representing the input template (\"{series} - {chapter}\")\n        :return: None if there's a missing key in the template\n        \"\"\"\n        try:\n            return input_template.format_map(self.get_template_values()).replace(\"  \", \" \")\n        except KeyError as e:\n            logger.error(f\"Could not get {list(e.args)} keys when filling template values\")\n            return None\n    ###############################\n    # LOADING METHODS\n    ###############################\n\n    def load_all(self):\n        try:\n            # Fixme: skip folders\n            # Update: 05-01-23 At this point i don't remember why the fix me. I'm leaving it there.\n            self.load_cover_info()\n            with ArchiveFile(self.file_path, 'r') as self.archive:\n                if not self.cinfo_object:\n                    self._load_metadata()\n\n        except zipfile.BadZipFile:\n            logger.error(f\"[{'Loading File':13s}] Failed to read file. File is not a zip file or is broken.\",\n                         exc_info=False)\n            raise BadZipFile()\n        return self\n\n    def load_metadata(self):\n        try:\n            with ArchiveFile(self.file_path, 'r') as self.archive:\n                if not self.cinfo_object:\n                    self._load_metadata()\n        except zipfile.BadZipFile:\n            logger.error(f\"[{'Loading File':13s}] Failed to read file. File is not a zip file or is broken.\",\n                         exc_info=False)\n            raise BadZipFile()\n        return self\n\n    ###############################\n    # PROCESSING METHODS\n    ###############################\n\n    # INTERFACE METHODS\n    def write_metadata(self, auto_unmark_changes=False):\n        # print(self.cinfo_object.__dict__)\n        self.has_changes = self.cinfo_object.has_changes(self.original_cinfo_object)\n        logger.debug(f\"[{'BEGIN WRITE':13s}] Writing metadata to file '{self.file_path}'\")\n        try:\n            self._process(write_metadata=self.has_changes)\n        finally:\n            if auto_unmark_changes:\n                self.has_changes = False\n\n    def convert_to_webp(self):\n        logger.debug(f\"[{'BEGIN CONVERT':13s}] Converting to webp: '{self.file_path}'\")\n        self._process(do_convert_to_webp=True)\n\n    def _export_metadata(self) -> str:\n        return str(self.cinfo_object.to_xml())\n\n    # ACTUAL LOGIC\n    def _process(self, write_metadata=False, do_convert_to_webp=False, **_):\n        logger.info(f\"[{'PROCESSING':13s}] Processing file '{self.file_path}'\")\n        if write_metadata and not do_convert_to_webp and not self.has_metadata:\n            with zipfile.ZipFile(self.file_path, mode='a', compression=zipfile.ZIP_STORED) as zf:\n                # We finally append our new ComicInfo file\n                zf.writestr(COMICINFO_FILE, self._export_metadata())\n                logger.debug(f\"[{_LOG_TAG_WRITE_META:13s}] New ComicInfo.xml appended to the file\",\n                               extra=self._logging_extra)\n            self.has_metadata = True\n\n        # Creates a tempfile in the directory the original file is at\n        tmpfd, tmpname = tempfile.mkstemp(dir=os.path.dirname(self.file_path))\n        os.close(tmpfd)\n        has_cover_action = self.cover_action not in (CoverActions.RESET, None) or self.backcover_action not in (\n            CoverActions.RESET, None)\n        original_size = os.path.getsize(self.file_path)\n        with ArchiveFile(self.file_path, 'r') as zin:\n            initial_file_count = len(zin.namelist())\n            for s in zin.infolist():\n                if s.file_size != 0:\n                    orig_comp_type = s.compress_type\n                    break\n            with zipfile.ZipFile(tmpname, \"w\",compression=orig_comp_type) as zout:  # The temp file where changes will be saved to\n                self._recompress(zin, zout, write_metadata=write_metadata, do_convert_webp=do_convert_to_webp)\n            newfile_size = os.path.getsize(tmpname)\n\n            # If the new file is smaller than the original file, we process again with no webp conversion.\n            # Some source files have better png compression than webp\n            if original_size < newfile_size and do_convert_to_webp:\n                logger.warning(f\"[{'Processing':13s}] New converted file is bigger than original file\",\n                               extra=self._logging_extra)\n                os.remove(tmpname)\n                if not has_cover_action and not write_metadata:\n                    logger.warning(f\"[{'Processing':13s}]  ⤷ Keeping original files. No additional processing left\")\n                    return\n                logger.warning(f\"[{'Processing':13s}]  ⤷ Cover action or new metadata detected. Processing new covers without converting source to webp\")\n                with zipfile.ZipFile(tmpname, \"w\") as zout:  # The temp file where changes will be saved to\n                    self._recompress(zin, zout, write_metadata=write_metadata, do_convert_webp=False)\n\n        # Reset cover flags\n        self.cover_action = CoverActions.RESET\n        self.backcover_action = CoverActions.RESET\n\n        logger.debug(f\"[{'Processing':13s}] Data from old file copied to new file\",\n                               extra=self._logging_extra)\n        # Delete old file and rename new file to old name\n        try:\n            with ArchiveFile(self.file_path, 'r') as zin:\n                assert initial_file_count == len(zin.namelist())\n            os.remove(self.file_path)\n            os.rename(tmpname, self.file_path)\n            logger.debug(f\"[{'Processing':13s}] Successfully deleted old file and named tempfile as the old file\",\n                               extra=self._logging_extra)\n        # If we fail to delete original file we delete temp file effecively aborting the metadata update\n        except PermissionError:\n            logger.exception(f\"[{'Processing':13s}] Permission error. Aborting and clearing temp files\",\n                               extra=self._logging_extra)\n            os.remove(\n                tmpname)  # Could be moved to a 'finally'? Don't want to risk it not clearing temp files properly\n            raise\n        except FileNotFoundError:\n            try:\n                logger.exception(f\"[{'Processing':13s}] File not found. Aborting and clearing temp files\",\n                               extra=self._logging_extra)\n                os.remove(tmpname)\n            except FileNotFoundError:\n                pass\n        except Exception:\n            logger.exception(f\"[{'Processing':13s}] Unhandled exception. Create an issue so this gets investigated.\"\n                             f\" Aborting and clearing temp files\",\n                               extra=self._logging_extra)\n            os.remove(tmpname)\n            raise\n\n        self.original_cinfo_object = copy.copy(self.cinfo_object)\n        logger.info(f\"[{'Processing':13s}] Successfully recompressed file\",\n                               extra=self._logging_extra)\n\n        if (self.cover_cache or self.backcover_cache) and has_cover_action:\n            logger.info(\"[{'Processing':13s}] Updating covers\")\n            self.load_cover_info()\n\n    def _recompress(self, zin, zout, write_metadata, do_convert_webp):\n        \"\"\"\n        Given 2 input and output zipfiles copy content of one zipfile to the new one.\n        Files that matches certain criteria gets skipped and not copied over, hence deleted.\n\n        :param zin: The zipfile object of the zip that's going to be read\n        :param zout: The ZipFile object of the new zip to copy stuff to\n        :param write_metadata: Should update metadata\n        :param do_convert_webp: Should convert images before adding to new zipfile\n        :return:\n        \"\"\"\n        is_metadata_backed = False\n        # Write the new metadata once\n        if write_metadata:\n            zout.writestr(COMICINFO_FILE, self._export_metadata())\n\n            logger.debug(f\"[{_LOG_TAG_WRITE_META:13s}] New ComicInfo.xml appended to the file\")\n            # Directly backup the metadata if it's at root.\n            if self.is_cinfo_at_root:\n                if Settings().get(SettingHeading.Main, \"create_backup_comicinfo\") == 'True' and self.had_metadata_on_open:\n                    zout.writestr(f\"Old_{COMICINFO_FILE}.bak\", zin.read(COMICINFO_FILE))\n                    logger.debug(f\"[{_LOG_TAG_WRITE_META:13s}] Backup for comicinfo.xml created\")\n                is_metadata_backed = True\n            self.has_metadata = True\n\n        # Append the cover if the action is append\n        if self.cover_action == CoverActions.APPEND:\n            self._append_image(zout, self.new_cover_path, False, do_convert_webp,\n                               current_backcover_filename=self.backcover_filename)\n\n        if self.backcover_action == CoverActions.APPEND:\n            self._append_image(zout, self.new_backcover_path, True, do_convert_webp,\n                               current_backcover_filename=self.backcover_filename)\n\n        # Start iterating files.\n        total = len(zin.namelist())\n        for i, item in enumerate(zin.infolist()):\n            counter = f\"{i}/{total}\"\n            if write_metadata:\n                # Discard old backup\n                if item.filename.endswith(\n                        COMICINFO_FILE_BACKUP):  # Skip file, effectively deleting old backup\n                    logger.debug(f\"[{_LOG_TAG_WRITE_META:13s}] Skipped old backup file\")\n                    continue\n\n                if item.filename.endswith(COMICINFO_FILE):\n                    # A root-level comicinfo was backed up already. This one is likely not where it should\n                    if is_metadata_backed:\n                        logger.info(f\"[{_LOG_TAG_WRITE_META:13s}] Skipping non compliant ComicInfo.xml\")\n                        continue\n\n                    # If filename is comicinfo save as old_comicinfo.xml\n                    if Settings().get(SettingHeading.Main, \"create_backup_comicinfo\") == 'True' and self.had_metadata_on_open:\n                        zout.writestr(f\"Old_{item.filename}.bak\", zin.read(item.filename))\n                        logger.debug(f\"[{_LOG_TAG_WRITE_META:13s}] Backup for comicinfo.xml created\")\n                    # Stop accepting more comicinfo files.\n                    is_metadata_backed = True\n                    continue\n\n            # Handle Cover Changes:\n            if item.filename == self.cover_filename:\n                match self.cover_action:\n                    case None:\n                        self._move_image(zin, zout=zout, item_=item, do_convert_to_webp=do_convert_webp)\n                    case CoverActions.DELETE:\n                        logger.trace(\n                            f\"[{_LOG_TAG_RECOMPRESSING:13}] Skipping cover to effectively delete it. File: '{item.filename}'\")\n                    case CoverActions.REPLACE:\n                        with open(self.new_cover_path, \"rb\") as opened_image:\n                            opened_image_io = io.BytesIO(opened_image.read())\n                            self._move_image(zin, zout=zout, item_=item, do_convert_to_webp=do_convert_webp,\n                                             image=opened_image_io)\n                    case _:\n                        self._move_image(zin, zout=zout, item_=item, do_convert_to_webp=do_convert_webp)\n                continue\n            # Handle BackCover Change\n            elif item.filename == self.backcover_filename:\n                match self.backcover_action:\n                    case None:\n                        self._move_image(zin, zout=zout, item_=item, do_convert_to_webp=do_convert_webp)\n                    case CoverActions.DELETE:\n                        logger.trace(\n                            f\"[{_LOG_TAG_RECOMPRESSING:13}] Skipping back cover to efectively delete it. File: '{item.filename}'\")\n                    case CoverActions.REPLACE:\n                        with open(self.new_backcover_path, \"rb\") as opened_image:\n                            opened_image_io = io.BytesIO(opened_image.read())\n                            self._move_image(zin, zout=zout, item_=item, do_convert_to_webp=do_convert_webp,\n                                             image=opened_image_io)\n                    case _:\n                        self._move_image(zin, zout=zout, item_=item, do_convert_to_webp=do_convert_webp)\n                continue\n            # Copy the rest of the images as they are\n            self._move_image(zin, zout=zout, item_=item, do_convert_to_webp=do_convert_webp)\n\n    # Recompressing methods\n    @staticmethod\n    def _move_image(zin: zipfile.ZipFile, zout: zipfile.ZipFile, item_: zipfile.ZipInfo,\n                    do_convert_to_webp: bool,\n                    new_filename=None, image: IO[bytes] = None):\n        \"\"\"\n        Given an input and output ZipFile copy the passed item to the new zipfile. Also converts image to webp if set to true\n        :param zin: The input zipfile object\n        :param zout: The output zipfile where the bytes will be copied over\n        :param item_: The zipfile 'item' object\n        :param do_convert_to_webp: Should the bytes be converted to webp formate\n        :param new_filename: If a new filename is desired this should be set. Else it will use original filename\n        :param image: Bytes of the image if the data wants to be overwritten\n        :return:\n        \"\"\"\n        # Convert to webp if option enabled and file is image\n        if do_convert_to_webp and IS_IMAGE_PATTERN.match(item_.filename):\n            with zin.open(item_) as opened_image:\n                new_filename = get_new_webp_name(new_filename if new_filename is not None else item_.filename)\n                zout.writestr(new_filename, convert_to_webp(opened_image if image is None else image))\n                logger.trace(f\"[{_LOG_TAG_RECOMPRESSING:13s}] Adding converted file '{new_filename}' to new tempfile\"\n                             f\" back to the new tempfile\")\n        # Keep the rest of the files.\n        else:\n            zout.writestr(item_.filename if new_filename is None else new_filename,\n                          zin.read(item_.filename) if image is None else image.read())\n            logger.trace(f\"[{_LOG_TAG_RECOMPRESSING:13s}] Adding '{item_.filename}' back to the new tempfile\")\n\n    @staticmethod\n    def _append_image(zout, cover_path, is_backcover=False, do_convert_to_webp=False, current_backcover_filename=''):\n        \"\"\"\n            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\n\n            :param zout: The zipfile object where the image is going to be added to\n            :param cover_path: The path to the image file\n            :param is_backcover: Whether we are \"appending\" a cover or backcover\n            :param do_convert_to_webp: Whether the provided image should be converted to webp\n            :return:\n            \"\"\"\n        file_name, ext = os.path.splitext(os.path.basename(cover_path))\n        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}\"\n        logger.trace(\n            f\"[{_LOG_TAG_RECOMPRESSING:13}] Apending cover to efectively delete it. Loading '{cover_path}'\")\n\n        if do_convert_to_webp:\n            with open(cover_path, \"rb\") as opened_image:\n                opened_image_io = io.BytesIO(opened_image.read())\n                new_filename = get_new_webp_name(new_filename)\n                zout.writestr(new_filename, convert_to_webp(opened_image_io))\n                logger.trace(\n                    f\"[{_LOG_TAG_RECOMPRESSING:13s}] Adding converted file '{new_filename}' to new tempfile\")\n        else:\n            zout.write(cover_path, new_filename)\n            logger.trace(\n                f\"[{_LOG_TAG_RECOMPRESSING:13s}] Adding file '{new_filename}' to new tempfile\")\n"
  },
  {
    "path": "MangaManager/src/Common/LoadedComicInfo/LoadedFileCoverData.py",
    "content": "from __future__ import annotations\n\nimport io\nimport logging\nimport zipfile\nfrom typing import IO\n\nfrom PIL import ImageTk, Image\n\nfrom src.Common.errors import BadZipFile\nfrom src.Common.utils import obtain_cover_filename\nfrom .ArchiveFile import ArchiveFile\nfrom .CoverActions import CoverActions\nfrom .ILoadedComicInfo import ILoadedComicInfo\nfrom ...Settings import Settings, SettingHeading\n\nlogger = logging.getLogger(\"LoadedCInfo\")\nCOMICINFO_FILE = 'ComicInfo.xml'\nCOVER_NAME = \"!0000_Cover\"\nBACKCOVER_NAME = \"9999_Back\"\n_LOG_TAG_WEBP = \"Convert Webp\"\n_LOG_TAG_WRITE_META = 'Write Meta'\n_LOG_TAG_RECOMPRESSING = \"Recompressing\"\nmove_to_value = \"\"\n\n\n\n\nclass LoadedFileCoverData(ILoadedComicInfo):\n    cover_filename: str | None = None\n    cover_cache: ImageTk.PhotoImage = None\n\n    backcover_filename: str | None = None\n    backcover_cache: ImageTk.PhotoImage = None\n\n    _cover_action: CoverActions | None = None\n    # Path to the new cover selected by the user\n    _new_cover_path: str | None = None\n    new_cover_cache: ImageTk.PhotoImage | None = None\n    # Path to the new backcover selected by the user\n    _backcover_action: CoverActions | None = None\n    _new_backcover_path: str | None = None\n    new_backcover_cache: ImageTk.PhotoImage | None = None\n\n    def get_cover_cache(self, is_backcover=False) -> ImageTk.PhotoImage | None:\n        if self._cover_action is None:\n            return self.backcover_cache if is_backcover else self.cover_cache\n        else:\n            return self.new_backcover_cache if is_backcover else self.new_cover_cache\n\n    @property\n    def cover_action(self):\n        return self._cover_action\n\n    @cover_action.setter\n    def cover_action(self, value: CoverActions):\n        if value == CoverActions.RESET:\n            self._new_cover_path = None\n            self.new_cover_cache = None\n            self._cover_action = None\n        else:\n            self._cover_action = value\n            self.has_changes = True\n\n    @property\n    def backcover_action(self):\n        return self._backcover_action\n\n    @backcover_action.setter\n    def backcover_action(self, value: CoverActions):\n        if value == CoverActions.RESET:\n            self._new_backcover_path = None\n            self.new_backcover_cache = None\n            self._backcover_action = None\n        else:\n            self._backcover_action = value\n            self.has_changes = True\n\n    @property\n    def new_cover_path(self):\n        return self._new_cover_path\n\n    @new_cover_path.setter\n    def new_cover_path(self, path):\n        if path is None:\n            self._new_cover_path = None\n            self.new_cover_cache = None\n            return\n        image = Image.open(path)\n        image = image.resize((190, 260), Image.NEAREST)\n        try:\n            self.new_cover_cache = ImageTk.PhotoImage(image)\n        except RuntimeError:\n            self.new_cover_cache = None\n        self._new_cover_path = path\n\n    @property\n    def new_backcover_path(self):\n        return self._new_backcover_path\n\n    @new_backcover_path.setter\n    def new_backcover_path(self, path):\n        if path is None:\n            self._new_backcover_path = None\n            self.new_backcover_cache = None\n            return\n        image = Image.open(path)\n        image = image.resize((190, 260), Image.NEAREST)\n        try:\n            self.new_backcover_cache = ImageTk.PhotoImage(image)\n        except RuntimeError:\n            self.new_cover_cache = None\n        self._new_backcover_path = path\n\n    def load_cover_info(self, load_images=True):\n        try:\n            with ArchiveFile(self.file_path,'r') as self.archive:\n                cover_info = obtain_cover_filename(self.archive.namelist())\n                if not cover_info:\n                    return\n                self.cover_filename, self.backcover_filename = cover_info\n\n                if not self.cover_filename:\n                    logger.warning(f\"[{'CoverParsing':13s}] Couldn't parse any cover\")\n                else:\n                    logger.info(f\"[{'CoverParsing':13s}] Cover parsed as '{self.cover_filename}'\")\n                    if bool(Settings().get(SettingHeading.Main, 'cache_cover_images')):\n                        self.get_cover_image_bytes()\n\n                if not self.backcover_filename:\n                    logger.warning(f\"[{'CoverParsing':13s}] Couldn't parse any back cover\")\n                else:\n                    logger.info(f\"[{'CoverParsing':13s}] Back Cover parsed as '{self.backcover_filename}'\")\n                    if load_images:\n                        self.get_cover_image_bytes(back_cover=True)\n        except zipfile.BadZipFile:\n            logger.error(f\"[{'Loading File':13s}] Failed to read file. File is not a zip file or is broken.\",\n                         exc_info=False)\n            raise BadZipFile()\n        except Exception:\n            logger.exception(f\"Unhandled error loading cover info for file: '{self.file_name}'\")\n        return self\n\n    def get_cover_image_bytes(self, resized=False, back_cover=False) -> IO[bytes] | None:\n        \"\"\"\n        Opens the cbz and returns the bytes for the parsed cover image\n        :return:\n        \"\"\"\n        if not self.file_path or not self.cover_filename:\n            return None\n        if back_cover and not self.backcover_filename:\n            return None\n        try:\n            with ArchiveFile(self.file_path,'r') as zin:\n                img_bytes = zin.open(self.cover_filename if not back_cover else self.backcover_filename)\n                image = Image.open(img_bytes)\n                image = image.resize((190, 260), Image.NEAREST)\n                try:\n                    if not back_cover:\n                        self.cover_cache = ImageTk.PhotoImage(image)\n                    else:\n                        self.backcover_cache = ImageTk.PhotoImage(image)\n                except RuntimeError as e:\n                    print(e)  # Random patch for some error when running tests\n                    ...\n                if resized:\n                    return io.BytesIO(image.tobytes())\n            return img_bytes\n        except Exception:\n            logger.exception(f\"Error getting cover bytes. BackCover = {'True' if back_cover else 'False'} File: {self.file_name}\")"
  },
  {
    "path": "MangaManager/src/Common/LoadedComicInfo/LoadedFileMetadata.py",
    "content": "import copy\nimport logging\n\nimport rarfile\nfrom lxml.etree import XMLSyntaxError\n\nfrom common.models import ComicInfo\nfrom src.Common.LoadedComicInfo.ILoadedComicInfo import ILoadedComicInfo\nfrom src.Common.errors import MissingRarTool\n\nlogger = logging.getLogger(\"LoadedCInfo\")\nCOMICINFO_FILE = 'ComicInfo.xml'\nCOVER_NAME = \"!0000_Cover\"\nBACKCOVER_NAME = \"9999_Back\"\n_LOG_TAG_WEBP = \"Convert Webp\"\n_LOG_TAG_WRITE_META = 'Write Meta'\n_LOG_TAG_RECOMPRESSING = \"Recompressing\"\nmove_to_value = \"\"\n\n\nclass LoadedFileMetadata(ILoadedComicInfo):\n\n    _cinfo_object: ComicInfo\n    original_cinfo_object: ComicInfo\n    # Used to keep original state after being loaded for the first time. Useful to undo sesion changes\n    original_cinfo_object_before_session: ComicInfo | None = None\n    had_metadata_on_open = False\n\n    @property\n    def cinfo_object(self):\n        return self._cinfo_object\n\n    @cinfo_object.setter\n    def cinfo_object(self, value: ComicInfo):\n        self._cinfo_object = value\n\n    @property\n    def volume(self):\n        if self.cinfo_object:\n            return self.cinfo_object.volume\n\n    @property\n    def chapter(self):\n        if self.cinfo_object:\n            return self.cinfo_object.number\n\n    @volume.setter\n    def volume(self, value):\n        self.cinfo_object.volume = value\n\n    @chapter.setter\n    def chapter(self, value):\n        self.cinfo_object.number = value\n\n    def _load_metadata(self):\n\n        \"\"\"\n        Reads the metadata from the ComicInfo.xml at root level\n        :raises CorruptedComicInfo If the metadata file exists but can't be parsed\n        :return:\n        \"\"\"\n        LOG_TAG = f\"[{'Reading Meta':13s}] \"\n        try:\n            # If Comicinfo is not at root try to grab any ComicInfo.xml in the file\n            if COMICINFO_FILE not in self.archive.namelist():\n                cinfo_file = [filename.endswith(COMICINFO_FILE) for filename in self.archive.namelist()][\n                                 0] or COMICINFO_FILE\n            else:\n                cinfo_file = COMICINFO_FILE\n                self.is_cinfo_at_root = True\n            xml_string = self.archive.read(cinfo_file).decode('utf-8')\n            self.has_metadata = True\n            self.had_metadata_on_open = True\n        except KeyError:\n            xml_string = \"\"\n        except rarfile.RarCannotExec:\n            xml_string = \"\"\n            raise MissingRarTool\n        except Exception:\n            xml_string = \"\"\n\n\n        if xml_string:\n            try:\n                self.cinfo_object = ComicInfo.from_xml(xml_string)\n            except XMLSyntaxError as e:\n                logger.warning(LOG_TAG + f\"Failed to parse XML due to a syntax error:\\n{e}\")\n            except Exception:\n                logger.exception(f\"[{'Reading Meta':13s}] Unhandled error reading metadata.\"\n                                 f\" Please create an issue for further investigation\")\n                raise\n            logger.debug(LOG_TAG + \"Successful\")\n            self.original_cinfo_object_before_session = copy.copy(self.cinfo_object)\n        else:\n            self.cinfo_object = ComicInfo()\n            logger.info(LOG_TAG + \"No metadata file was found. A new file will be created\")\n        self.original_cinfo_object = copy.copy(self.cinfo_object)\n        self.original_cinfo_object_before_session = copy.copy(self.cinfo_object)\n\n    def reset_metadata(self):\n        \"\"\"\n        Returns the metadata to the first state of loaded cinfo\n        \"\"\"\n        self.cinfo_object = self.original_cinfo_object"
  },
  {
    "path": "MangaManager/src/Common/LoadedComicInfo/__init__.py",
    "content": ""
  },
  {
    "path": "MangaManager/src/Common/ResourceLoader.py",
    "content": "import os\nfrom os.path import abspath\n\nfrom pkg_resources import resource_filename\n\nres_path = abspath(resource_filename(__name__, '../../res/'))\n\n\nclass ResourceLoader:\n    \"\"\"\n        ResourceLoader loads resources from res/ folder for the application\n    \"\"\"\n\n    @staticmethod\n    def get(filename):\n        return os.path.join(res_path, filename)\n"
  },
  {
    "path": "MangaManager/src/Common/__init__.py",
    "content": "from .ResourceLoader import ResourceLoader\n"
  },
  {
    "path": "MangaManager/src/Common/errors.py",
    "content": "class NoMetadataFileFound(Exception):\n    \"\"\"\n    Exception raised when not enough data is given to create a Metadata object.\n    \"\"\"\n\n    def __init__(self, cbz_path):\n        super().__init__(f\"ComicInfo.xml not found inside '{cbz_path}'\")\n\n\nclass MangaNotFoundError(Exception):\n    \"\"\"\n    Exception raised when the manga cannot be found in the results from the provided source.\n    \"\"\"\n    def __init__(self, source, manga_title):\n        super().__init__(f'{source} did not return any results for series name \"{manga_title}\"'\n                         f'This may be due to a difference in manga series titles')\n\n\nclass EditedCinfoNotSet(RuntimeError):\n    def __init__(self, message=None):\n        super(EditedCinfoNotSet, self).__init__(message)\n\n\nclass CorruptedComicInfo(Exception):\n    \"\"\"\n    Exception raised when the attempt to recover comicinfo file fails..\n    \"\"\"\n\n    def __init__(self, cbz_path):\n        super().__init__(f'Failed to recover ComicInfo.xml data in {cbz_path}')\n\n\nclass CancelComicInfoLoad(Exception):\n    \"\"\"\n    Exception raised when the users want to stop loading comicInfo.\n    Triggered when an exception is found.\n    \"\"\"\n\n    def __init__(self):\n        super().__init__(f'Loading cancelled')\n\n\nclass CancelComicInfoSave(Exception):\n    \"\"\"\n    Exception raised when the users cancel parsing.\n    Triggered when the user wants to cancel.\n    \"\"\"\n\n    def __init__(self):\n        super().__init__(f'Saving cancelled')\n\n\nclass NoFilesSelected(Exception):\n    \"\"\"\n    Exception raised when a method that requires selected files is called and no selected files.\n    \"\"\"\n\n    def __init__(self):\n        super().__init__(f'No Files Selected')\n\n\nclass BadZipFile(Exception):\n    \"\"\"\n    Exception raise when the file is broken or is not a zip file\n    \"\"\"\n\n    def __init__(self):\n        super().__init__(f'File is broken or not a valid zip file')\n\n\nclass NoComicInfoLoaded(Exception):\n    \"\"\"\n    Exception raised when the list of LoadedComicInfo is empty.\n    \"\"\"\n\n    def __init__(self, info=\"\"):\n        super().__init__(f'No ComicInfo Loaded' + info)\n\n\nclass NoModifiedCinfo(Exception):\n    \"\"\"\n    Exception raised when a processing is attempted but there are no loaded_cinfo with it's comicinfo modified\n    \"\"\"\n\n    def __init__(self):\n        super().__init__(f'No loaded_cinfo to process')\n\n\nclass FailedBackup(RuntimeError):\n    \"\"\"\n    Exception raised when a file fails to create a backup\n    \"\"\"\n\n    def __init__(self):\n        super(FailedBackup, self).__init__()\n\nclass MissingRarTool(Exception):\n    \"\"\"Exception raised when there is no installed tool\"\"\"\n\n    def __init__(self):\n        super(MissingRarTool, self).__init__()"
  },
  {
    "path": "MangaManager/src/Common/naturalsorter.py",
    "content": "from __future__ import annotations\n\nimport pathlib\n\nfrom natsort import natsort_key\n\n\ndef decompose_path_into_components(x):\n    path_split = list(pathlib.Path(x).parts)\n    # Remove the final filename component from the path.\n    final_component = pathlib.Path(path_split.pop())\n    # Split off all the extensions.\n    suffixes = final_component.suffixes\n    stem = final_component.name.replace(''.join(suffixes), '')\n    # Remove the '.' prefix of each extension, and make that\n    # final component a list of the stem and each suffix.\n    final_component = [stem] + [x[1:] for x in suffixes]\n    # Replace the split final filename component.\n    path_split.extend(final_component)\n    return path_split\n\n\ndef natsort_key_with_path_support(x):\n    return tuple(natsort_key(s) for s in decompose_path_into_components(x))\n"
  },
  {
    "path": "MangaManager/src/Common/parser.py",
    "content": "import re\n\n\"\"\"\nRegex Patterns adapted from Kavita: https://github.com/Kareadita/Kavita\n\"\"\"\nNumber = \"\\d+(\\.\\d)?\"\nNumberRange = Number + \"(-\" + Number + \")?\"\n\nvolume_patterns = [\n    # Dance in the Vampire Bund v16-17\n    re.compile(r\"(?P<Series>.*)(\\b|_|\\s)v(?P<Volume>\\d+-?\\d+)\", re.IGNORECASE),\n    # NEEDLESS_Vol.4_-Simeon_6_v2[SugoiSugoi].rar\n    re.compile(r\"(?P<Series>.*)(\\b|_|\\s)(?!\\[)(vol\\.?)(?P<Volume>\\d+(-\\d+)?)(?!\\])\", re.IGNORECASE),\n    # Historys Strongest Disciple Kenichi_v11_c90-98.zip or Dance in the Vampire Bund v16-17\n    re.compile(r\"(?P<Series>.*)(\\b|_|\\s)(?!\\[)v(?P<Volume>\" + NumberRange + \")(?!\\])\", re.IGNORECASE),\n    # Kodomo no Jikan vol. 10, [dmntsf.net] One Piece - Digital Colored Comics Vol. 20.5-21.5 Ch. 177\n    re.compile(r\"(?P<Series>.*)(\\b|_|\\s)(vol\\.? ?)(?P<Volume>\\d+(\\.\\d)?(-\\d+)?(\\.\\d)?)\", re.IGNORECASE),\n    # Killing Bites Vol. 0001 Ch. 0001 - Galactica Scanlations (gb)\n    re.compile(r\"(vol\\.? ?)(?P<Volume>\\d+(\\.\\d)?)\", re.IGNORECASE),\n    # Tonikaku Cawaii [Volume 11].cbz\n    re.compile(r\"(volume )(?P<Volume>\\d+(\\.\\d)?)\", re.IGNORECASE),\n    # Tower Of God S01 014 (CBT) (digital).cbz\n    re.compile(r\"(?P<Series>.*)(\\b|_||\\s)(S(?P<Volume>\\d+))\", re.IGNORECASE),\n    # vol_001-1.cbz for MangaPy default naming convention\n    re.compile(r\"(vol_)(?P<Volume>\\d+(\\.\\d)?)\", re.IGNORECASE)\n]\n\nseries_patterns = [\n    # Grand Blue Dreaming - SP02\n    re.compile(r'(?P<Series>.*)(\\b|_|-|\\s)(?:sp)\\d', re.IGNORECASE),\n    # [SugoiSugoi]_NEEDLESS_Vol.2_-_Disk_The_Informant_5_[ENG].rar, Yuusha Ga Shinda! - Vol.tbd Chapter 27.001 V2 Infection ①.cbz\n    re.compile(r'^(?P<Series>.*)( |_)Vol\\.?(\\d+|tbd)', re.IGNORECASE),\n    # Mad Chimera World - Volume 005 - Chapter 026.cbz, The Duke of Death and His Black Maid - Vol. 04 Ch. 054.5 - V4 Omake\n    re.compile(r'(?P<Series>.+?)(\\s|_|-)+(?:Vol(ume|\\.)?(\\s|_|-)+\\d+)(\\s|_|-)+(?:(Ch|Chapter|Ch)\\.?)(\\s|_|-)+(?P<Chapter>\\d+)', re.IGNORECASE),\n    # Ichiban_Ushiro_no_Daimaou_v04_ch34_[VISCANS].zip, VanDread-v01-c01.zip\n    re.compile(r'(?P<Series>.*)(\\b|_)v(?P<Volume>\\d+-?\\d*)(\\s|_|-)', re.IGNORECASE),\n    # Gokukoku no Brynhildr - c001-008 (v01) [TrinityBAKumA], Black Bullet - v4 c17 [batoto]\n    re.compile(r'(?P<Series>.*)( - )(?:v|vo|c|chapters)\\d', re.IGNORECASE),\n    # Kedouin Makoto - Corpse Party Musume, Chapter 19 [Dametrans].zip\n    re.compile(r'(?P<Series>.*)(?:, Chapter )(?P<Chapter>\\d+)', re.IGNORECASE),\n    # Please Go Home, Akutsu-San! - Chapter 038.5 - Volume Announcement.cbz, My Charms Are Wasted on Kuroiwa Medaka - Ch. 37.5 - Volume Extras\n    re.compile(r'(?P<Series>.+?)(\\s|_|-)(?!Vol)(\\s|_|-)((?:Chapter)|(?:Ch\\.))(\\s|_|-)(?P<Chapter>\\d+)', re.IGNORECASE),\n    # [dmntsf.net] One Piece - Digital Colored Comics Vol. 20 Ch. 177 - 30 Million vs 81 Million.cbz\n    re.compile(r'(?P<Series>.+?):? (\\b|_|-)(vol)\\.?(\\s|-|_)?\\d+', re.IGNORECASE),\n    # [xPearse] Kyochuu Rettou Chapter 001 Volume 1 [English] [Manga] [Volume Scans]\n    re.compile(r'(?P<Series>.+?):?(\\s|\\b|_|-)Chapter(\\s|\\b|_|-)\\d+(\\s|\\b|_|-)(vol)(ume)', re.IGNORECASE),\n    # [xPearse] Kyochuu Rettou Volume 1 [English] [Manga] [Volume Scans]\n    re.compile(r'(?P<Series>.+?):? (\\b|_|-)(vol)(ume)', re.IGNORECASE),\n]\n\nchapter_patterns = [\n    # Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus)\n    re.compile(r'^(?P<Series>.+?)(?: |_)v(?P<Volume>\\d+)(?: |_)(c? ?)(?P<Chapter>(\\d+(\\.\\d)?)-?(\\d+(\\.\\d)?)?)(c? ?)', re.IGNORECASE),\n    # Batman & Robin the Teen Wonder #0\n    re.compile(r'^(?P<Series>.+?)(?:\\s|_)#(?P<Chapter>\\d+)', re.IGNORECASE),\n    # Batman 2016 - Chapter 01, Batman 2016 - Issue 01, Batman 2016 - Issue #01\n    re.compile(r'^(?P<Series>.+?)((c(hapter)?)|issue)(_|\\s)#?(?P<Chapter>(\\d+(\\.\\d)?)-?(\\d+(\\.\\d)?)?)', re.IGNORECASE),\n    # Batgirl Vol.2000 #57 (December, 2004)\n    re.compile(r'^(?P<Series>.+?)(?:vol\\.?\\d+)\\s#(?P<Chapter>\\d+)', re.IGNORECASE),\n    # Saga 001 (2012) (Digital) (Empire-Zone)\n    re.compile(r'(?P<Series>.+?)(?: |_)(c? ?)(?P<Chapter>(\\d+(\\.\\d)?)-?(\\d+(\\.\\d)?)?)\\s\\(\\d{4}', re.IGNORECASE),\n    # Historys Strongest Disciple Kenichi_v11_c90-98.zip, ...c90.5-100.5\n    re.compile(r'(\\b|_)(c|ch)(\\.?\\s?)(?P<Chapter>(\\d+(\\.\\d)?)-?(\\d+(\\.\\d)?)?)', re.IGNORECASE),\n    # Green Worldz - Chapter 027, Kimi no Koto ga Daidaidaidaidaisuki na 100-nin no Kanojo Chapter 11-10\n    re.compile(r'^(?!Vol)(?P<Series>.*)\\s?(?<!vol\\. )\\sChapter\\s(?P<Chapter>\\d+(?:\\.?[\\d-]+)?)', re.IGNORECASE),\n    # Hinowa ga CRUSH! 018 (2019) (Digital) (LuCaZ).cbz, Hinowa ga CRUSH! 018.5 (2019) (Digital) (LuCaZ).cbz\n    re.compile(r'^(?!Vol)(?P<Series>.+?)(?<!Vol)(?<!Vol\\.)\\s(\\d\\s)?(?P<Chapter>\\d+(?:\\.\\d+|-\\d+)?)(?:\\s\\(\\d{4}\\))?(\\b|_|-)', re.IGNORECASE),\n    # Tower Of God S01 014 (CBT) (digital).cbz\n    re.compile(r'(?P<Series>.*)\\sS(?P<Volume>\\d+)\\s(?P<Chapter>\\d+(?:.\\d+|-\\d+)?)', re.IGNORECASE),\n    # Vol 1 Chapter 2\n    re.compile(r'(?P<Volume>((vol|volume|v))?(\\s|_)?\\.?\\d+)(\\s|_)(Chp|Chapter)\\.?(\\s|_)?(?P<Chapter>\\d+)', re.IGNORECASE),\n]\n\ndef _parse(patterns, group, filename):\n    for pattern in patterns:\n        match = re.search(pattern, filename)\n        if match:\n            volume_number = match.group(group)\n            return volume_number\n    return \"\"\n\n\ndef parse_volume(filename: str) -> str:\n    \"\"\"Attempts to parse the Volume from a filename\"\"\"\n    return _parse(volume_patterns, \"Volume\", filename)\n\n\ndef parse_series(filename: str) -> str:\n    \"\"\"Attempts to parse the Series from a filename\"\"\"\n    return _parse(series_patterns, \"Series\", filename)\n\n\ndef parse_number(filename: str) -> str:\n    \"\"\"Attempts to parse the Number from a filename\"\"\"\n    return _parse(chapter_patterns, \"Chapter\", filename)\n"
  },
  {
    "path": "MangaManager/src/Common/progressbar.py",
    "content": "import abc\nimport logging\nimport time\nfrom string import Template\nfrom threading import Timer\n\nfrom src.Common.utils import get_elapsed_time, get_estimated_time\n\nlogger = logging.getLogger()\n\nclass RepeatedTimer(object):\n    def __init__(self, interval = 1):\n        \"\"\"\n\n        :param interval:\n        :param total:\n        \"\"\"\n        self._timer = None\n        self.interval = interval\n\n        self.is_running = False\n        self.total = -1\n        # self.start()\n        self.update_hook: set[callable] = set()\n\n    def register_callable(self, function: callable):\n        \"\"\"\n        Registers a function that will be called in the defined interval\n        :param function: The callable\n        :return:\n        \"\"\"\n        self.update_hook.add(function)\n\n    def unregister_callable(self, function: callable):\n        self.update_hook.remove(function)\n\n    def _run(self):\n        self.is_running = False\n        self.start()\n        self._call_hooks()\n\n    def _call_hooks(self):\n        for function in self.update_hook:\n            try:\n                function()\n            except Exception:\n                logger.exception(\"Exception calling the update hook\")\n\n    def start(self):\n        if not self.is_running:\n            self._timer = Timer(self.interval, self._run)\n            self._timer.start()\n            self.is_running = True\n\n    def stop(self):\n        if self._timer is not None:\n            self._timer.cancel()\n        self.is_running = False\n        self._timer = None\n\n\nclass ProgressBar(abc.ABC):\n    running = False\n    PROCESSED_TAG = \"$processed\"\n    TOTAL_TAG = \"$total\"\n    ERRORS_TAG = \"$errors\"\n    ELAPSED_TIME_TAG = \"$elapsed_time\"\n    ESTIMATED_TIME_TAG = \"$estimated_time\"\n\n    def __init__(self):\n\n        self.timer = RepeatedTimer()\n        self.timer.register_callable(self._update)\n        self.start_time = -1\n        self.processed = 0\n        self.processed_errors = 0\n        self.total = -1\n\n        self.PROCESSED_TAG = \"$processed\"\n        self.TOTAL_TAG = \"$total\"\n        self.ERRORS_TAG = \"$errors\"\n        self.ELAPSED_TIME_TAG = \"$elapsed_time\"\n        self.ESTIMATED_TIME_TAG = \"$estimated_time\"\n        self.template = Template(f\"\"\"Processed: {self.PROCESSED_TAG}/{self.TOTAL_TAG} files - {self.ERRORS_TAG} errors\nElapsed time  : {self.ELAPSED_TIME_TAG}\nEstimated time: {self.ESTIMATED_TIME_TAG}\"\"\")\n    def set_template(self,new_value:str):\n        self.template = Template(new_value)\n        self.update_progress_label()\n\n    @property\n    def label_text(self):\n        return self.template.safe_substitute(\n            processed=self.processed,\n            total=self.total,\n            errors=self.processed_errors,\n            elapsed_time=get_elapsed_time(self.start_time),\n            estimated_time=get_estimated_time(self.start_time, self.processed, self.total)\n        )\n    @property\n    def percentage(self):\n        return (self.processed / self.total) * 100\n    @abc.abstractmethod\n    def update_progress_label(self):\n        ...\n    @abc.abstractmethod\n    def _update(self):\n        ...\n\n    def start(self,total):\n        self.total = total\n        if self.total == -1:\n            raise ValueError(\"Configure the progressbar items first\")\n        self.start_time = time.time()\n        self.processed = 0\n        self.processed_errors = 0\n        # self.timer.start()\n\n    def stop(self):\n        self.timer.stop()\n        self._update()\n    def increase_processed(self):\n        if self.processed >= self.total:\n            return\n        self.processed += 1\n        self._update()\n\n    def increase_failed(self):\n        self.processed_errors += 1\n        self.increase_processed()\n\n    def reset(self):\n        self.total = self.timer.total = -1\n        self.processed = 0  # Processed items. Whether they fail or not\n        self.processed_errors = 0  # Items that failed\n        self._update()\n\n\n\n\n"
  },
  {
    "path": "MangaManager/src/Common/terminalcolors.py",
    "content": "class TerminalColors:\n    RESET =                 \"\\x1b[0m\"\n    BOLD =                  \"\\x1b[1m\"\n    CURSIVE =               \"\\x1b[3m\"\n    UNDERLINED =            \"\\x1b[4m\"\n    REVERSED_COLOR =        \"\\x1b[7\" # Reversed colors\n    REVERSED_COLOR_NORMAL = \"\\x1b[27\"\n\n    BLACK =                 \"\\x1b[30m\"\n    GREY =                  \"\\x1b[30m;1\"\n    RED =                   \"\\x1b[31m\"\n    GREEN =                 \"\\x1b[32m\"\n    YELLOW =                \"\\x1b[33m\"\n    BLUE =                  \"\\x1b[34m\"\n    PURPLE =                \"\\x1b[35m\"\n    CYAN =                  \"\\x1b[36m\"\n    WHITE =                 \"\\x1b[97m\"\n\n    LIGHT_BLACK =           \"\\x1b[90m\"\n    LIGHT_GREY =            \"\\x1b[37\"\n    LIGHT_RED =             \"\\x1b[91m\"\n    LIGHT_GREEN =           \"\\x1b[92m\"\n    LIGHT_YELLOW =          \"\\x1b[93m\"\n    LIGHT_BLUE =            \"\\x1b[94m\"\n    LIGHT_PURPLE =          \"\\x1b[95m\"\n    LIGHT_CYAN =            \"\\x1b[96m\"\n    LIGHT_WHITE =           \"\\x1b[97m\"\n\n    BG_BLACK  =             \"\\x1b[4\"\n    # BG_GREY =             \"\\x1b[4\n    BG_RED    =             \"\\x1b[41m\"\n    BG_GREEN  =             \"\\x1b[42m\"\n    BG_YELLOW =             \"\\x1b[43m\"\n    BG_BLUE   =             \"\\x1b[44m\"\n    BG_PURPLE =             \"\\x1b[45m\"\n    BG_CYAN   =             \"\\x1b[46m\"\n    BG_WHITE  =             \"\\x1b[107m\"\n\n    BG_LIGHT_BLACK =        \"\\x1b[4\"\n    BG_GREY =               \"\\x1b[100\"\n    BG_LIGHT_RED =          \"\\x1b[101m\"\n    BG_LIGHT_GREEN =        \"\\x1b[102m\"\n    BG_LIGHT_YELLOW =       \"\\x1b[103m\"\n    BG_LIGHT_BLUE =         \"\\x1b[104m\"\n    BG_LIGHT_PURPLE =       \"\\x1b[105m\"\n    BG_LIGHT_CYAN =         \"\\x1b[106m\"\n    BG_LIGHT_WHITE =        \"\\x1b[107m\"\n\n\nif __name__ == '__main__':\n    for color in TerminalColors.__dict__:\n        if not color.startswith(\"_\"):\n            print(TerminalColors.RESET + f\"{color:15s}\" + TerminalColors.__dict__[color] + \"Addsadsadasdas\")"
  },
  {
    "path": "MangaManager/src/Common/utils.py",
    "content": "import logging\nimport os\nimport re\nimport subprocess\nimport sys\nimport time\nimport urllib.request\nfrom io import BytesIO\nfrom pathlib import Path\nfrom typing import IO\n\nfrom src.Common.naturalsorter import natsort_key_with_path_support\n\n# Patterns for picking cover\nIMAGE_EXTENSIONS = ('png', 'jpg', 'jpeg', 'tiff', 'bmp', 'gif', 'webp')\ncovers_patterns = ['^!*0+.[a-z]+$', '.*cover.*.[a-z]+$']\nCOVER_PATTERN = re.compile(f\"(?i)({'|'.join(covers_patterns)})\")\ncover_r3_alt = '^!*0+1\\\\.[a-z]+$'\nALT_COVER_PATTERN = re.compile(f\"(?i)({'|'.join([cover_r3_alt])})\")\nIS_IMAGE_PATTERN = re.compile(rf\"(?i).*.(?:{'|'.join(IMAGE_EXTENSIONS)})$\")\n\nlogger = logging.getLogger()\nfrom PIL import Image\n\ntry:\n    from anytree import Node, RenderTree\nexcept ImportError:\n    logger.exception(\"Failed to import anytree. Some cli functionality might break. Make sure all requirements are installed\")\n\ndef remove_text_inside_brackets(text, brackets=\"()[]\"):\n    count = [0] * (len(brackets) // 2)  # count open/close brackets\n    saved_chars = []\n    for character in text:\n        for i, b in enumerate(brackets):\n            if character == b:  # found bracket\n                kind, is_close = divmod(i, 2)\n                count[kind] += (-1) ** is_close  # `+1`: open, `-1`: close\n                if count[kind] < 0:  # unbalanced bracket\n                    count[kind] = 0  # keep it\n                else:  # found bracket to remove\n                    break\n        else:  # character is not a [balanced] bracket\n            if not any(count):  # outside brackets\n                saved_chars.append(character)\n    return (''.join(saved_chars)).strip()\n\nimport unicodedata\n\ndef normalize_filename(filename):\n    # Normalize the filename using the NFC form\n    normalized_filename = unicodedata.normalize('NFC', filename)\n    # Replace all non-ASCII characters with their ASCII equivalents\n    ascii_filename = normalized_filename.encode('ascii', 'ignore').decode('ascii')\n    return ascii_filename\n\ndef clean_filename(sourcestring, removestring=\" %:/,.\\\\[]<>*?\\\"\"):\n    \"\"\"Clean a string by removing selected characters.\n\n    Creates a legal and 'clean' source string from a string by removing some\n    clutter and  characters not allowed in filenames.\n    A default set is given but the user can override the default string.\n\n    Args:\n        | sourcestring (string): the string to be cleaned.\n        | removestring (string): remove all these characters from the string (optional).\n\n    Returns:\n        | (string): A cleaned-up string.\n\n    Raises:\n        | No exception is raised.\n    \"\"\"\n    # remove the undesireable characters\n    return ''.join([c for c in sourcestring if c not in removestring])\n\n\ndef find_chapter(text):\n    r = r\"(?i)(?:chapter|ch)(?:\\s|\\.)?(?:\\s|\\.)?(\\d+)\"\n    match = re.findall(r, text)\n    if match:\n        return match[0]\n    return match\n\n\ndef fetch_chapter(text):\n    r = r\"(?i)(?:chapter|ch|#)(?:\\s|\\.)?(?:\\s|\\.)?(\\d+)\"\n    return re.findall(r, text)\n\n\ndef fetch_volume(text):\n    r = r\"(?i)(?:volume|vol|v)(?:\\s|\\.)?(?:\\s|\\.)?(\\d+)\"\n    return re.findall(r, text)\n\n\ndef obtain_cover_filename(file_list) -> (str, str):\n    \"\"\"\n    Helper function to find a cover file based on a list of filenames\n    :param file_list:\n    :return:\n    \"\"\"\n    list_image_files = [filename for filename in file_list if IS_IMAGE_PATTERN.findall(filename)]\n    latest_cover = sorted(list_image_files, key=natsort_key_with_path_support, reverse=True)\n    if latest_cover:\n        latest_cover = latest_cover[0]\n    else:\n        latest_cover = None\n    # Cover stuff\n    possible_covers = [filename for filename in file_list\n                       if IS_IMAGE_PATTERN.findall(filename) and COVER_PATTERN.findall(filename)]\n    if possible_covers:\n        cover = possible_covers[0]\n        return cover, latest_cover\n    # Try to get 0001\n    possible_covers = [filename for filename in file_list if ALT_COVER_PATTERN.findall(filename)]\n    if possible_covers:\n        cover = possible_covers[0]\n        return cover, latest_cover\n    # Resource back to first filename available that is a cover\n    # list_image_files = (filename for filename in file_list if IS_IMAGE_PATTERN.findall(filename))\n    cover = sorted(list_image_files, key=natsort_key_with_path_support, reverse=False)\n    if cover:\n        cover = cover[0]\n        return cover, latest_cover\n\n\nwebp_supported_formats = (\".png\", \".jpeg\", \".jpg\")\n\n\ndef get_new_webp_name(currentName: str) -> str:\n    filename, file_format = os.path.splitext(currentName)\n    if filename.endswith(\".\"):\n        filename = filename.strip(\".\")\n    return filename + \".webp\"\n\n\ndef convert_to_webp(image_bytes_to_convert: IO[bytes]) -> bytes:\n    \"\"\"\n    Converts the provided image to webp and returns the converted image bytes\n    :param image_bytes_to_convert: The image that has to be converted\n    :return:\n    \"\"\"\n    # TODO: Bulletproof image passed not image\n    image = Image.open(image_bytes_to_convert).convert()\n    # print(image.size, image.mode, len(image.getdata()))\n    converted_image = BytesIO()\n\n    image.save(converted_image, format=\"webp\")\n    image.close()\n    return converted_image.getvalue()\n\n\ndef get_platform():\n    platforms = {\n        'linux1': 'Linux',\n        'linux2': 'Linux',\n        'darwin': 'OS X',\n        'win32': 'Windows'\n    }\n\n    if sys.platform not in platforms:\n        return sys.platform\n\n    return platforms[sys.platform]\n\n\nclass ShowPathTreeAsDict:\n    \"\"\"Builds a tree like structure out of a list of paths\"\"\"\n    def display_tree(self) -> int:\n        \"\"\"\n\n        :return: Number of lines printed\n        \"\"\"\n        root = Node(\"Root\")\n        self._build_tree(root, self.new_path_dict)\n        _counter = 0\n        for pre, fill, node in RenderTree(root):\n            print(\"%s%s\" % (pre, node.name))\n            _counter+=1\n        return _counter\n    def __init__(self,paths: list,  base_path = None):\n        if not base_path:\n            base_path = os.path.commonprefix(paths)\n        new_path_dict = {\"subfolders\": [],\n                         \"files\": [],\n                         \"current\": Path(base_path)}\n        self.new_path_dict = new_path_dict\n        for path in paths:\n            self._recurse(new_path_dict, Path(path).parts)\n        ...\n\n    def _recurse(self, parent_dic: dict, breaked_subpath):\n\n        if len(breaked_subpath) == 0:\n            return\n        if len(breaked_subpath) == 1:\n            parent_dic[\"files\"].append(breaked_subpath[0])\n            self.on_file(parent_dic, breaked_subpath[0])\n            return\n\n        key, *new_chain = breaked_subpath\n        if key == \"\\\\\":\n            key = \"root\"\n        if key not in parent_dic:\n            parent_dic[key] = {\"subfolders\": [], \"files\": [], \"current\": Path(parent_dic.get(\"current\"), key)}\n            parent_dic[\"subfolders\"].append(key)\n            self.on_subfolder(parent_dic, key)\n        self._recurse(parent_dic[key], new_chain)\n\n    def get(self):\n        return self.new_path_dict\n\n    def on_file(self, parent_dict: dict, breaked_subpath):\n        ...\n\n    def on_subfolder(self, parent_dict: dict, subfolder):\n        ...\n\n    def _build_tree(self, parent, data):\n        for key, value in data.items():\n            if key == \"subfolders\":\n                for subfolder in value:\n                    subfolder_node = Node(subfolder, parent=parent)\n                    self._build_tree(subfolder_node, data[subfolder])\n            elif key == \"files\":\n                for file in value:\n                    Node(file, parent=parent)\n\n\ndef get_elapsed_time(start_time: float) -> str:\n    \"\"\"\n    This functions returns a string of how much time has elapsed\n\n    :param start_time: The start time (time.time())\n    :return: \"{minutes:int} minutes and {seconds:int} seconds\"\n    \"\"\"\n    if start_time == -1:\n        return \"\"\n    current_time = time.time()\n    seconds = current_time - start_time\n    minutes, seconds = divmod(seconds, 60)\n\n    return f\"{int(round(minutes, 0))} minutes and {int(round(seconds, 0))} seconds\"\n\n\ndef get_estimated_time(start_time: float, processed_files: int, total_files: int) -> str:\n    \"\"\"\n    This functions returns a statistic of how much time is left to finish processing. (Uses elapsed time per file)\n\n    :param start_time: The start time (time.time())\n    :param processed_files: Number of files that have already been processed\n    :param total_files: Total number of files to be processed\n    :return: \"{minutes:int} minutes and {seconds:int} seconds\"\n    \"\"\"\n    if start_time == -1:\n        return \"0\"\n    try:\n        current_time = time.time()\n        elapsed_time = current_time - start_time\n\n        time_per_file = elapsed_time / processed_files\n\n        estimated_time = time_per_file * (total_files - processed_files)\n\n        minutes, seconds = divmod(estimated_time, 60)\n        return f\"{int(round(minutes, 0))} minutes and {int(round(seconds, 0))} seconds\"\n    except ZeroDivisionError:\n        return f\"{int(round(0, 0))} minutes and {int(round(0, 0))} seconds\"\n\n\ndef open_folder(folder_path, selected_file: str = None):\n    try:\n        if sys.platform == 'darwin':\n            subprocess.check_call(['open', '--', folder_path])\n        elif sys.platform == 'linux2':\n            subprocess.check_call(['xdg-open', '--', folder_path])\n        elif sys.platform == 'win32':\n            if selected_file:\n                subprocess.Popen(f'explorer /select, \"{os.path.abspath(selected_file)}\"',shell=True)\n            else:\n                subprocess.Popen(f'explorer \"{os.path.abspath(folder_path)}\"',shell=True)\n        else:\n            logger.error(f\"Couldn't detect platform. Can't open settings_class folder. Please navigate to {folder_path}\")\n            return\n    except Exception:\n        logger.exception(f\"Exception opening '{folder_path}' folder\")\n\n\ndef get_language_iso_list():\n    with urllib.request.urlopen(\n            'https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry.txt') as response:\n        registry = response.read().decode('utf-8')\n\n    # Split the registry into lines\n    lines = registry.split('\\n')\n\n    # Initialize a list to store the language tags\n    tags = []\n\n    # Iterate over the lines\n    for line in lines:\n        # Check if the line starts with 'Language:'\n        if line.startswith('Language:'):\n            # Split the line into fields\n            fields = line.split('\\t')\n            # Get the language tag from the second field\n            tag = fields[1]\n            # Add the tag to the list\n            tags.append(tag)\n\n    # Print the list of language tags\n    print(tags)\n\n\ndef extract_folder_and_module(file_path):\n    file_name, ext = os.path.splitext(os.path.basename(file_path))\n    dir_name = os.path.basename(os.path.dirname(file_path))\n    return dir_name, file_name\n\n\ndef match_pyfiles_with_foldername(file_path):\n    folder, file_ = extract_folder_and_module(file_path)\n    return folder == file_\n\n\ndef parse_bool(value: str) -> bool:\n    if isinstance(value,bool):\n        return value\n    match value.lower():\n        case \"true\" | \"1\" | 1:\n            return True\n        case \"false\" | \"0\" | 0:\n            return False\n        case _:\n            raise ValueError(f\"Invalid boolean string: {value}\")\n\n"
  },
  {
    "path": "MangaManager/src/DynamicLibController/__init__.py",
    "content": ""
  },
  {
    "path": "MangaManager/src/DynamicLibController/extension_manager.py",
    "content": "import glob\nimport importlib\nimport logging\nimport os\nimport sys\nfrom pathlib import Path\n\nfrom Extensions.IExtensionApp import IExtensionApp\nfrom src import sub_mm_path\n\nlogger = logging.getLogger()\n\n\n# Extension loader\ndef extract_folder_and_module(file_path):\n    file_name, ext = os.path.splitext(os.path.basename(file_path))\n    dir_name = os.path.basename(os.path.dirname(file_path))\n    return dir_name, file_name\n\n\ndef match_pyfiles_with_foldername(file_path):\n    folder, file_ = extract_folder_and_module(file_path)\n\n    return folder == file_\n\n\nloaded_extensions = []\n\n\ndef load_extensions(extensions_directory,) -> list[IExtensionApp]:\n    # EXTENSIONS_DIRECTORY = extensions_directory\n\n    EXTENSIONS_DIRECTORY = Path(sub_mm_path, \"Extensions\")\n    extensions_path = os.path.expanduser(EXTENSIONS_DIRECTORY)\n    sys.path.append(extensions_path)\n\n    # Search for Python files in the extensions directory\n    extension_files = [extension for extension in\n                       glob.glob(os.path.join(EXTENSIONS_DIRECTORY, \"*/**.py\"), recursive=True)\n                       if match_pyfiles_with_foldername(extension)]\n    if not extension_files:\n        EXTENSIONS_DIRECTORY = os.path.join(os.getcwd(), \"Extensions\")\n        extension_files = [extension for extension in\n                           glob.glob(os.path.join(os.getcwd(), \"Extensions\", \"*/**.py\"), recursive=True)\n                           if match_pyfiles_with_foldername(extension)]\n\n    print(f\"Found extensions: {extension_files}\")\n    # Load the extensions\n    loaded_extensions = []\n    for extension_file in extension_files:\n        if extension_file in loaded_extensions:\n            continue\n        # Import the extension module\n        try:\n            extension_module = importlib.import_module(f\"Extensions.{'.'.join(extract_folder_and_module(extension_file))}\",package=EXTENSIONS_DIRECTORY)\n        except ModuleNotFoundError:\n            logger.exception(f\"Failed to Import Extension: {extension_file}\")\n            continue\n        except Exception:\n            logger.exception(f\"Failed to Load extension {extension_file}\")\n\n        # Get the ExtensionApp subclasses from the module\n        extension_classes = [\n            cls\n            for cls in extension_module.__dict__.values()\n            if isinstance(cls, type) and issubclass(cls, IExtensionApp) and cls != IExtensionApp\n        ]\n\n        # Instantiate the ExtensionApp subclasses and add them to the list of extensions\n        loaded_extensions.extend([cls for cls in extension_classes])\n    return loaded_extensions\n"
  },
  {
    "path": "MangaManager/src/DynamicLibController/models/CoverSourceInterface.py",
    "content": "import abc\nimport dataclasses\nfrom typing import final\n\n\nclass ICoverSource(abc.ABC):\n    name = None\n\n    @classmethod\n    @abc.abstractmethod\n    def download(cls, identifier: str):\n        ...\n\n    @final\n    def __init__(self, master, super_=None, **kwargs):\n        if self.name is None:  # Check if the \"name\" attribute has been set\n            raise ValueError(\n                f\"Error initializing the {self.__class__.__name__} Extension.\"\n                f\"The 'name' attribute must be set in the CoverSource class.\")\n        # if self.embedded_ui:\n        super().__init__(master=master, **kwargs)\n        if super_ is not None:\n            self._super = super_\n\n\n@dataclasses.dataclass\nclass Cover:\n    series_name: str\n    vol: int\n    alternative: int\n    url: str\n\n    image_bytes: bytes\n"
  },
  {
    "path": "MangaManager/src/DynamicLibController/models/ExtensionsInterface.py",
    "content": "import abc\n\n\nclass IMMExtension(abc.ABC):\n    \"\"\"\n        The basic interface that all extensions must implement. An extension is functionality that can be added and\n        dynamically loaded into Manga Manager. An extension can optionally offer settings to the user to configure.\n    \"\"\"\n\n    \"\"\"\n       A set of settings which will be found in the main settings dialog of Manga Manager and used for the source\n    \"\"\"\n    settings = []\n    # Version of the extension\n    version = '0.0.0.0'\n    # Name of the Extension\n    name = ''\n\n    def save_settings(self):\n        \"\"\"\n        When a setting update occurs, this is invoked and internal state should be updated from Settings()\n        \"\"\"\n        pass\n"
  },
  {
    "path": "MangaManager/src/DynamicLibController/models/IMetadataSource.py",
    "content": "import abc\nimport logging\nfrom html.parser import HTMLParser\nfrom io import StringIO\nfrom typing import final\n\nfrom common.models import ComicInfo\nfrom src.Settings import Settings, SettingSection, SettingControl\nfrom .ExtensionsInterface import IMMExtension\n\n\ndef _merge(value1, value2):\n    return IMetadataSource.trim(value1 + \",\" + value2)\n\n\n# MLStripper: https://stackoverflow.com/a/925630\nclass MLStripper(HTMLParser):\n    def __init__(self):\n        super().__init__()\n        self.reset()\n        self.strict = False\n        self.convert_charrefs = True\n        self.text = StringIO()\n\n    def handle_data(self, d):\n        self.text.write(d)\n\n    def get_data(self):\n        return self.text.getvalue()\n\n\nclass IMetadataSource(IMMExtension):\n    name = ''\n    \"\"\"\n        A set of settings which will be found in the main settings dialog of Manga Manager and used for the source\n    \"\"\"\n    settings = []\n    logger = None\n\n    @classmethod\n    @abc.abstractmethod\n    def get_cinfo(cls, comic_info_from_ui: ComicInfo) -> ComicInfo:\n        ...\n\n    def save_settings(self):\n        \"\"\"\n        When a setting update occurs, this is invoked and internal state should be updated from Settings()\n        \"\"\"\n        pass\n\n    @staticmethod\n    def trim(value):\n        ret = value.strip()\n        if ret.endswith(','):\n            return ret[0:-1]\n        return ret\n\n    @staticmethod\n    def update_people_from_mapping(people: list[object], mapping, comicinfo: ComicInfo, name_selector,\n                                   role_selector):\n        if comicinfo is None:\n            return\n\n        for person in people:\n            name = name_selector(person)\n            role = role_selector(person)\n\n            for map_role in mapping:\n                if map_role == role:\n                    for fields in mapping[map_role]:\n                        old_name = comicinfo.get_by_tag_name(fields.strip())\n                        if old_name and old_name.strip() != \"\":\n                            comicinfo.set_by_tag_name(fields.strip(), _merge(old_name, name))\n                        else:\n                            comicinfo.set_by_tag_name(fields.strip(), name.strip())\n\n            logging.info(f\"No mapping found for: '{name}' as '{role}'\")\n\n    @staticmethod\n    def clean_description(summary: str, remove_source: bool) -> str:\n        \"\"\"\n        Removes HTML text like <br> from String\n        Removes \"(Source ...)\" from String when flag is set to True\n\n        :param summary:\n        :param remove_source:\n        :return:\n        \"\"\"\n        if summary is None:\n            return \"\"\n        # Remove HTML\n        s = MLStripper()\n        s.feed(summary.strip())\n        summary = s.get_data()\n\n        # Remove \"(Source ...)\"\n        source_index = summary.find(\"Source\")\n        if remove_source and source_index != -1:\n            start_index = summary.find(\"(\", 0, source_index)\n            end_index = summary.find(\")\", source_index)\n            if start_index != -1 and end_index != -1:\n                if summary[start_index - 1] == '\\n':\n                    start_index -= 1\n                summary = summary[:start_index] + summary[end_index + 1:]\n\n        return summary.strip()\n\n    def init_settings(self):\n        \"\"\"\n        Grabs extension settings and loads it to the base setting controller\n        :return:\n        \"\"\"\n        for section in self.settings:\n            section: SettingSection\n            for control in section.values:\n                control: SettingControl\n                Settings().set_default(section.key, control.key, control.value)\n        Settings().save()\n\n        # Load any saved settings into memory to overwrite defaults\n        self.save_settings()\n\n    @final\n    def __init__(self):\n        if self.name is None:  # Check if the \"name\" attribute has been set\n            raise ValueError(\n                f\"Error initializing the {self.__class__.__name__} Extension. The 'name' attribute must be set in the CoverSource class.\")\n\n        self.logger = logging.getLogger(f'{self.__module__}.{self.__class__.__name__}')\n        # Save any default settings to ini\n        self.init_settings()\n"
  },
  {
    "path": "MangaManager/src/DynamicLibController/models/__init__.py",
    "content": ""
  },
  {
    "path": "MangaManager/src/MetadataManager/CoverManager/CoverManager.py",
    "content": "import copy\nimport logging\nimport platform\nimport tkinter\nfrom idlelib.tooltip import Hovertip\nfrom tkinter import Frame, CENTER, Button, NW\nfrom tkinter.filedialog import askopenfile\nfrom tkinter.ttk import Treeview\n\nimport numpy as np\nfrom PIL import Image, ImageTk\n\nfrom src.Common import ResourceLoader\nfrom src.Common.LoadedComicInfo.LoadedComicInfo import CoverActions, LoadedComicInfo\nfrom src.MetadataManager.GUI.MessageBox import MessageBoxWidgetFactory as mb\nfrom src.MetadataManager.GUI.scrolledframe import ScrolledFrame\nfrom src.MetadataManager.GUI.widgets import ButtonWidget\nfrom src.MetadataManager.GUI.widgets.CanvasCoverWidget import CoverFrame, CanvasCoverWidget\nfrom src.MetadataManager.MetadataManagerGUI import GUIApp\nfrom src.Settings import SettingHeading\nfrom src.Settings.Settings import Settings\n\naction_template = ResourceLoader.get('cover_action_template.png')\n\nlogger = logging.getLogger()\noverlay_image: Image = None\n\n\nclass ComicFrame(CoverFrame):\n    def __init__(self, master, loaded_cinfo: LoadedComicInfo):\n        \"\"\"\n        Custom Implementation of the CoverFrame for cover Manager\n\n        :param master: Parent window\n        :param loaded_cinfo: The lcinfo to display covers from\n        \"\"\"\n        super(CoverFrame, self).__init__(master, highlightbackground=\"black\")\n\n        self.loaded_cinfo: LoadedComicInfo = loaded_cinfo\n        self.configure(highlightthickness=2, highlightcolor=\"grey\", highlightbackground=\"grey\", padx=20, pady=10)\n        title = tkinter.Label(self,\n                              text=f\"{loaded_cinfo.file_name[:70]}{'...' if len(loaded_cinfo.file_name) > 70 else ''}\")\n        Hovertip(title, loaded_cinfo.file_name, 20)\n        title.pack(expand=True)\n        # COVER\n        self.cover_frame = Frame(self)\n        self.cover_frame.pack(side=\"left\")\n\n        self.cover_canvas = CanvasCoverWidget(self.cover_frame)\n        self.cover_canvas.configure(background='#878787', height='260', width='190', highlightthickness=8)\n        self.cover_canvas.pack(side=\"top\", expand=False, anchor=CENTER)\n\n        self.cover_canvas.overlay_image = ImageTk.PhotoImage(overlay_image, master=self.cover_canvas)\n        self.cover_canvas.overlay_id = self.cover_canvas.create_image(150, 150, image=self.cover_canvas.overlay_image,\n                                                                      state=\"hidden\")\n        self.cover_canvas.action_id = self.cover_canvas.create_text(150, 285, text=\"\", justify=\"center\", fill=\"yellow\",\n                                                                    font=('Helvetica 15 bold'))\n        self.cover_canvas.no_image_warning_id = self.cover_canvas.create_text(150, 120,\n                                                                              text=\"No Cover!\\nNo image\\ncould be\\nloaded\",\n                                                                              justify=\"center\", fill=\"red\",\n                                                                              state=\"hidden\",\n                                                                              font=('Helvetica 28 bold'))\n        self.cover_canvas.image_id = self.cover_canvas.create_image(0, 0, anchor=NW)\n        self.cover_canvas.scale(\"all\", -1, 1, 0.63, 0.87)\n        self.cover_canvas.tag_lower(self.cover_canvas.image_id)\n        btn_frame = Frame(self.cover_frame)\n        btn_frame.pack(side=\"bottom\", anchor=CENTER, fill=\"x\")\n        btn = Button(btn_frame, text=\"✎\", command=lambda:\n        self.cover_action(self.loaded_cinfo, action=CoverActions.REPLACE, parent=self))\n        btn.pack(side=\"left\", fill=\"x\", expand=True)\n\n        btn = Button(btn_frame, text=\"🗑\", command=lambda:\n        self.cover_action(self.loaded_cinfo, action=CoverActions.DELETE))\n        btn.pack(side=\"left\", fill=\"x\", expand=True)\n\n        btn = Button(btn_frame, text=\"➕\", command=lambda:\n        self.cover_action(self.loaded_cinfo, action=CoverActions.APPEND, parent=self))\n        btn.pack(side=\"left\", fill=\"x\", expand=True)\n\n        btn = Button(btn_frame, text=\"Reset\", command=lambda:\n        self.cover_action(self.loaded_cinfo, action=CoverActions.RESET))\n        btn.pack(side=\"left\", fill=\"x\", expand=True)\n        self.cover_action(self.loaded_cinfo, auto_trigger=True, proc_update=False)\n\n        # BACK COVER\n        self.backcover_frame = Frame(self)\n        self.backcover_frame.pack(side=\"left\")\n\n        self.backcover_canvas = CanvasCoverWidget(self.backcover_frame)\n        self.backcover_canvas.configure(background='#878787', height='260', width='190', highlightthickness=8)\n        self.backcover_canvas.pack(side=\"top\", expand=False, anchor=CENTER)\n\n        self.backcover_canvas.overlay_image = ImageTk.PhotoImage(overlay_image, master=self.backcover_canvas)\n        self.backcover_canvas.overlay_id = self.backcover_canvas.create_image(150, 150,\n                                                                              image=self.backcover_canvas.overlay_image,\n                                                                              state=\"hidden\")\n        self.backcover_canvas.action_id = self.backcover_canvas.create_text(150, 285, text=\"\", justify=\"center\",\n                                                                            fill=\"yellow\",\n                                                                            font=('Helvetica 15 bold'))\n        self.backcover_canvas.no_image_warning_id = self.backcover_canvas.create_text(150, 120,\n                                                                                      text=\"No Cover!\\nNo image\\ncould be\\nloaded\",\n                                                                                      justify=\"center\", fill=\"red\",\n                                                                                      state=\"hidden\",\n                                                                                      font=('Helvetica 28 bold'))\n        self.backcover_canvas.image_id = self.backcover_canvas.create_image(0, 0, anchor=NW)\n        self.backcover_canvas.scale(\"all\", -1, 1, 0.63, 0.87)\n        self.backcover_canvas.tag_lower(self.backcover_canvas.image_id)\n        btn_frame = Frame(self.backcover_frame)\n        btn_frame.pack(side=\"bottom\", anchor=CENTER, fill=\"x\")\n        btn = Button(btn_frame, text=\"✎\", command=lambda:\n        self.backcover_action(self.loaded_cinfo, action=CoverActions.REPLACE, parent=self))\n        btn.pack(side=\"left\", fill=\"x\", expand=True)\n\n        btn = Button(btn_frame, text=\"🗑\", command=lambda:\n        self.backcover_action(self.loaded_cinfo, action=CoverActions.DELETE))\n        btn.pack(side=\"left\", fill=\"x\", expand=True)\n\n        btn = Button(btn_frame, text=\"➕\", command=lambda:\n        self.backcover_action(self.loaded_cinfo, action=CoverActions.APPEND, parent=self))\n        btn.pack(side=\"left\", fill=\"x\", expand=True)\n\n        btn = Button(btn_frame, text=\"Reset\", command=lambda:\n        self.backcover_action(self.loaded_cinfo, action=CoverActions.RESET))\n        btn.pack(side=\"left\", fill=\"x\", expand=True)\n\n        # Load backcover\n        self.backcover_action(self.loaded_cinfo, auto_trigger=True, proc_update=False)\n\n\nclass CoverManager(tkinter.Toplevel):\n    name = \"CoverManager\"\n\n    scrolled_widget: Frame\n    top_level: tkinter.Toplevel = tkinter.Toplevel\n\n    def __init__(self, master, super_: GUIApp = None, **kwargs):\n        \"\"\"\n        Initializes the toplevel window but hides the window.\n        \"\"\"\n        if self.name is None:  # Check if the \"name\" attribute has been set\n            raise ValueError(\n                f\"Error initializing the {self.__class__.__name__} Extension. The 'name' attribute must be set in the ExtensionApp class.\")\n        # if self.embedded_ui:\n        super().__init__(master=master, **kwargs)\n        self.title(self.__class__.name)\n        if super_ is not None:\n            self._super = super_\n        global overlay_image\n        overlay_image = Image.open(action_template)\n        overlay_image = overlay_image.resize((190, 260), Image.NEAREST)\n\n        self.serve_gui()\n        if not self._super.loaded_cinfo_list:\n            mb.showwarning(self, \"No files selected\", \"No files were selected so none will be displayed in cover manager\")\n            # self.deiconify()\n            self.destroy()\n            return\n\n        # bind the redraw function to the <Configure> event\n        # so that it will be called whenever the window is resized\n        self.bind(\"<Configure>\", self.redraw)\n\n    def redraw(self, event):\n        \"\"\"\n        Redraws the widgets in the scrolled widget based on the current size of the window.\n\n        The function is triggered by an event (e.g. window resize) and only redraws the widgets if\n        the window dimensions have changed since the last redraw. The widgets are laid out in a grid\n        with a number of columns equal to the number of widgets that fit in the current width of the\n        window, minus 300 pixels.\n\n        :param: event: The event that triggered to redraw (e.g. a window resize event).\n\n        \"\"\"\n        width = self.winfo_width()\n        height = self.winfo_height()\n        if not event:\n            return\n        if not (width != event.width or height != event.height):\n            return\n\n        width = self.winfo_width() - 300\n        if width == self.prev_width:\n            return\n        childrens = self.scrolled_widget.winfo_children()\n        for child in childrens:\n            child.grid_forget()\n        if not self.scrolled_widget.winfo_children():\n            return\n\n        num_widgets = width // 414\n        try:\n            logger.trace(f\"Number of widgets per row: {num_widgets}\")\n            logger.trace(f\"Number of rows: {len(self.scrolled_widget.winfo_children()) / num_widgets}\")\n        except ZeroDivisionError:\n            pass\n        # redraw the widgets\n        widgets_to_redraw = list(\n            reversed(copy.copy(self.scrolled_widget.winfo_children())))  # self.scrolled_widget.grid_slaves()\n        i = 0\n        j = 0\n        while widgets_to_redraw:\n            if j >= num_widgets:\n                i += 1\n                j = 0\n            widgets_to_redraw.pop().grid(row=i, column=j)\n            j += 1\n\n    def exit_btn(self):\n        self._super.show_not_saved_indicator()\n        self.destroy()\n        self.update()\n\n    def serve_gui(self):\n        \"\"\"\n        This function creates and serves the GUI for the application.\n        \"\"\"\n        if platform.system() == \"Linux\":\n            self.attributes('-zoomed', True)\n        elif platform.system() == \"Windows\":\n            self.state('zoomed')\n        side_panel_control = Frame(self)\n        side_panel_control.pack(side=\"right\", expand=False, fill=\"y\")\n        ctr_btn = Frame(self)\n        ctr_btn.pack()\n        #\n        #\n        tree = self.tree = Treeview(side_panel_control, columns=(\"Filename\", \"type\"), show=\"headings\", height=8)\n        tree.column(\"#1\")\n        tree.heading(\"#1\", text=\"Filename\")\n        tree.column(\"#2\", anchor=CENTER, width=80)\n        tree.heading(\"#2\", text=\"Type\")\n        tree.pack(expand=True, fill=\"y\", pady=(80, 0), padx=30, side=\"top\")\n        action_buttons = Frame(side_panel_control)\n        action_buttons.pack(ipadx=20, ipady=20, pady=(0, 80), fill=\"x\", padx=30)\n\n        ButtonWidget(master=action_buttons, text=\"Delete Selected\",\n                     tooltip=\"Deletes the image for the selected cover/backcovers\",\n                     command=lambda: self.run_bulk_action(CoverActions.DELETE)).pack(side=\"top\", fill=\"x\", ipady=10)\n        ButtonWidget(master=action_buttons, text=\"Append to Selected\",\n                     tooltip=\"Appends the image for the selected cover/backcovers\",\n                     command=lambda: self.run_bulk_action(CoverActions.APPEND)).pack(side=\"top\", fill=\"x\", ipady=10)\n        ButtonWidget(master=action_buttons, text=\"Replace Selected\",\n                     tooltip=\"Replaces the image for the selected cover/backcovers\",\n                     command=lambda: self.run_bulk_action(CoverActions.REPLACE)).pack(side=\"top\", fill=\"x\", ipady=10)\n        ButtonWidget(master=action_buttons, text=\"Clear Selection\",\n                     command=self.clear_selection).pack(fill=\"x\", ipady=10)\n        ButtonWidget(master=action_buttons, text=\"Close window\",\n                     command=self.exit_btn).pack(fill=\"x\", ipady=10)\n\n        self.select_similar_btn = ButtonWidget(master=action_buttons, text=\"Select similar\", state=\"disabled\",\n                                               command=self.select_similar)\n        self.select_similar_btn.pack(fill=\"x\", ipady=10)\n\n        frame = Frame(action_buttons)\n        frame.pack(fill=\"x\", pady=(10, 0))\n        tkinter.Label(frame, text=\"Delta %\").pack(side=\"left\")\n        self.delta_entry = tkinter.Entry(frame, width=\"10\")\n        self.delta_entry.insert(0, \"90\")\n        self.delta_entry.pack(side=\"left\")\n\n        frame = tkinter.LabelFrame(action_buttons, text=\"Scan:\")\n        frame.pack(fill=\"x\", expand=True, pady=(0, 5))\n        self.scan_covers = tkinter.BooleanVar(value=True)\n        self.scan_backcovers = tkinter.BooleanVar(value=False)\n\n        tkinter.Checkbutton(frame, text=\"Covers\", variable=self.scan_covers).pack()\n        tkinter.Checkbutton(frame, text=\"Back Covers\", variable=self.scan_backcovers).pack()\n\n        content_frame = Frame(self)\n        content_frame.pack(fill=\"both\", side=\"left\", expand=True)\n\n        frame = ScrolledFrame(master=content_frame, scrolltype=\"vertical\", usemousewheel=True)\n        frame.pack(fill=\"both\", expand=True)\n        self.scrolled_widget = frame.innerframe\n\n\n        self.tree_dict = {}\n        self.prev_width = 0\n        self.last_folder = \"\"\n        self.selected_frames: list[tuple[ComicFrame, str]] = []\n\n        for i, cinfo in enumerate(self._super.loaded_cinfo_list):\n            # create a ComicFrame for each LoadedComicInfo object\n            comic_frame = ComicFrame(self.scrolled_widget, cinfo)\n\n            comic_frame.cover_canvas.bind(\"<Button-1>\",\n                                          lambda event, frame_=comic_frame: self.select_frame(event, frame_, \"front\"))\n            comic_frame.backcover_canvas.bind(\"<Button-1>\",\n                                              lambda event, frame_=comic_frame: self.select_frame(event, frame_,\n                                                                                                  \"back\"))\n            comic_frame.grid()\n        self.redraw(None)\n\n    def select_frame(self, _, frame: ComicFrame, pos: str):\n        \"\"\"\n        Selects the frame. Adds to selected frames and modifies its border to show green as \"selected\"\n        \"\"\"\n        if (frame, pos) in self.selected_frames:\n            for children in self.tree.get_children():\n                if self.tree_dict[children][\"cinfo\"] == frame.loaded_cinfo and self.tree_dict[children][\"type\"] == pos:\n                    self.selected_frames.remove((frame, pos))\n                    self.tree.delete(children)\n                    del self.tree_dict[children]\n            if pos == \"front\":\n                frame.cover_canvas.configure(highlightbackground=\"#f0f0f0\", highlightcolor=\"white\")\n            else:\n                frame.backcover_canvas.configure(highlightbackground=\"#f0f0f0\", highlightcolor=\"white\")\n\n        else:\n            node = self.tree.insert('', 'end', text=\"1\", values=(frame.loaded_cinfo.file_name, pos))\n            self.tree_dict[node] = {\"cinfo\": frame.loaded_cinfo, \"type\": pos}\n            self.selected_frames.append((frame, pos))\n            if pos == \"front\":\n                frame.cover_canvas.configure(highlightbackground=\"green\", highlightcolor=\"green\")\n            else:\n                frame.backcover_canvas.configure(highlightbackground=\"green\", highlightcolor=\"green\")\n        # noinspection PyTypeChecker\n        self.select_similar_btn.configure(state=\"normal\" if len(self.selected_frames) == 1 else \"disabled\")\n\n    def run_bulk_action(self, action: CoverActions):\n        \"\"\"\n        Applies the action to currently selected files\n        :param action:\n        :return:\n        \"\"\"\n        new_cover_file = None\n        cover = None\n        if action == CoverActions.APPEND or action == CoverActions.REPLACE:\n            new_cover_file = askopenfile(parent=self,\n                                         initialdir=Settings().get(SettingHeading.Main, 'covers_folder_path')).name\n\n        for frame, type_ in self.selected_frames:\n            # create a ComicFrame for each LoadedComicInfo object\n            frame: ComicFrame\n            loaded_cinfo = frame.loaded_cinfo\n            canva: CanvasCoverWidget = frame.cover_canvas if type_ == \"front\" else frame.backcover_canvas\n            if action is not None:\n                # If reset, undo action changes. Forget about the new cover.\n                if type_ == \"front\":\n                    loaded_cinfo.cover_action = action\n                else:\n                    loaded_cinfo.backcover_action = action\n            if loaded_cinfo.new_backcover_cache:\n                cover = loaded_cinfo.new_backcover_cache\n            else:\n                cover = loaded_cinfo.backcover_cache\n\n            if not cover:\n                canva.itemconfig(canva.overlay_id, image=canva.overlay_image, state=\"hidden\")\n                canva.itemconfig(canva.no_image_warning_id, state=\"normal\")\n                canva.itemconfig(canva.action_id, text=\"\")\n                canva.itemconfig(canva.image_id, state=\"hidden\")\n            else:\n                # A cover exists. Hide warning\n                canva.itemconfig(canva.no_image_warning_id, state=\"hidden\")\n            canva.itemconfig(canva.overlay_id, image=canva.overlay_image, state=\"normal\")\n            canva.itemconfig(canva.image_id, image=cover, state=\"normal\")\n            match action:\n                case CoverActions.APPEND | CoverActions.REPLACE:\n                    loaded_cinfo.new_cover_path = new_cover_file\n                    cover = loaded_cinfo.new_cover_cache\n                    # Show the Action label\n                    canva.itemconfig(canva.action_id,\n                                     text=\"Append\" if\n                                     action == CoverActions.APPEND else \"Replace\", state=\"normal\")\n                case CoverActions.DELETE:\n                    canva.itemconfig(canva.action_id, text=\"Delete\", state=\"normal\")\n                case _:\n                    canva.itemconfig(canva.overlay_id, state=\"hidden\")\n                    canva.itemconfig(canva.action_id, text=\"\", state=\"normal\")\n\n            # Update the displayed cover\n            canva.itemconfig(canva.image_id, image=cover, state=\"normal\")\n\n    def clear_selection(self):\n        \"\"\"\n        Clears the selected files\n        :return:\n        \"\"\"\n        while self.selected_frames:\n            frame, pos = self.selected_frames.pop()\n            frame.cover_canvas.configure(highlightbackground=\"#f0f0f0\", highlightcolor=\"white\")\n            frame.backcover_canvas.configure(highlightbackground=\"#f0f0f0\", highlightcolor=\"white\")\n\n        for children in self.tree.get_children():\n            self.tree.delete(children)\n            del self.tree_dict[children]\n\n    ########################\n    # Cover scanner methods\n    ########################\n    # TODO: Add tests\n    def select_similar(self):\n        \"\"\"\n        Compares the selected file with all the loaded covers and backcovers\n        Selects files that match.\n        :return:\n        \"\"\"\n        assert len(self.selected_frames) == 1\n        frame, pos = self.selected_frames[0]\n        if pos == \"front\":\n            selected_photoimage: ImageTk.PhotoImage = frame.loaded_cinfo.get_cover_cache()\n        else:\n            selected_photoimage: ImageTk.PhotoImage = frame.loaded_cinfo.get_cover_cache(True)\n\n        selected_image = ImageTk.getimage(selected_photoimage)\n        x = np.array(selected_image.histogram())\n        self.clear_selection()\n        # Compare all covers:\n        for comicframe in self.scrolled_widget.winfo_children():\n            comicframe: ComicFrame\n            lcinfo: LoadedComicInfo = comicframe.loaded_cinfo\n            try:\n                if self.scan_covers.get():\n                    self._scan_images(lcinfo=lcinfo, x=x, is_backcover=False, comicframe=comicframe)\n                if self.scan_backcovers.get():\n                    self._scan_images(lcinfo=lcinfo, x=x, is_backcover=True, comicframe=comicframe)\n            except Exception:\n                logger.exception(f\"Failed to compare images for file {comicframe.loaded_cinfo.file_name}\")\n\n    def _scan_images(self, x, lcinfo:LoadedComicInfo, comicframe, is_backcover=False):\n        \"\"\"\n\n        :param x: Numpy array containing the selected image histogram\n        :param lcinfo: The loaded comicinfo of the compared image\n        :param is_backcover:\n        :param comicframe: The comicframe the lcinfo is linked to\n        :return:\n        \"\"\"\n        image = lcinfo.get_cover_cache(is_backcover)\n        if image is None:\n            logger.error(f\"Failed to compare cover image. File is not loaded. File '{lcinfo.file_name}'\")\n        else:\n            compared_image = ImageTk.getimage(image)\n            self._compare_images(x, compared_image, comicframe, \"back\" if is_backcover else \"front\")\n\n    def _compare_images(self, x, compared_image, comicframe, pos):\n        delta = float(self.delta_entry.get())\n        y = np.array(compared_image.histogram())\n        if self.compare_image(x, y, delta=delta):\n            self.select_frame(None, frame=comicframe, pos=pos)\n\n    @staticmethod\n    def compare_image(x, y, delta:float):\n        \"\"\"\n        Compares the image histograms\n        :param img1: Image Object\n        :param imge2: Image Object\n        :param x: Numpy array containing the selected image histogram\n        :param y: Numpy array containing the selected image histogram\n        :param delta: 1-100 match value\n        :return:\n        \"\"\"\n        actual_error = 0\n        if len(x) == len(y):\n            error = np.sqrt(((x - y) ** 2).mean())\n            error = str(error)[:2]\n            actual_error = float(100) - float(error)\n            logger.debug(f\"Match percentage: {actual_error}%\")\n            if actual_error >= delta:\n                logger.trace(\"Matched image\")\n                return True\n            else:\n                logger.trace(\"Images not similar\")"
  },
  {
    "path": "MangaManager/src/MetadataManager/CoverManager/__init__.py",
    "content": "\n"
  },
  {
    "path": "MangaManager/src/MetadataManager/GUI/ControlManager.py",
    "content": "import logging\nimport tkinter\n\nimport _tkinter\n\nlogger = logging.getLogger()\n\n\nclass ControlManager:\n    \"\"\"\n    \"\"\"\n    control_button_set = set()\n    control_hooks = []  # Callables to call when it should lock or unlock\n\n    def add(self, widget: tkinter.Widget):\n        self.control_button_set.add(widget)\n\n    def append(self, widget: tkinter.Widget):\n        self.control_button_set.add(widget)\n\n    def toggle(self, enabled=True):\n        for widget in self.control_button_set:\n            try:\n                widget.configure(state=\"normal\" if enabled else \"disabled\")\n            except _tkinter.TclError:\n                logger.exception(\"Unhandled exception updating widget state\", exc_info=False)\n\n    def lock(self):\n        self.toggle(False)\n\n    def unlock(self):\n        self.toggle(True)\n\n\n\n\n\n\n\n\n"
  },
  {
    "path": "MangaManager/src/MetadataManager/GUI/ExceptionWindow.py",
    "content": "import logging\nimport tkinter\nimport traceback\nfrom tkinter import Frame\nfrom tkinter.font import Font\nfrom tkinter.ttk import Treeview, Style\n\nfrom src.Common.LoadedComicInfo.LoadedComicInfo import LoadedComicInfo\n\nlogger = logging.getLogger()\n\n\nclass ExceptionHandler(logging.Handler):\n    def __init__(self, tree_widget):\n        logging.Handler.__init__(self)\n        self.tree_widget = tree_widget\n\n    def emit(self, record):\n        ei = record.exc_info\n        parent_id = self.tree_widget.insert(\"\", 'end', text=f\"{record.levelname:12s} {record.msg}\")\n        self.tree_widget.dict[parent_id] = record\n        if \"processed_filename\" in record.__dict__:\n            self.tree_widget.insert(parent_id, 'end', text=f\"Filename: '{record.processed_filename}'\")\n        if \"lcinfo\" in record.__dict__:\n            lcinfo: LoadedComicInfo = record.__dict__[\"lcinfo\"]\n            self.tree_widget.insert(parent_id, 'end', text=f\"Filename: '{lcinfo.file_name}'\")\n        if ei:\n            stack_tab = self.tree_widget.insert(parent_id, 'end', text=\"Stack Trace info\", open=False)\n            exc_type, exc_value, exc_traceback = ei\n            tb_str = ''.join(traceback.format_exception(exc_type, exc_value, exc_traceback))\n            for string in tb_str.split(\"\\n\"):\n                self.tree_widget.insert(stack_tab, 'end', text=string)\n\n\nclass ExceptionFrame(Frame):\n    def __init__(self, master=None, is_test=False, **kwargs):\n        Frame.__init__(self, master, **kwargs)\n        ter_font = Font(family=\"Consolas\", size=6)\n        style = Style()\n        style.configure('Terminal.Treeview', font=ter_font)\n        self.tree = Treeview(self, style='Terminal.Treeview', show=\"tree\")\n        self.tree.style = style\n        self.tree.dict = dict()\n        self.tree.pack(expand=True, fill='both')\n        self.selected_logging_level = tkinter.StringVar(self)\n        self.selected_logging_level.set(\"WARNING\")\n        self.input_type = tkinter.OptionMenu(self,self.selected_logging_level,*(\"WARNING\", \"ERROR\", \"INFO\", \"DEBUG\",\"TRACE\"))\n        self.input_type.pack(side=\"left\", fill=\"y\")\n\n        tkinter.Button(self,text=\"Clear logs\",command=self.clear_treeview).pack(side=\"left\", fill=\"y\")\n\n        self.selected_logging_level.trace(\"w\", self.update_handler_level)\n        handler = self.handler = ExceptionHandler(self.tree)\n        handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))\n        handler.setLevel(logging.WARNING)\n\n        if not is_test:\n            logger.addHandler(handler)\n        # Pump logging events not loaded with the ui\n            logger.debug(\"Removing unpumped handler\")\n            logger.removeHandler(logging.umpumped_handler)\n            while logging.umpumped_events:\n                record = logging.umpumped_events.pop()\n                handler.emit(record)\n\n    def update_handler_level(self,*args):\n        self.handler.setLevel(logging.getLevelName(self.selected_logging_level.get()))\n        logger.info(f\"Selected '{self.selected_logging_level.get()}' as UI logging level\",extra={\"ui\":True})\n\n    def clear_treeview(self):\n        # Delete all items in the Treeview\n        self.tree.delete(*self.tree.get_children())\n\n    def __del__(self):\n        logger.removeHandler(self.handler)\n"
  },
  {
    "path": "MangaManager/src/MetadataManager/GUI/FileChooserWindow.py",
    "content": "import dataclasses\nimport fnmatch\nimport os\nimport tkinter\nfrom idlelib.tooltip import Hovertip\nfrom pathlib import Path\nfrom tkinter import font\nfrom tkinter import ttk, Frame\n\nfrom src.MetadataManager.GUI.widgets import ScrolledFrameWidget\n\n\n@dataclasses.dataclass\nclass DummyFile:\n    name: str\n    def __str__(self):\n        return self.name\n\nclass TreeAutocompleteCombobox(ttk.Combobox):\n    def set_completion_list(self,path, completion_list):\n        \"\"\"Use our completion list as our drop down selection menu, arrows move through menu.\"\"\"\n        if not completion_list:\n            self._completion_list = []\n        else:\n            self._completion_list = [str(Path(path,item)) for item in completion_list]  # Work with a sorted list\n        self._hits = []\n        self._hit_index = 0\n        self.position = 0\n        self.bind('<KeyRelease>', self.handle_keyrelease)\n        self['values'] = self._completion_list  # Setup our popup menu\n\n    def autocomplete(self, delta=0):\n        \"\"\"autocomplete the Combobox, delta may be 0/1/-1 to cycle through possible hits\"\"\"\n        if delta:  # need to delete selection otherwise we would fix the current position\n            self.delete(self.position, tkinter.END)\n        else:  # set position to end so selection starts where textentry ended\n            self.position = len(self.get())\n        # collect hits\n        _hits = []\n        for element in self._completion_list:\n            if element.lower().startswith(self.get().lower()):  # Match case insensitively\n                _hits.append(element)\n        # if we have a new hit list, keep this in mind\n        if _hits != self._hits:\n            self._hit_index = 0\n            self._hits = _hits\n        # only allow cycling if we are in a known hit list\n        if _hits == self._hits and self._hits:\n            self._hit_index = (self._hit_index + delta) % len(self._hits)\n        # now finally perform the auto completion\n        if self._hits:\n            self.delete(0, tkinter.END)\n            self.insert(0, self._hits[self._hit_index])\n            self.select_range(self.position, tkinter.END)\n\n    def handle_keyrelease(self, event):\n        \"\"\"event handler for the keyrelease event on this widget\"\"\"\n        if event.keysym == \"BackSpace\":\n            self.delete(self.index(tkinter.INSERT), tkinter.END)\n            self.position = self.index(tkinter.END)\n        if event.keysym == \"Left\":\n            if self.position < self.index(tkinter.END):  # delete the selection\n                self.delete(self.position, tkinter.END)\n            else:\n                self.position = self.position - 1  # delete one character\n                self.delete(self.position, tkinter.END)\n        if event.keysym == \"Right\":\n            self.position = self.index(tkinter.END)  # go to end (no selection)\n        if len(event.keysym) == 1:\n            self.autocomplete()\n        # No need for up/down, we'll jump to the popup\n        # list at the position of the autocompletion\n\n\nclass TreeviewExplorerWidget(ttk.Treeview):\n\n    def __init__(self,master, *_, **__):\n        super(TreeviewExplorerWidget, self).__init__(master)\n        self.on_select_hooks:callable = []\n        self.nodes:dict = dict()\n        self.directory_nodes:dict = dict()\n        self.tree = dict()\n        self.FOLDER_ICON = tkinter.PhotoImage(\n            data=\"\"\"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABmJLR0QA/wD/AP+gvaeTAAABF0lEQVQ4jcXQQS8DQRTA8f9sZ7dVdCvsgcSNC4kQ4uJLSIRP4nP4IOJuv4CLi8RNE044oJrVaraz3XkOyyLZTboH8U4zk/d+782D/w71dRheHe2BXcpuzmNz6/RiYqB/ebCOsteA8/luFRyL4r6oSAQz245DtXo+0gCm21sTJc6PHEfPtE8c1y1ua4XXu/QQONMA/Ydnd2ohQNUyo9Zo4q9sgFLFABDd3uzmwPTi8ub89s7vMREQKQWSaKABdDaR1TYdlSYX/iJNyAGsQcZxJQBrvoF64HfS5L1SfT3wOzngtbyuNYNKgNfyujmQmgikfOOFobIFa4Dx8M1A+cZLBJMDL9FTONfw9pXgTVIqCtOLTVix4x/FB+6JXX9v9hbtAAAAAElFTkSuQmCC\"\"\")\n        self.FILE_ICON = tkinter.PhotoImage(\n            data=\"\"\"iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAAtklEQVRIid2VSwrCMBRFjyJ06LZK99GhIzfg0H2Jo+6i2YArsE5ewYY03rwoSC88Qj435+VHYItqgQBMH+LiBYzC5FWQ2ayMmYDrrwHFEA+gCOIFJCG7FfNaXzwmpYXvkEtTnSQH3TsBsv4GcAdumXo14Mlyj+N6kZRrKvvUFZyADjhadNbmUpxJz/rD6jM+GdAAZ2AAHhaDtTXfAKiqOgO3UoBgpfrpvGceENRS9qvNMZp3Y3oBGlFzy7XlpJsAAAAASUVORK5CYII=\"\"\")\n        self.bind('<Double-1>', self._on_select)\n\n\n    def nothing(self, *event):\n        \"\"\" # Hacking moment: A function that does nothing, for those times you need it...\n        \"\"\"\n        pass\n    def _on_select(self,*args):\n        if not self.selection():\n            return\n        self.item(self.selection()[0],open=True)\n        for hook in self.on_select_hooks:\n            hook(*args)\n        return \"break\"\n\n    def clear(self):\n        self.delete(*self.get_children())\n        self.nodes = dict()\n        self.tree = dict()\n        self.directory_nodes = dict()\n\n    def show_nested_items(self, current_path,glob=\"*.cbz\"):\n        current_path = str(Path(current_path))\n        self.clear()\n        files = []\n        items = os.scandir(current_path)\n        for item in items:\n            if item.is_dir():\n                node = self.insert('', 'end', text=item.name, open=True, image=self.FOLDER_ICON)\n                self.tree[node] = {\"path\": str(Path(current_path, item.name)),\n                                   \"is_dir\": True}\n            else:\n                files.append(item)\n        for file in fnmatch.filter(files, glob):\n            node = self.insert('', 'end', text=file.name, open=True, image=self.FILE_ICON)\n            self.tree[node] = {\"path\": str(file.path),\n                               \"is_dir\": False}\n\n# TODO: This needs to be a Widget or Window submodule\nclass FileChooser(tkinter.Toplevel):\n    def __init__(self, parent, initialdir=None,*_, **__):\n        super(FileChooser, self).__init__(parent)\n        self.prev_path = [None]\n        self.next_path = [None]\n        self.current_search_path: Path = Path(initialdir) if initialdir else None\n        self.title(\"File Selector\")\n        self.geometry(\"800x600\")\n\n        header = Frame(self)\n        header.pack(expand=False,fill=\"x\")\n\n        self.search_bar = search_bar = Frame(header, highlightbackground=\"blue\", highlightthickness=2,background=\"grey\")\n        search_bar.pack(expand=False, fill=\"x\", pady=10,padx=10,ipady=5,ipadx=1)\n        search_bar.bind('<Double-Button-1>', self.change_to_entry)\n\n        control_arrow_frame = Frame(header)\n        control_arrow_frame.pack(side=\"left\")\n\n        self.prev_btn = tkinter.Button(control_arrow_frame, text=\"🡰\", command=lambda:self.update_search_bar(self.prev_path[-1]))\n        self.prev_btn.pack(side=\"left\")\n        tkinter.Button(control_arrow_frame, text=\"⮥\", command= lambda:self.update_search_bar(Path(*self.current_search_path.parts[:-1]))).pack(side=\"right\")\n\n\n        glob_frame = Frame(header)\n        glob_frame.pack(side=\"right\")\n        glob_frame.tooltip = Hovertip(glob_frame, \"Find all files with the provided glob. (Enables recursiveness)\", 20)\n\n        tkinter.Label(glob_frame,text=\"Glob:  ⁱ\").pack(side=\"left\")\n\n        self.glob_entry = ttk.Entry(glob_frame)\n        self.glob_entry.insert(0, \"*.cbz\")\n        self.glob_entry.pack(side=\"right\")\n        treeview_frame = ScrolledFrameWidget(self).create_frame()\n\n        self.tree = TreeviewExplorerWidget(master=treeview_frame,selectmode=\"extended\")\n        self.tree.on_select_hooks.append(self.on_treeview_select)\n        self.tree.heading(\"#0\", text='Filename', anchor='n')\n        self.tree.pack(expand=True, fill=\"both\")\n        self.tree.bind(\"<FocusIn>\", lambda x: self.update_search_bar(self.current_search_path))\n\n        footer = Frame(self)\n        footer.pack(side=\"bottom\")\n\n        tkinter.Button(footer,text=\"Accept\", command=self.get_selection).pack()\n\n        self.selection = None\n        if self.current_search_path:\n            self.update_search_bar(self.current_search_path)\n\n    def update_suggestions(self):\n        try:\n            list_of_files = os.listdir(self.entry.get())\n        except NotADirectoryError:\n            list_of_files = []\n        self.entry.set_completion_list(self.entry.get(), list_of_files)\n        self.entry.event_generate(\"<Button-1>\")\n\n    def change_to_entry(self, *_):\n        self.clear_search_chilren()\n        self.entry = entry = TreeAutocompleteCombobox(self.search_bar)\n        entry.set(self.current_search_path)\n        entry.set_completion_list(self.current_search_path, os.listdir(entry.get()))\n        entry.bind(\"<Tab>\",lambda x:self.update_suggestions())\n\n        self.entry.focus()\n        # entry.bind(\"<FocusOut>\", lambda x: self.update_search_bar(self.entry.get()))\n        entry.bind(\"<Return>\", lambda x: self.update_search_bar(self.entry.get()))\n\n        entry.pack(expand=False, fill=\"x\", anchor=\"center\")\n\n    def clear_search_chilren(self, *_):\n        \"\"\"\n        Removes all widgets in the search bar frame\n        :param event:\n        :return:\n        \"\"\"\n        for child in self.search_bar.winfo_children():\n            child.destroy()\n\n    def update_search_bar(self, new_path):\n        \"\"\"\n        Processes the given paths and creates a button instance of each part of the path.\n        Displays the buttons in order in the search frame\n        :param new_path:\n        :return:\n        \"\"\"\n        if not new_path:\n            return\n        if new_path != self.prev_path[-1]:\n            self.prev_path.append(self.current_search_path)\n        else:\n            self.prev_path.pop()\n        self.clear_search_chilren()\n\n        parts = Path(new_path).parts\n        current_iter_path = \"\"\n\n        for i, part in enumerate(parts):\n            current_iter_path = Path(current_iter_path, part) if current_iter_path else part\n            self.current_search_path = current_iter_path\n            s = ttk.Style(self)\n            # s.theme_use('clam')\n            s.configure('flat.TButton', borderwidth=0, width=\"1\", font=1)\n            btn = tkinter.Button(master=self.search_bar,\n                       text=part,\n                       command=lambda x=current_iter_path: self.update_search_bar(x),relief=\"flat\",justify=\"left\", height=1,\n                                 anchor=\"center\",padx=2)\n            btn[\"font\"] = font.Font(size=7)\n            btn.bind('<Button-1>', lambda evenht,x=current_iter_path:self.update_search_bar(x))\n            btn.bind('<Double-Button-1>', self.change_to_entry)\n            btn.pack(side=\"left\", expand=False, fill=\"none\",)\n        self.tree.show_nested_items(current_iter_path,self.glob_entry.get())\n\n    def on_treeview_select(self, *_):\n        \"\"\"\n        When an item is selected in the treeview.\n        If double click and is directory, browse to that folder\n        :param args:\n        :return:\n        \"\"\"\n        selection = self.tree.selection()\n        if not selection or len(selection)>1:\n            return\n        item = self.tree.tree.get(selection[0])\n        if not item.get(\"is_dir\"):\n            return\n        self.update_search_bar(Path(item.get(\"path\")))\n        self.tree.item(self.tree.selection(),open=True)\n        return \"break\"\n    # def update_treeview(self,base_path):\n\n    def get_selection(self):\n        self.selection = [DummyFile(str(Path(self.tree.tree.get(item).get(\"path\")))) for item in self.tree.selection()]\n        self.destroy()\n\n    def get_selected_files(self, *_):\n\n        self.wm_protocol(\"WM_DELETE_WINDOW\", self.destroy)\n        self.wait_window(self)\n        return self.selection\n\n    def exit_btn(self):\n        self.destroy()\n        self.update()\n\n\ndef askopenfiles(parent, *args, **kwargs):\n    from tkinter.filedialog import askopenfiles\n    return askopenfiles(*args,**kwargs)\n    # Fixme\n    # filechooser = FileChooser(parent,*args,**kwargs)\n    # selection = filechooser.get_selected_files()\n    # return selection\n\ndef askdirectory(*args, **kwargs):\n    from tkinter.filedialog import askdirectory\n    return askdirectory(*args, **kwargs)"
  },
  {
    "path": "MangaManager/src/MetadataManager/GUI/MessageBox.py",
    "content": "from typing import Type\n\nfrom src.MetadataManager.GUI.OneTimeMessageBox import OneTimeMessageBox\nfrom src.MetadataManager.GUI.widgets.MessageBoxWidget import MessageBoxWidget, MessageBoxButton\n\n\nclass MessageBoxWidgetFactory:\n    \"\"\"\n    A factory that generates predefined widgets. Return values /buttons are predefined (NO, YES, CANCEL)\n    \"\"\"\n\n    @staticmethod\n    def yes_or_no(parent, title, description):\n        ret = MessageBoxWidget(parent) \\\n            .with_title(title) \\\n            .with_icon(MessageBoxWidget.icon_question) \\\n            .with_description(description) \\\n            .with_actions([MessageBoxButton(0, \"No\"), MessageBoxButton(1, \"Yes\")])\n        return ret.prompt()\n\n    @staticmethod\n    def showerror(parent, title, description):\n        ret = MessageBoxWidget(parent) \\\n            .with_title(title) \\\n            .with_icon(MessageBoxWidget.icon_error) \\\n            .with_description(description) \\\n            .with_actions([MessageBoxButton(1, \"Ok\")])\n\n        return ret.prompt()\n\n    @staticmethod\n    def showwarning(parent, title, description):\n        ret = MessageBoxWidget(parent) \\\n            .with_title(title) \\\n            .with_icon(MessageBoxWidget.icon_warning) \\\n            .with_description(description) \\\n            .with_actions([MessageBoxButton(1, \"Ok\")])\n        return ret.prompt()\n\n    @staticmethod\n    def get_onetime_messagebox() -> Type[OneTimeMessageBox]:\n        return OneTimeMessageBox\n\n    @staticmethod\n    def get_box_button() -> Type[MessageBoxButton]:\n        return MessageBoxButton\n\n"
  },
  {
    "path": "MangaManager/src/MetadataManager/GUI/OneTimeMessageBox.py",
    "content": "from tkinter import Checkbutton, BooleanVar\n\nfrom src.Common.utils import parse_bool\nfrom src.MetadataManager.GUI.widgets.MessageBoxWidget import MessageBoxWidget\nfrom src.Settings import Settings, SettingHeading\n\nDISABLED_MESSAGE_BOX = \"disabled_message_box\"\n\n\nclass OneTimeMessageBox(MessageBoxWidget):\n    \"\"\"\n    Messagebox that implements a checkbox to not show again\n    \"\"\"\n    def __new__(cls, mb_id, *args, **kwargs):\n        if mb_id is None:\n            raise AttributeError(\"mb_id can't be NoneType\")\n        mb_setting = Settings().get_default(SettingHeading.MessageBox, mb_id, False)\n        if parse_bool(mb_setting):\n            cls.disabled = True\n        else:\n            cls.disabled = False\n        return super().__new__(cls, *args, **kwargs)\n\n    def __init__(self, mb_id=None, *args, **kwargs):\n        self.mb_id = mb_id\n        super().__init__(*args, **kwargs)\n        self.with_dontshowagain()\n\n    def with_dontshowagain(self):\n        if not self.disabled:\n            self.dont_show_again_value = BooleanVar(self, value=False, name=\"Dont show again checkbtn\")\n            Checkbutton(self, text=\"Don't show this window again\", variable=self.dont_show_again_value).pack(pady=(10, 0))\n\n    def prompt(self):\n        if self.disabled:\n            return DISABLED_MESSAGE_BOX\n        ret = super().prompt()\n        if self.dont_show_again_value.get():\n            Settings().set(section=SettingHeading.MessageBox, key=self.mb_id, value=True)\n            Settings().save()\n        return ret\n"
  },
  {
    "path": "MangaManager/src/MetadataManager/GUI/__init__.py",
    "content": ""
  },
  {
    "path": "MangaManager/src/MetadataManager/GUI/scrolledframe.py",
    "content": "# Extracted from: https://github.com/alejandroautalan/pygubu/tree/master/pygubu\n\n# encoding: utf8\nfrom __future__ import annotations\n\nimport platform\nimport tkinter as tk\nimport tkinter.ttk as ttk\n\nCONFIGURE = '<Configure>'\n\n\ndef bindings(widget, seq):\n    return [x for x in widget.bind(seq).splitlines() if x.strip()]\n\n\ndef _funcid(binding):\n    return binding.split()[1][3:]\n\n\ndef remove_binding(widget, seq, index=None, funcid=None):\n    b = bindings(widget, seq)\n\n    if index is not None:\n        try:\n            binding = b[index]\n            widget.unbind(seq, _funcid(binding))\n            b.remove(binding)\n        except IndexError:\n            return\n\n    elif funcid:\n        binding = None\n        for x in b:\n            if _funcid(x) == funcid:\n                binding = x\n                b.remove(binding)\n                widget.unbind(seq, funcid)\n                break\n        if not binding:\n            return\n    else:\n        raise ValueError('No index or function id defined.')\n\n    for x in b:\n        widget.bind(seq, '+' + x, 1)\n\n\nclass ApplicationLevelBindManager(object):\n    # Mouse wheel support\n    mw_active_area = None\n    mw_initialized = False\n\n    @staticmethod\n    def on_mousewheel(event):\n        if ApplicationLevelBindManager.mw_active_area:\n            ApplicationLevelBindManager.mw_active_area.on_mousewheel(event)\n\n    @staticmethod\n    def mousewheel_bind(widget):\n        ApplicationLevelBindManager.mw_active_area = widget\n\n    @staticmethod\n    def mousewheel_unbind():\n        ApplicationLevelBindManager.mw_active_area = None\n\n    @staticmethod\n    def init_mousewheel_binding(master):\n        if not ApplicationLevelBindManager.mw_initialized:\n            _os = platform.system()\n            if _os in ('Linux', 'OpenBSD', 'FreeBSD'):\n                master.bind_all(\n                    '<4>', ApplicationLevelBindManager.on_mousewheel, add='+')\n                master.bind_all(\n                    '<5>', ApplicationLevelBindManager.on_mousewheel, add='+')\n            else:\n                # Windows and MacOS\n                master.bind_all(\n                    \"<MouseWheel>\",\n                    ApplicationLevelBindManager.on_mousewheel,\n                    add='+')\n            ApplicationLevelBindManager.mw_initialized = True\n\n    @staticmethod\n    def make_onmousewheel_cb(widget, orient, factor=1):\n        \"\"\"Create a callback to manage mousewheel events\n        orient: string (posible values: ('x', 'y'))\n        widget: widget that implement tk xview and yview methods\n        \"\"\"\n        _os = platform.system()\n        view_command = getattr(widget, orient + 'view')\n        if _os in ('Linux', 'OpenBSD', 'FreeBSD'):\n            def on_mousewheel(event):\n                if event.num == 4:\n                    view_command('scroll', (-1) * factor, 'units')\n                elif event.num == 5:\n                    view_command('scroll', factor, 'units')\n\n        elif _os == 'Windows':\n            def on_mousewheel(event):\n                view_command('scroll', (-1) *\n                             int((event.delta / 120) * factor), 'units')\n\n        elif _os == 'Darwin':\n            def on_mousewheel(event):\n                view_command('scroll', event.delta, 'units')\n        else:\n            # FIXME: unknown platform scroll method\n            def on_mousewheel(*_):\n                pass\n\n        return on_mousewheel\n\n\n# This is used in CoverManager it seems\n# noinspection PyUnresolvedReferences\nclass ScrolledFrame(ttk.Frame):\n    VERTICAL = 'vertical'\n    HORIZONTAL = 'horizontal'\n    BOTH = 'both'\n\n    _framecls = ttk.Frame\n    _sbarcls = ttk.Scrollbar\n\n    # noinspection PyMissingConstructor\n    def __init__(self, master=None, **kw):\n        self.scrolltype = kw.pop('scrolltype', self.VERTICAL)\n        self.usemousewheel = tk.getboolean(kw.pop('usemousewheel', False))\n        self._bindingids = []\n\n        self._framecls.__init__(self, master, **kw)\n\n        self._container = self._framecls(self, width=200, height=200)\n        self._clipper = self._framecls(self._container, width=200, height=200)\n        self.innerframe = self._framecls(self._clipper)\n        self.vsb = self._sbarcls(self._container)\n        self.hsb = self._sbarcls(self._container, orient=\"horizontal\")\n\n        # variables\n        self.hsbOn = 0\n        self.vsbOn = 0\n        self.hsbNeeded = 0\n        self.vsbNeeded = 0\n        self._jfraction = 0.05\n        self._scrollTimer = None\n        self._scrollRecurse = 0\n        self._startX = 0\n        self._start_y = 0\n\n        # configure scroll\n        self.hsb.set(0.0, 1.0)\n        self.vsb.set(0.0, 1.0)\n        self.vsb.config(command=self.yview)\n        self.hsb.config(command=self.xview)\n\n        # grid\n        self._container.pack(expand=True, fill='both')\n        self._clipper.grid(row=0, column=0, sticky=tk.NSEW)\n\n        self._container.rowconfigure(0, weight=1)\n        self._container.columnconfigure(0, weight=1)\n\n        # Whenever the clipping window or scrolled frame change size,\n        # update the scrollbars.\n        self.innerframe.bind(CONFIGURE, self._reposition)\n        self._clipper.bind(CONFIGURE, self._reposition)\n        self.bind(CONFIGURE, self._reposition)\n        self._configure_mousewheel()\n\n    # Set timer to call real reposition method, so that it is not\n    # called multiple times when many things are reconfigured at the\n    # same time.\n    def reposition(self):\n        if self._scrollTimer is None:\n            self._scrollTimer = self.after_idle(self._scrollBothNow)\n\n    # Called when the user clicks in the horizontal scrollbar.\n    # Calculates new position of frame then calls reposition() to\n    # update the frame and the scrollbar.\n    def xview(self, mode=None, value=None, units=None):\n        if isinstance(value, str):\n            value = float(value)\n        if mode is None:\n            return self.hsb.get()\n        elif mode == 'moveto':\n            frame_width = self.innerframe.winfo_reqwidth()\n            self._startX = value * float(frame_width)\n        else:  # mode == 'scroll'\n            clipper_width = self._clipper.winfo_width()\n            if units == 'units':\n                jump = int(clipper_width * self._jfraction)\n            else:\n                jump = clipper_width\n            self._startX = self._startX + value * jump\n\n        self.reposition()\n\n    # Called when the user clicks in the vertical scrollbar.\n    # Calculates new position of frame then calls reposition() to\n    # update the frame and the scrollbar.\n    def yview(self, mode=None, value=None, units=None):\n\n        if isinstance(value, str):\n            value = float(value)\n        if mode is None:\n            return self.vsb.get()\n        elif mode == 'moveto':\n            frame_height = self.innerframe.winfo_reqheight()\n            self._start_y = value * float(frame_height)\n        else:  # mode == 'scroll'\n            clipper_height = self._clipper.winfo_height()\n            if units == 'units':\n                jump = int(clipper_height * self._jfraction)\n            else:\n                jump = clipper_height\n            self._start_y = self._start_y + value * jump\n\n        self.reposition()\n\n    def _reposition(self, *_):\n        self.reposition()\n\n    def _getxview(self):\n\n        # Horizontal dimension.\n        clipper_width = self._clipper.winfo_width()\n        frame_width = self.innerframe.winfo_reqwidth()\n        if frame_width <= clipper_width:\n            # The scrolled frame is smaller than the clipping window.\n\n            self._startX = 0\n            end_scrollX_x = 1.0\n            # use expand by default\n            relwidth = 1\n        else:\n            # The scrolled frame is larger than the clipping window.\n            # use expand by default\n            if self._startX + clipper_width > frame_width:\n                self._startX = frame_width - clipper_width\n                end_scrollX_x = 1.0\n            else:\n                if self._startX < 0:\n                    self._startX = 0\n                end_scrollX_x = (self._startX + clipper_width) / float(frame_width)\n            relwidth = ''\n\n        # Position frame relative to clipper.\n        self.innerframe.place(x=-self._startX, relwidth=relwidth)\n        return (self._startX / float(frame_width), end_scrollX_x)\n\n    def _getyview(self):\n\n        # Vertical dimension.\n        clipper_height = self._clipper.winfo_height()\n        frame_height = self.innerframe.winfo_reqheight()\n        if frame_height <= clipper_height:\n            # The scrolled frame is smaller than the clipping window.\n\n            self._start_y = 0\n            end_scroll_y = 1.0\n            # use expand by default\n            relheight = 1\n        else:\n            # The scrolled frame is larger than the clipping window.\n            # use expand by default\n            if self._start_y + clipper_height > frame_height:\n                self._start_y = frame_height - clipper_height\n                end_scroll_y = 1.0\n            else:\n                if self._start_y < 0:\n                    self._start_y = 0\n                end_scroll_y = (self._start_y + clipper_height) / float(frame_height)\n            relheight = ''\n\n        # Position frame relative to clipper.\n        self.innerframe.place(y=-self._start_y, relheight=relheight)\n        return (self._start_y / float(frame_height), end_scroll_y)\n\n    # According to the relative geometries of the frame and the\n    # clipper, reposition the frame within the clipper and reset the\n    # scrollbars.\n    def _scrollBothNow(self):\n        self._scrollTimer = None\n\n        # Call update_idletasks to make sure that the containing frame\n        # has been resized before we attempt to set the scrollbars.\n        # Otherwise the scrollbars may be mapped/unmapped continuously.\n        self._scrollRecurse = self._scrollRecurse + 1\n        self.update_idletasks()\n        self._scrollRecurse = self._scrollRecurse - 1\n        if self._scrollRecurse != 0:\n            return\n\n        xview = self._getxview()\n        yview = self._getyview()\n        self.hsb.set(xview[0], xview[1])\n        self.vsb.set(yview[0], yview[1])\n\n        require_hsb = self.scrolltype in (self.BOTH, self.HORIZONTAL)\n        self.hsbNeeded = (xview != (0.0, 1.0)) and require_hsb\n        require_vsb = self.scrolltype in (self.BOTH, self.VERTICAL)\n        self.vsbNeeded = (yview != (0.0, 1.0)) and require_vsb\n\n        # If both horizontal and vertical scrollmodes are dynamic and\n        # currently only one scrollbar is mapped and both should be\n        # toggled, then unmap the mapped scrollbar.  This prevents a\n        # continuous mapping and unmapping of the scrollbars.\n        if (self.hsbNeeded != self.hsbOn and\n                self.vsbNeeded != self.vsbOn and\n                self.vsbOn != self.hsbOn):\n            if self.hsbOn:\n                self._toggleHorizScrollbar()\n            else:\n                self._toggleVertScrollbar()\n            return\n\n        if self.hsbNeeded != self.hsbOn:\n            self._toggleHorizScrollbar()\n\n        if self.vsbNeeded != self.vsbOn:\n            self._toggleVertScrollbar()\n\n    def _toggleHorizScrollbar(self):\n\n        self.hsbOn = not self.hsbOn\n\n        if self.hsbOn:\n            self.hsb.grid(row=1, column=0, sticky=tk.EW)\n        else:\n            self.hsb.grid_forget()\n\n    def _toggleVertScrollbar(self):\n\n        self.vsbOn = not self.vsbOn\n\n        if self.vsbOn:\n            self.vsb.grid(row=0, column=1, sticky=tk.NS)\n        else:\n            self.vsb.grid_forget()\n\n    def configure(self, cnf=None, **kw):\n        # noinspection PyProtectedMember\n        args = tk._cnfmerge((cnf, kw))\n        key = 'usemousewheel'\n        if key in args:\n            self.usemousewheel = tk.getboolean(args[key])\n            del args[key]\n            self._configure_mousewheel()\n        self._framecls.configure(self, args)\n\n    config = configure\n\n    def cget(self, key):\n        option = 'usemousewheel'\n        if key == option:\n            return self.usemousewheel\n        return self._framecls.cget(self, key)\n\n    __getitem__ = cget\n\n    def _configure_mousewheel(self):\n        if self.usemousewheel:\n            ApplicationLevelBindManager.init_mousewheel_binding(self)\n\n            if self.hsb and not hasattr(self.hsb, 'on_mousewheel'):\n                self.hsb.on_mousewheel = ApplicationLevelBindManager.make_onmousewheel_cb(\n                    self, 'x', 2)\n            if self.vsb and not hasattr(self.vsb, 'on_mousewheel'):\n                self.vsb.on_mousewheel = ApplicationLevelBindManager.make_onmousewheel_cb(\n                    self, 'y', 2)\n\n            main_sb = self.vsb or self.hsb\n            if main_sb:\n                self.on_mousewheel = main_sb.on_mousewheel\n                bid = self.bind(\n                    '<Enter>',\n                    lambda event: ApplicationLevelBindManager.mousewheel_bind(self),\n                    add='+')\n                self._bindingids.append((self, bid))\n                bid = self.bind('<Leave>',\n                                lambda event: ApplicationLevelBindManager.mousewheel_unbind(),\n                                add='+')\n                self._bindingids.append((self, bid))\n            for s in (self.vsb, self.hsb):\n                if s:\n                    bid = s.bind(\n                        '<Enter>',\n                        lambda event,\n                               scrollbar=s: ApplicationLevelBindManager.mousewheel_bind(scrollbar),\n                        add='+')\n                    self._bindingids.append((s, bid))\n                    if s != main_sb:\n                        bid = s.bind(\n                            '<Leave>',\n                            lambda event: ApplicationLevelBindManager.mousewheel_unbind(),\n                            add='+')\n                        self._bindingids.append((s, bid))\n        else:\n            for widget, bid in self._bindingids:\n                remove_binding(widget, bid)\n"
  },
  {
    "path": "MangaManager/src/MetadataManager/GUI/utils.py",
    "content": "import re\n\nINT_PATTERN = re.compile(\"^-*\\d*(?:,?\\d+|\\.?\\d+)?$\")\n\ndef validate_int(value) -> bool:\n    \"\"\"\n    Validates if all the values in the string matches the int pattern\n    :param value:\n    :return: true if matches\n    \"\"\"\n    ilegal_chars = [character for character in str(value) if not INT_PATTERN.match(character)]\n    return not ilegal_chars\n\n\ndef center(win):\n    \"\"\"\n    centers a tkinter window\n    :param win: the main window or Toplevel window to center\n    \"\"\"\n    win.update_idletasks()\n    width = win.winfo_width()\n    frm_width = win.winfo_rootx() - win.winfo_x()\n    win_width = width + 2 * frm_width\n    height = win.winfo_height()\n    titlebar_height = win.winfo_rooty() - win.winfo_y()\n    win_height = height + titlebar_height + frm_width\n    x = win.winfo_screenwidth() // 2 - win_width // 2\n    y = win.winfo_screenheight() // 2 - win_height // 2\n    win.geometry('{}x{}+{}+{}'.format(width, height, x, y))\n    win.deiconify()"
  },
  {
    "path": "MangaManager/src/MetadataManager/GUI/widgets/AutocompleteComboboxWidget.py",
    "content": "import tkinter\nfrom .MMWidget import MMWidget\nfrom tkinter.ttk import Combobox\n\n\nclass AutocompleteComboboxWidget(MMWidget):\n    def __init__(self, master, cinfo_name, label_text=None, default_values=None, width=None,\n                 force_validation_from_list = True, tooltip:str = None):\n        super(AutocompleteComboboxWidget, self).__init__(master=master)\n        self.name = cinfo_name\n        self.default = \"\"\n        self.set_label(label_text, tooltip)\n        self.widget = Combobox(self, name=cinfo_name.lower(), values=default_values, style=\"Custom.TCombobox\")\n        if width is not None:\n            self.widget.configure(width=width)\n\n        self._completion_list = default_values or []\n        self._hits = []\n        self._hit_index = 0\n        self.position = 0\n\n        self.bind('<KeyRelease>', self.handle_keyrelease)\n        self.widget['values'] = self._completion_list  # Setup our popup menu\n\n    def autocomplete(self, delta=0):\n        \"\"\"autocomplete the Combobox, delta may be 0/1/-1 to cycle through possible hits\"\"\"\n        if delta:  # need to delete selection otherwise we would fix the current position\n            self.widget.delete(self.position, tkinter.END)\n        else:  # set position to end so selection starts where textentry ended\n            self.position = len(self.get())\n        # collect hits\n        _hits = []\n        for element in self._completion_list:\n            if element.lower().startswith(self.widget.get().lower()):  # Match case insensitively\n                _hits.append(element)\n        # if we have a new hit list, keep this in mind\n        if _hits != self._hits:\n            self._hit_index = 0\n            self._hits = _hits\n        # only allow cycling if we are in a known hit list\n        if _hits == self._hits and self._hits:\n            self._hit_index = (self._hit_index + delta) % len(self._hits)\n        # now finally perform the auto completion\n        if self._hits:\n            self.widget.delete(0, tkinter.END)\n            self.widget.insert(0, self._hits[self._hit_index])\n            self.widget.select_range(self.position, tkinter.END)\n\n    def handle_keyrelease(self, event):\n        \"\"\"event handler for the keyrelease event on this widget\"\"\"\n        if event.keysym == \"BackSpace\":\n            self.widget.delete(self.widget.index(tkinter.INSERT), tkinter.END)\n            self.position = self.widget.index(tkinter.END)\n        if event.keysym == \"Left\":\n            if self.position < self.widget.index(tkinter.END):  # delete the selection\n                self.widget.delete(self.position, tkinter.END)\n            else:\n                self.position = self.position - 1  # delete one character\n                self.widget.delete(self.position, tkinter.END)\n        if event.keysym == \"Right\":\n            self.position = self.widget.index(tkinter.END)  # go to end (no selection)\n        if len(event.keysym) == 1:\n            self.autocomplete()\n        # No need for up/down, we'll jump to the popup\n        # list at the position of the autocompletion"
  },
  {
    "path": "MangaManager/src/MetadataManager/GUI/widgets/ButtonWidget.py",
    "content": "import tkinter\nfrom idlelib.tooltip import Hovertip\n\n\nclass ButtonWidget(tkinter.Button):\n    def __init__(self, tooltip=None,image=None, *args, **kwargs):\n        super(ButtonWidget, self).__init__(image=image, *args, **kwargs)\n\n        if tooltip:\n            self.configure(text=self.cget('text') + '  ⁱ')\n            self.tooltip = Hovertip(self, tooltip, 20)"
  },
  {
    "path": "MangaManager/src/MetadataManager/GUI/widgets/CanvasCoverWidget.py",
    "content": "import logging\nimport pathlib\nimport tkinter\nfrom idlelib.tooltip import Hovertip\nfrom os.path import basename\nfrom tkinter import Frame, Label, StringVar, Event, Canvas, NW, CENTER, Button\nfrom tkinter.filedialog import askopenfile\n\nimport _tkinter\nfrom PIL import Image, ImageTk\n\nfrom src.Common import ResourceLoader\nfrom src.Common.LoadedComicInfo.CoverActions import CoverActions\nfrom src.Common.LoadedComicInfo.LoadedComicInfo import LoadedComicInfo\nfrom src.Settings import SettingHeading\nfrom src.Settings.Settings import Settings\n\nlogger = logging.getLogger()\nwindow_width, window_height = 0, 0\naction_template = ResourceLoader.get('cover_action_template.png')\nMULTIPLE_FILES_SELECTED = \"Multiple Files Selected\"\n\n\nclass CanvasCoverWidget(Canvas):\n    overlay_id = None\n    overlay_image = None\n    no_image_warning_id = None\n\n    action_id = None\n    image_id = None\n\n\nclass CoverFrame(Frame):\n    canvas_cover_image_id = None\n    canvas_backcover_image_id = None\n    action_buttons = []\n\n    displayed_cinfo: LoadedComicInfo | None = None\n\n    cover_frame = None\n    backcover_frame = None\n\n    def get_canvas(self, cover_else_backcover: bool = True) -> CanvasCoverWidget:\n        if cover_else_backcover:\n            return self.cover_canvas\n        else:\n            return self.backcover_canvas\n\n    def get_cinfo_cover_data(self):\n        ...\n\n    def resized(self, event: Event):\n\n        global window_width, window_height\n        if window_width != event.width:\n            if 1000 >= event.width:\n                self.hide_back_image()\n                window_width, window_height = event.width, event.height\n\n            elif 1000 < event.width and window_width + 400 < event.width:\n                if not Settings().get(SettingHeading.Main, 'cache_cover_images'):\n                    return\n                self.show_back_image()\n                window_width, window_height = event.width, event.height\n\n    def __init__(self, master):\n        super(CoverFrame, self).__init__(master, highlightbackground=\"black\", highlightthickness=2)\n        self.configure(pady=5)\n        canvas_frame = self\n        master.master.bind(\"<Configure>\", self.resized)\n        self.selected_file_path_var = StringVar(canvas_frame, value=\"No file selected\")\n        self.selected_file_var = StringVar(canvas_frame, value=\"No file selected\")\n        self.cover_subtitle = Label(canvas_frame, background=\"violet\", textvariable=self.selected_file_var)\n        self.cover_subtitle.configure(width=25, compound=\"right\", justify=\"left\")\n        self.selected_file_var.set('No file selected')\n        self.tooltip_filename = Hovertip(self, \"No file selected\", 20)\n        self.cover_subtitle.grid(row=0, sticky=\"nsew\")\n        self.grid_columnconfigure(0, weight=1)\n        images_frame = Frame(canvas_frame)\n\n        images_frame.grid(column=0, row=1, sticky=\"nsew\")\n\n        overlay_image = Image.open(action_template)\n        overlay_image = overlay_image.resize((190, 260), Image.NEAREST)\n\n        # COVER\n        self.cover_frame = Frame(images_frame)\n        self.cover_frame.pack(side=\"left\")\n\n        self.cover_canvas = CanvasCoverWidget(self.cover_frame)\n        self.cover_canvas.configure(background='#878787', height='260', width='190')\n        self.cover_canvas.pack(side=\"top\", expand=False, anchor=CENTER)\n\n        self.cover_canvas.overlay_image = ImageTk.PhotoImage(overlay_image, master=self.cover_canvas)\n        self.cover_canvas.overlay_id = self.cover_canvas.create_image(150, 150, image=self.cover_canvas.overlay_image,\n                                                                      state=\"hidden\")\n        self.cover_canvas.action_id = self.cover_canvas.create_text(150, 285, text=\"\", justify=\"center\", fill=\"yellow\",\n                                                                    font=('Helvetica 15 bold'))\n        self.cover_canvas.no_image_warning_id = self.cover_canvas.create_text(150, 120,\n                                                                              text=\"No Cover!\\nNo image\\ncould be\\nloaded\",\n                                                                              justify=\"center\", fill=\"red\",\n                                                                              state=\"hidden\",\n                                                                              font=('Helvetica 28 bold'))\n        self.cover_canvas.image_id = self.cover_canvas.create_image(0, 0, anchor=NW)\n        self.cover_canvas.scale(\"all\", -1, 1, 0.63, 0.87)\n        self.cover_canvas.tag_lower(self.cover_canvas.image_id)\n        btn_frame = Frame(self.cover_frame)\n        btn_frame.pack(side=\"bottom\", anchor=CENTER, fill=\"x\")\n        btn = Button(btn_frame, text=\"✎\", command=lambda:\n                     self.cover_action(action=CoverActions.REPLACE))\n        btn.pack(side=\"left\", fill=\"x\", expand=True)\n        self.action_buttons.append(btn)\n\n        btn = Button(btn_frame, text=\"🗑\", command=lambda:\n                     self.cover_action(action=CoverActions.DELETE))\n        btn.pack(side=\"left\", fill=\"x\", expand=True)\n        self.action_buttons.append(btn)\n\n        btn = Button(btn_frame, text=\"➕\", command=lambda:\n                     self.cover_action(action=CoverActions.APPEND))\n        btn.pack(side=\"left\", fill=\"x\", expand=True)\n        self.action_buttons.append(btn)\n\n        btn = Button(btn_frame, text=\"Reset\", command=lambda:\n                     self.cover_action(action=CoverActions.RESET))\n        btn.pack(side=\"left\", fill=\"x\", expand=True)\n        self.action_buttons.append(btn)\n\n        # BACK COVER\n        self.backcover_frame = Frame(images_frame)\n        self.backcover_frame.pack(side=\"left\")\n        self.backcover_canvas = CanvasCoverWidget(self.backcover_frame)\n        self.backcover_canvas.configure(background='#878227', height='260', width='190')\n        self.backcover_canvas.pack(side=\"top\", expand=False, anchor=CENTER)\n\n        self.backcover_canvas.overlay_image = ImageTk.PhotoImage(overlay_image, master=self.backcover_canvas)\n        self.backcover_canvas.overlay_id = self.backcover_canvas.create_image(150, 150,\n                                                                              image=self.backcover_canvas.overlay_image,\n                                                                              state=\"hidden\")\n        self.backcover_canvas.action_id = self.backcover_canvas.create_text(150, 285, text=\"\", justify=\"center\",\n                                                                            state=\"hidden\",\n                                                                            fill=\"yellow\", font=('Helvetica 15 bold'))\n        self.backcover_canvas.no_image_warning_id = self.backcover_canvas.create_text(150, 120,\n                                                                              text=\"No Cover!\\nNo image\\ncould be\\nloaded\",\n                                                                              justify=\"center\", fill=\"red\",\n                                                                              state=\"hidden\",\n                                                                              font=('Helvetica 28 bold'))\n        self.backcover_canvas.image_id = self.backcover_canvas.create_image(0, 0, anchor=NW)\n        self.backcover_canvas.scale(\"all\", -1, 1, 0.63, 0.87)\n        self.backcover_canvas.tag_lower(self.backcover_canvas.image_id)\n\n        btn_frame = Frame(self.backcover_frame)\n        btn_frame.pack(side=\"bottom\", anchor=CENTER, fill=\"x\")\n\n        btn = Button(btn_frame, text=\"✎\", command=lambda: self.backcover_action(action=CoverActions.REPLACE))\n        btn.pack(side=\"left\", fill=\"x\", expand=True)\n        self.action_buttons.append(btn)\n\n        btn = Button(btn_frame, text=\"🗑\", command=lambda: self.backcover_action(action=CoverActions.DELETE))\n        btn.pack(side=\"left\", fill=\"x\", expand=True)\n        self.action_buttons.append(btn)\n\n        btn = Button(btn_frame, text=\"➕\", command=lambda: self.backcover_action(action=CoverActions.APPEND))\n        btn.pack(side=\"left\", fill=\"x\", expand=True)\n        self.action_buttons.append(btn)\n\n        btn = Button(btn_frame, text=\"Reset\", command=lambda: self.backcover_action(action=CoverActions.RESET))\n        btn.pack(side=\"bottom\", fill=\"x\", expand=True)\n        self.action_buttons.append(btn)\n\n        self.toggle_action_buttons(False)\n\n    def cover_action(self, loaded_cinfo: LoadedComicInfo = None, auto_trigger=False, action=None, parent=None, proc_update=True):\n        if not parent:\n            parent = self\n        if loaded_cinfo is None:\n            loaded_cinfo = self.displayed_cinfo\n        front_canva: CanvasCoverWidget = self.cover_canvas\n        if action is not None:\n            loaded_cinfo.cover_action = action\n        lcinfo_action = loaded_cinfo.cover_action\n\n        # If the file has a new cover selected, display the new cover and show \"edit overlay\"\n        if loaded_cinfo.new_cover_path:\n            cover = loaded_cinfo.new_cover_cache\n        else:\n            cover = loaded_cinfo.cover_cache\n        if not cover:\n            front_canva.itemconfig(front_canva.overlay_id, image=front_canva.overlay_image, state=\"hidden\")\n            front_canva.itemconfig(front_canva.no_image_warning_id, state=\"normal\")\n            front_canva.itemconfig(front_canva.action_id, text=\"\")\n            front_canva.itemconfig(front_canva.image_id, state=\"hidden\")\n            if proc_update:\n                self.update()\n            return\n        # A cover exists. Hide warning\n        front_canva.itemconfig(front_canva.no_image_warning_id, state=\"hidden\")\n        if proc_update:\n            self.update()\n\n        front_canva.itemconfig(front_canva.overlay_id, image=front_canva.overlay_image, state=\"normal\")\n        front_canva.itemconfig(front_canva.image_id, image=cover, state=\"normal\")\n        match lcinfo_action:\n            case CoverActions.APPEND | CoverActions.REPLACE:\n                # If the function was manually called, ask the user to select the new cover\n                if not auto_trigger:\n                    new_cover_file = askopenfile(parent=parent, initialdir=Settings().get(SettingHeading.Main, 'covers_folder_path')).name\n                    loaded_cinfo.new_cover_path = new_cover_file\n                    cover = loaded_cinfo.new_cover_cache\n                # Show the Action label\n                front_canva.itemconfig(front_canva.action_id,\n                                       text=\"Append\" if\n                                       lcinfo_action == CoverActions.APPEND else \"Replace\", state=\"normal\")\n            case CoverActions.DELETE:\n                front_canva.itemconfig(front_canva.action_id, text=\"Delete\", state=\"normal\")\n            case _:\n                front_canva.itemconfig(front_canva.overlay_id, state=\"hidden\")\n                front_canva.itemconfig(front_canva.action_id, text=\"\", state=\"normal\")\n        # Update the displayed cover\n        front_canva.itemconfig(front_canva.image_id, image=cover, state=\"normal\")\n        self.update()\n\n    def backcover_action(self, loaded_cinfo: LoadedComicInfo = None, auto_trigger=False, action=None, parent=None, proc_update=True):\n        if not parent:\n            parent = self\n        if loaded_cinfo is None:\n            loaded_cinfo = self.displayed_cinfo\n        back_canva: CanvasCoverWidget = self.backcover_canvas\n        if action is not None:\n            # If reset, undo action changes. Forget about the new cover.\n            loaded_cinfo.backcover_action = action\n        lcinfo_action = loaded_cinfo.backcover_action\n\n        # If the file has a new cover selected, display the new cover and show \"edit overlay\"\n        if loaded_cinfo.new_backcover_cache:\n            cover = loaded_cinfo.new_backcover_cache\n        else:\n            cover = loaded_cinfo.backcover_cache\n        if not cover:\n            back_canva.itemconfig(back_canva.overlay_id, image=back_canva.overlay_image, state=\"hidden\")\n            back_canva.itemconfig(back_canva.no_image_warning_id, state=\"normal\")\n            back_canva.itemconfig(back_canva.action_id, text=\"\")\n            back_canva.itemconfig(back_canva.image_id, state=\"hidden\")\n            if proc_update:\n                self.update()\n            return\n        # A cover exists. Hide warning\n        back_canva.itemconfig(back_canva.no_image_warning_id, state=\"hidden\")\n        if proc_update:\n            self.update()\n\n\n        back_canva.itemconfig(back_canva.overlay_id, image=back_canva.overlay_image, state=\"normal\")\n        back_canva.itemconfig(back_canva.image_id, image=cover, state=\"normal\")\n        match lcinfo_action:\n            case CoverActions.APPEND | CoverActions.REPLACE:\n                # If the function was manually called, ask the user to select the new cover\n                if not auto_trigger:\n                    new_cover_file = askopenfile(parent=parent, initialdir=Settings().get(SettingHeading.Main, 'covers_folder_path')).name\n                    loaded_cinfo.new_backcover_path = new_cover_file\n                    cover = loaded_cinfo.new_backcover_cache\n                # Show the Action label\n                back_canva.itemconfig(back_canva.action_id,\n                                      text=\"Append\" if\n                                      lcinfo_action == CoverActions.APPEND else \"Replace\",state=\"normal\")\n            case CoverActions.DELETE:\n                back_canva.itemconfig(back_canva.action_id, text=\"Delete\", state=\"normal\")\n            case _:\n                back_canva.itemconfig(back_canva.overlay_id, state=\"hidden\")\n                back_canva.itemconfig(back_canva.action_id, text=\"\", state=\"normal\")\n        # Update the displayed cover\n        back_canva.itemconfig(back_canva.image_id, image=cover, state=\"normal\")\n        self.update()\n\n    def clear(self):\n        self.tooltip_filename.text = \"No file selected\"\n        try:\n            self.cover_canvas.itemconfig(self.cover_canvas.image_id, state=\"hidden\")\n        except _tkinter.TclError as e:\n            if str(e).startswith('image \"pyimage') and str(e).endswith(f' doesn\\'t exist'):\n                # Handle the case where the image with the given id doesn't exist\n                logger.warning(\"Attempted to configure an item with an image that no longer exists\", exc_info=True)\n            else:\n                # If the error is caused by something else, re-raise the exception\n                raise e\n\n        self.backcover_canvas.itemconfig(self.backcover_canvas.image_id, state=\"hidden\")\n        self.hide_actions()\n\n    def update_cover_image(self, loadedcomicinfo_list: list[LoadedComicInfo], **__):\n        if len(loadedcomicinfo_list) > 1:\n            self.clear()\n            # self.cover_subtitle.configure(text=MULTIPLE_FILES_SELECTED)\n            self.selected_file_var.set(MULTIPLE_FILES_SELECTED)\n            self.selected_file_path_var.set(MULTIPLE_FILES_SELECTED)\n            self.tooltip_filename.text = \"\\n\".join(\n                [basename(loadedcomicinfo.file_path) for loadedcomicinfo in loadedcomicinfo_list])\n            # self.update()\n            self.toggle_action_buttons(False)\n            return\n\n        if not loadedcomicinfo_list:\n            # raise NoFilesSelected()\n            self.toggle_action_buttons(False)\n            return\n\n        loadedcomicinfo = loadedcomicinfo_list[0]\n        if loadedcomicinfo.file_path is None:\n            return\n        self.toggle_action_buttons(True)\n        self.displayed_cinfo = loadedcomicinfo\n        if not loadedcomicinfo.cover_cache and not loadedcomicinfo.backcover_cache:\n            self.clear()\n        else:\n            # self.update_cover_button.grid(column=0, row=1)\n            ...\n        self.tooltip_filename.text = loadedcomicinfo.file_name\n        self.selected_file_var.set(loadedcomicinfo.file_name)\n        self.selected_file_path_var.set(loadedcomicinfo.file_path)\n\n        # Checks to display actions:\n        self.cover_action(loadedcomicinfo, auto_trigger=True)\n        # Update backcover\n        self.backcover_action(loadedcomicinfo, auto_trigger=True)\n\n    def hide_actions(self):\n        self.cover_canvas.itemconfig(self.cover_canvas.overlay_id, state=\"hidden\")\n        self.cover_canvas.itemconfig(self.cover_canvas.action_id, state=\"hidden\")\n\n        self.backcover_canvas.itemconfig(self.backcover_canvas.overlay_id, state=\"hidden\")\n        self.backcover_canvas.itemconfig(self.backcover_canvas.action_id, state=\"hidden\")\n\n    def display_action(self, _: str = None):\n\n        image = Image.open(\n            pathlib.Path(action_template))\n        image = image.resize((190, 260), Image.NEAREST)\n        self.watermark = ImageTk.PhotoImage(image, master=self.cover_canvas)\n        self._watermark_image_id = self.cover_canvas.create_image(150, 150, image=self.watermark)\n        self.cover_canvas.tag_lower(self._image_id)\n        self._text_id = self.cover_canvas.create_text(150, 285, text=\"\", justify=\"center\", fill=\"yellow\",\n                                                      font=('Helvetica 15 bold'))\n\n        self.cover_canvas.scale(\"all\", -1, 1, 0.63, 0.87)\n\n        self.update()\n        self.cover_canvas.itemconfig(self._text_id, text=\"Replace\")\n        self.update()\n        self.cover_canvas.itemconfig(self._text_id, text=\"Delete\")\n        self.update()\n        self.cover_canvas.itemconfig(self._text_id, text=\"Append\")\n        self.update()\n\n    def hide_back_image(self):\n        self.backcover_frame.pack_forget()\n        self.cover_frame.pack(side=\"top\")\n\n    def show_back_image(self):\n        self.cover_frame.pack(side=\"left\")\n        self.backcover_frame.pack(side=\"right\")\n\n    def opencovers(self):\n        ...\n\n    def display_next_cover(self, event):\n        ...\n\n    def toggle_action_buttons(self, enabled=True):\n        for button in self.action_buttons:\n            button:Button\n            try:\n                button.configure(state=\"normal\" if enabled else \"disabled\")\n            except:\n                pass\n"
  },
  {
    "path": "MangaManager/src/MetadataManager/GUI/widgets/ComboBoxWidget.py",
    "content": "from tkinter.ttk import Combobox\n\nfrom src.MetadataManager.GUI.utils import validate_int\nfrom .MMWidget import MMWidget\n\n\nclass ComboBoxWidget(MMWidget):\n    def __init__(self, master, cinfo_name:str, label_text=None, default_values=None, width=None, default=\"\",\n                 validation=None, tooltip: str = None):\n\n        super(ComboBoxWidget, self).__init__(master=master,name=cinfo_name.lower())\n\n        if label_text is None:\n            label_text = cinfo_name\n        self.name = cinfo_name\n        self.default = default\n        self.default_vals = default_values\n        # Label:\n        self.set_label(label_text, tooltip)\n\n        # Input:\n        self.validation = validation\n        vcmd = (self.register(validate_int), '%S')\n        if validation == \"int\":\n            self.widget: Combobox = Combobox(self, name=cinfo_name.lower(), values=default_values,\n                                             validate='key', validatecommand=vcmd)\n        else:\n            self.widget: Combobox = Combobox(self, name=cinfo_name.lower(), values=default_values)\n\n        if width is not None:\n            self.widget.configure(width=width)\n"
  },
  {
    "path": "MangaManager/src/MetadataManager/GUI/widgets/FileMultiSelectWidget.py",
    "content": "import copy\nimport logging\nimport tkinter\nfrom tkinter.ttk import Treeview\n\nfrom src.Common.LoadedComicInfo.LoadedComicInfo import LoadedComicInfo\n\nlogger = logging.getLogger()\n\n\nclass FileMultiSelectWidget(Treeview):\n    def __init__(self, *args, **kwargs):\n        super(FileMultiSelectWidget, self).__init__(*args, **kwargs)\n        self.heading('#0', text='Click to select all files', command=self.select_all)\n        # self.pack(expand=True, side=\"top\")\n        self.bind('<<TreeviewSelect>>', self._on_select)\n        self._hook_items_inserted: list[callable] = []\n        self._hook_items_selected: list[callable] = []\n        self.content = {}\n        self.prev_selection = None\n        self.bind(\"<Button-3>\", self.popup)\n        self.ctx_menu = tkinter.Menu(self, tearoff=0)\n        self.ctx_menu.add_command(label=\"{clicked_file}\", state=\"disabled\")\n        self.ctx_menu.add_separator()\n        self.ctx_menu.add_command(label=\"Open in Explorer\", command=self.open_in_explorer)\n        self.ctx_menu.add_command(label=\"Reset changes\", command=self.reset_loadedcinfo_changes,state=\"disabled\")\n\n\n    def clear(self):\n        self.delete(*self.get_children())\n\n    def select_all(self, *_):\n        for item in self.get_children():\n            self.selection_add(item)\n\n    def get_selected(self) -> list[LoadedComicInfo]:\n        return [self.content.get(item) for item in self.selection()]\n\n    def insert(self, loaded_cinfo: LoadedComicInfo, *args, **kwargs):\n        super(FileMultiSelectWidget, self).insert(\"\", 'end', loaded_cinfo.file_path, text=loaded_cinfo.file_name, tags=(\"darkmode\", \"important\"), *args,\n                                                  **kwargs)\n        self.content[loaded_cinfo.file_path] = loaded_cinfo\n        # self._call_hook_item_inserted(loaded_cinfo)\n        self.select_all()\n\n    def _on_select(self, *_):\n        prev_selection = copy.copy(self.prev_selection)\n        selected = [self.content.get(item) for item in self.selection()]\n        self.prev_selection = selected\n        if not selected:\n            return\n        self._call_hook_item_selected(selected, prev_selection)\n\n    ##################\n    # Hook Stuff\n    ##################\n    def add_hook_item_selected(self, function: callable):\n        self._hook_items_selected.append(function)\n\n    def add_hook_item_inserted(self, function: callable):\n        self._hook_items_inserted.append(function)\n\n    def _call_hook_item_selected(self, loaded_cinfo_list: list[LoadedComicInfo], prev_selection):\n        self._run_hook(self._hook_items_selected, loaded_cinfo_list, prev_selection)\n\n    def _call_hook_item_inserted(self, loaded_comicinfo: LoadedComicInfo):\n        self._run_hook(self._hook_items_inserted, [loaded_comicinfo])\n\n    def popup(self, event):\n        \"\"\"action in event of button 3 on tree view\"\"\"\n        # select row under mouse\n        iid = self.identify_row(event.y)\n        if iid:\n            # mouse pointer over item\n            self.selection_set(iid)\n            self.ctx_menu.entryconfigure(0, label=iid)\n            self.ctx_menu.entryconfigure(2,command=lambda x=iid: self.open_in_explorer(x))\n            self.ctx_menu.post(event.x_root, event.y_root)\n        else:\n            # mouse pointer not over item\n            # occurs when items do not fill frame\n            # no action required\n            pass\n\n    def open_in_explorer(self, event=None):\n        raise NotImplementedError()\n\n    def reset_loadedcinfo_changes(self, event=None):\n        raise NotImplementedError()\n\n    @staticmethod\n    def _run_hook(source: list[callable], *args):\n        for hook_function in source:\n            try:\n                hook_function(*args)\n            except:\n                logger.exception(\"Error calling hook\")\n\n"
  },
  {
    "path": "MangaManager/src/MetadataManager/GUI/widgets/FormBundleWidget.py",
    "content": "from idlelib.tooltip import Hovertip\nfrom tkinter import Frame, Label, Entry, Checkbutton, StringVar, BooleanVar\n\nfrom src.MetadataManager.GUI.widgets import ComboBoxWidget\nfrom src.Settings import SettingControl, SettingSection\n\n\nclass FormBundleWidget(Frame):\n    label: Label\n    input_widget: ComboBoxWidget | Checkbutton | Entry\n    input_var: StringVar | BooleanVar\n    # This is the frame that holds the bundle\n    row: Frame\n    control: SettingControl\n    section: SettingSection\n    validation_error: StringVar\n    # Reference held so UI can render it\n    validation_label: Label\n    validation_row: Frame\n    mapper_fn = None\n\n    def __init__(self, master, mapper_fn,name=None, *_, **kwargs):\n        super(FormBundleWidget, self).__init__(master, name=name, **kwargs)\n\n        self.mapper_fn = mapper_fn\n\n        self.row = Frame(master)\n        self.row.pack(expand=True, fill=\"x\")\n\n        self.validation_row = Frame(master)\n        self.validation_row.pack(expand=True, fill=\"x\")\n\n        self.pack(expand=True, fill='both', side='top')\n\n    def with_label(self, title, tooltip=\"\"):\n        self.label = Label(master=self.row, text=title, width=30, justify=\"right\", anchor=\"e\")\n        if tooltip:\n            self.label.tooltip = Hovertip(self.label, tooltip, 20)\n\n        self.label.pack(side=\"left\")\n        return self\n\n    def with_input(self, control: SettingControl, section: SettingSection):\n        entry, string_var = self.mapper_fn(self.row, control, section)\n        self.control = control\n        self.section = section\n        self.input_widget = entry\n        self.input_var = string_var\n\n        return self\n\n    def build(self):\n        self.validation_error = StringVar()\n        self.validation_error.set(\"\")\n        self.validation_label = Label(master=self.validation_row, width=30, justify=\"right\", anchor=\"e\",\n                                      textvariable=self.validation_error, fg='red')\n\n        self.validation_label.pack(side=\"left\")\n        self.validation_label.pack_forget()\n        return self\n\n    def validate(self):\n        if self.control.validate is None:\n            return True\n        error = self.control.validate(self.control.key, str(self.input_var.get()))\n        self.validation_error.set(error)\n\n        has_error = error != \"\"\n\n        if has_error:\n            self.validation_label.pack()\n        else:\n            self.validation_label.pack_forget()\n\n        return not has_error\n\n    def format_output(self):\n        if self.control.format_value is None:\n            return str(self.input_var.get())\n        return self.control.format_value(self.input_var.get())\n"
  },
  {
    "path": "MangaManager/src/MetadataManager/GUI/widgets/HyperlinkLabelWidget.py",
    "content": "import webbrowser\nfrom tkinter import Frame, Label\n\n\nclass HyperlinkLabelWidget(Frame):\n    def __init__(self, master=None, text=\"\", url=\"\", url_text=None, **kwargs):\n        Frame.__init__(self, master, **kwargs)\n        self.url = url\n        self.label = Label(self,text=text, font=(\"Helvetica\", 12), justify=\"left\")\n        self.label.pack(side=\"left\")\n        self.url_label = Label(self, text=url_text if url_text else url, font=(\"Helvetica\", 12), justify=\"left\")\n        self.url_label.configure(foreground=\"blue\", underline=True)\n        self.url_label.pack(side=\"left\")\n        self.url_label.bind(\"<1>\", lambda e: self.open_url())\n        self.url_label.bind(\"<Enter>\", lambda e: self.configure(cursor=\"hand2\"))\n        self.url_label.bind(\"<Leave>\", lambda e: self.configure(cursor=\"\"))\n\n    def open_url(self):\n        webbrowser.open(self.url)\n\n    def set_text(self, text):\n        self.configure(text=text)\n\n    def set_url(self, url):\n        self.url = url\n"
  },
  {
    "path": "MangaManager/src/MetadataManager/GUI/widgets/LongTextWidget.py",
    "content": "from tkinter.scrolledtext import ScrolledText\n\n\nfrom .MMWidget import MMWidget, _LongText\n\n\nclass LongTextWidget(MMWidget):\n    def __init__(self, master, cinfo_name, label_text=None, width: int = None):\n        super(LongTextWidget, self).__init__(master,name=cinfo_name.lower())\n        if label_text is None:\n            label_text = cinfo_name\n        self.set_label(label_text)\n        self.default = \"\"\n        self.name = cinfo_name\n        # Input\n        self.widget_slave = ScrolledText(self)\n        self.widget_slave.configure(height='5', width=width)\n        self.widget_slave.pack(fill='both', side='top')\n\n        self.widget = _LongText(name=cinfo_name)\n        self.widget.linked_text_field = self.widget_slave\n"
  },
  {
    "path": "MangaManager/src/MetadataManager/GUI/widgets/MMWidget.py",
    "content": "from idlelib.tooltip import Hovertip\nfrom tkinter.ttk import Combobox, OptionMenu, Frame, Label\nfrom tkinter import Text, INSERT, END\n\nfrom src.MetadataManager.GUI.utils import validate_int\n\n# This class is used for LongTextWidget but it itself isn't technically a widget.\n# We have it here as MM needs it but LongTextWidget needs MMWidget\nclass _LongText:\n    \"\"\"\n    Helper class to have a multi line summary input\n    \"\"\"\n\n    linked_text_field: Text | None = None\n    name: str\n    _value: str = \"\"\n\n    def __init__(self, name=None):\n        if name:\n            self.name = name\n\n    def set(self, value: str):\n        \"\"\"\n        Sets the text to be displayed in the input field\n        :param value: The text to be displayed\n        :return:\n        \"\"\"\n        if not self.linked_text_field:  # If it's not defined then UI is not being use. Store value in class variable.\n            self._value = value\n            return  # self._value\n        self.linked_text_field.delete(1.0, END)\n        self.linked_text_field.insert(INSERT, value)\n\n    def clear(self):\n        \"\"\"\n        Clears the input text and sets it to empty string\n        :return:\n        \"\"\"\n        if not self.linked_text_field:\n            self._value = \"\"\n            return\n        self.linked_text_field.delete(1.0, END)\n\n    def get(self) -> str:\n        \"\"\"\n        Returns the value in the input field\n        :return:\n        \"\"\"\n        if not self.linked_text_field:  # If it's not defined then UI is not being use. Store value in class variable.\n            return self._value\n\n        return self.linked_text_field.get(index1=\"1.0\", index2='end-1c')\n\n    def __str__(self):\n        return self.name\n\nclass MMWidget(Frame):\n    validation: str | None = None\n    widget_slave = None\n    widget: Combobox | _LongText | OptionMenu\n    name: str\n    NONE = \"~~# None ##~~\"\n\n    def __init__(self, master,name):\n        super(MMWidget, self).__init__(master,name=name)\n\n    def set(self, value):\n        if value is None:\n            return\n        if not self.validation:\n            self.widget.set(value)\n            return\n\n        if value and validate_int(value):\n            if self.validation == \"rating\" and (float(value) < 0 or float(value) > 10):\n                return\n            self.widget.set(str(int(value)))\n\n    def set_default(self):\n        self.widget.set(\"\")\n\n    def get(self):\n        return self.widget.get()\n\n    def pack(self, **kwargs):\n        widget = self.widget_slave or self.widget\n        widget.pack(fill=\"both\", side=\"top\")\n\n        super(Frame, self).pack(kwargs or {\"fill\": \"both\", \"side\": \"top\"})\n        return self\n\n    def grid(self, row=None, column=None, **kwargs):\n        widget = self.widget_slave or self.widget\n        widget.pack(fill=\"both\", side=\"top\")\n\n        super(Frame, self).grid(row=row, column=column, sticky=\"we\", **kwargs)\n        return self\n\n    def set_label(self, text, tooltip=None):\n        self.label = Label(self, text=text)\n        if text:\n            self.label.pack(side=\"top\")\n        if tooltip:\n            self.label.configure(text=self.label.cget('text') + '  ⁱ')\n            self.label.tooltip = Hovertip(self.label, tooltip, 20)"
  },
  {
    "path": "MangaManager/src/MetadataManager/GUI/widgets/MessageBoxWidget.py",
    "content": "from tkinter import Button, Label, Frame, Toplevel\n\nfrom src.MetadataManager.GUI.scrolledframe import ScrolledFrame\nfrom src.MetadataManager.GUI.utils import center\n\n\nclass MessageBoxButton:\n    def __init__(self, id, title):\n        self.id = id\n        self.title = title\n\n\nclass MessageBoxWidget(Toplevel):\n    \"\"\"A Toplevel widget that represents a message box with a title, content, and buttons.\"\"\"\n\n    icon_warning = \"::tk::icons::warning\"\n    icon_error = \"::tk::icons::error\"\n    icon_information = \"::tk::icons::information\"\n    icon_question = \"::tk::icons::question\"\n\n    selected_value = None\n    disabled = False\n\n    def __init__(self, *args, **kwargs):\n        super().__init__()\n        if self.disabled:\n            self.wm_deiconify()\n            self.destroy()\n            return\n\n        # Setting Geometry\n        self.geometry(\"500x260+100+100\")\n\n        content = Frame(self)\n        self.label = Label(content, text=\"\", font=(\"Helvetica\", 16), justify=\"center\")\n        self.label.configure(wraplength=500)\n        self.label.pack(side=\"top\", fill=\"x\", expand=False)\n\n        self.description = Label(content, text=\"\", font=(\"monospace\", 12), justify=\"center\")\n        self.description.configure(wraplength=500)\n        self.description.pack(side=\"top\", fill=\"x\", expand=False)\n\n        self._scrolled_frame = ScrolledFrame(master=content, scrolltype=\"vertical\", usemousewheel=True)\n        self.content_frame = self._scrolled_frame.innerframe\n\n        content.pack(side=\"top\", fill=\"both\")\n        self.control_frame = Frame(self,height=50)\n        self.control_frame.pack(pady=10, side=\"bottom\", anchor=\"center\")\n\n        # Removing titlebar from the Dialogue\n        self.overrideredirect(False)\n\n        # Making MessageBox Visible\n        center(self)\n\n        # Make the windows always on top\n        self.attributes(\"-topmost\", True)\n        self.lift()\n\n        # Force focus on this window\n        self.grab_set()\n\n    def with_icon(self, icon_path):\n        \"\"\"Adds an icon to the MessageBoxWidget.\n\n        :param icon_path: The path to the icon image.\n        :return: The MessageBoxWidget instance.\n        \"\"\"\n        if not self.disabled:\n            self.label.configure(image=icon_path, compound=\"left\")\n        return self\n\n    def with_title(self, title):\n        \"\"\"\n        Adds a title to the MessageBoxWidget.\n        :param title: The text for the title.\n        :return: The MessageBoxWidget instance.\n        \"\"\"\n        if not self.disabled:\n            self.title = title\n            self.label.configure(text=title)\n\n        return self\n\n    def with_description(self, description):\n        if not self.disabled:\n            self.description.configure(text=description)\n        return self\n\n    def with_content(self, content_frame: Frame):\n        \"\"\"\n        Adds content to the MessageBoxWidget.\n        :param content_frame: The content frame.\n        :return: The MessageBoxWidget instance.\n        \"\"\"\n        if not self.disabled:\n            self._scrolled_frame.pack(fill=\"both\", expand=True)\n            self.content_frame.pack()\n\n        content_frame.master = self.content_frame\n        return self\n\n    def with_actions(self, action_buttons: list[MessageBoxButton]):\n        \"\"\"\n        Adds buttons to the MessageBoxWidget.\n        :param action_buttons: A list of MessageBoxButton instances.\n        :return: The MessageBoxWidget instance.\n        \"\"\"\n        if not self.disabled:\n            # button is a MessageBoxButton class with id, title\n            for button in action_buttons:\n                Button(self.control_frame, text=button.title, padx=10, pady=5, borderwidth=3,\n                       command=lambda btn=button: self._set_selected_value(btn.id)).pack(side=\"left\", ipadx=20)\n                Label(self.control_frame).pack(side=\"left\", padx=5)\n        return self\n\n    def build(self):\n        \"\"\"\n        Builds the MessageBoxWidget.\n        :return: The MessageBoxWidget instance.\n        \"\"\"\n        return self\n\n    def prompt(self):\n        \"\"\"\n        Displays the MessageBoxWidget and waits for a button press.\n        :return: The selected value (the value of the pressed button).\n        \"\"\"\n\n        # Set to wait for window to get deleted.\n        self.wm_protocol(\"WM_DELETE_WINDOW\", self.destroy)\n        self.wait_window(self)\n        # return the value\n        return self.selected_value\n\n    def _set_selected_value(self, value):\n        \"\"\"\n        Sets the selected value of the MessageBoxWidget.\n        :param value: The selected value.\n        \"\"\"\n        self.selected_value = value\n        self.destroy()\n"
  },
  {
    "path": "MangaManager/src/MetadataManager/GUI/widgets/OptionMenuWidget.py",
    "content": "import logging\nimport tkinter\nfrom tkinter.ttk import Combobox\n\nfrom common.models import AgeRating, Formats, YesNo, Manga\nfrom .MMWidget import MMWidget\n\nlogger = logging.getLogger()\n\n\nclass OptionMenuWidget(MMWidget):\n    def __init__(self, master: tkinter.Frame, cinfo_name, label_text=None, width=None, max_width=None, default=None, values=None):\n        super(OptionMenuWidget, self).__init__(master=master,name=cinfo_name.lower())\n        if values is None:\n            values = []\n        if label_text is None:\n            label_text = cinfo_name\n        self.default = default\n        self.name = cinfo_name\n        self.set_label(label_text)\n        # noinspection PyTypeChecker\n        self.widget = tkinter.StringVar(self, name=cinfo_name, value=default)\n        self.widget_slave: Combobox = Combobox(self, textvariable=self.widget)\n        self.widget_slave.configure(state=\"readonly\")\n        if width:\n            self.widget_slave.configure(width=width)\n        self.update_listed_values(self.default, list(values))\n        # noinspection PyUnresolvedReferences\n        if max_width:\n            self.widget_slave.configure(width=max_width)\n\n    def update_listed_values(self, default_selected, values) -> None:\n        self.widget_slave[\"values\"] = list(values)\n        self.widget_slave.set(default_selected)\n\n    def get_options(self) -> list[str]:\n        values_list = []\n        match self.name:\n            case \"AgeRating\":\n                values_list = AgeRating.list()\n            case \"Format\":\n                values_list = list(Formats)\n            case \"BlackAndWhite\":\n                values_list = YesNo.list()\n            case \"Manga\":\n                values_list = Manga.list()\n            case _:\n                logger.error(f\"Unhandled error. '{self.name}' is not a registered widget which can extract options from\")\n        return values_list\n\n    def append_first(self, value: str):\n        self.update_listed_values(value, [value] + self.get_options())\n\n    def remove_first(self):\n        self.update_listed_values(\"\", self.get_options())\n"
  },
  {
    "path": "MangaManager/src/MetadataManager/GUI/widgets/ProgressBarWidget.py",
    "content": "import logging\nimport tkinter\nfrom tkinter.ttk import Progressbar, Style\n\nfrom src.Common.progressbar import ProgressBar\n\nlogger = logging.getLogger()\n\n\nclass ProgressBarWidget(ProgressBar):\n    def __init__(self, parent):\n        pb_frame = tkinter.Frame(parent)\n        pb_frame.pack(expand=False, fill=\"x\")\n        super().__init__()\n\n        self.style = Style(pb_frame)\n        self.style.layout('text.Horizontal.TProgressbar',\n                          [\n                              ('Horizontal.Progressbar.trough',\n                               {\n                                   'children': [\n                                       ('Horizontal.Progressbar.pbar',\n                                        {\n                                            'side': 'left',\n                                            'sticky': 'ns'\n                                        }\n                                        )\n                                   ],\n                                   'sticky': 'nswe'\n                               }\n                               ),\n                              ('Horizontal.Progressbar.label',\n                               {\n                                   'sticky': 'nswe'\n                               }\n                               )\n                          ]\n                          )\n        self.style.configure('text.Horizontal.TProgressbar', text='0 %', anchor='center')\n\n        self.progress_bar = Progressbar(pb_frame, length=10, style='text.Horizontal.TProgressbar',\n                                        mode=\"determinate\")  # create progress bar\n        self.progress_bar.pack(expand=False, fill=\"x\", side=\"top\")\n        self.pb_label_variable = tkinter.StringVar(value=self.label_text)\n        self.pb_label = tkinter.Label(pb_frame, justify=\"right\", textvariable=self.pb_label_variable)\n        self.pb_label.pack(expand=False, fill=\"x\", side=\"right\")\n        logger.debug(\"Initialized progress bar\")\n\n    def update_progress_label(self):\n        self.pb_label_variable.set(self.label_text)\n\n    def _update(self):\n\n        if not self.timer:\n            return\n        if self.processed >= self.total:\n            self.timer.stop()\n        self.update_progress_label()\n        self.style.configure('text.Horizontal.TProgressbar',\n                             text='{:g} %'.format(round(self.percentage, 2)))  # update label\n        self.progress_bar['value'] = self.percentage\n        self.progress_bar.update()\n"
  },
  {
    "path": "MangaManager/src/MetadataManager/GUI/widgets/ScrolledFrameWidget.py",
    "content": "import tkinter\nfrom tkinter import Frame\n\nfrom src.MetadataManager.GUI.scrolledframe import ScrolledFrame\n\n\nclass ScrolledFrameWidget(ScrolledFrame):\n    def __init__(self, master, *_, **kwargs):\n        super(ScrolledFrameWidget, self).__init__(master, **kwargs)\n        self.configure(usemousewheel=True)\n        self.paned_window = tkinter.PanedWindow(self.innerframe)\n        self.paned_window.pack(fill=\"both\", expand=False)\n        self.pack(expand=False, fill='both', side='top')\n\n    def create_frame(self, **kwargs):\n        \"\"\"Creates a subframe and packs it\"\"\"\n        frame = Frame(self.paned_window)\n        frame.pack(**kwargs or {})\n        # frame.pack()\n        self.paned_window.add(frame)\n        return frame"
  },
  {
    "path": "MangaManager/src/MetadataManager/GUI/widgets/WidgetManager.py",
    "content": "from .OptionMenuWidget import OptionMenuWidget\nfrom .LongTextWidget import LongTextWidget\nfrom .ComboBoxWidget import ComboBoxWidget\n\n\nclass WidgetManager:\n    cinfo_tags: list[str] = list()\n\n    def get_widget(self, name) -> ComboBoxWidget | LongTextWidget | OptionMenuWidget:\n        return getattr(self, name)\n\n    def add_widget(self, name, widget_frame: ComboBoxWidget | LongTextWidget | OptionMenuWidget):\n        self.cinfo_tags.append(name)\n        setattr(self, name, widget_frame)\n\n    def __setattr__(self, key, value):\n        self.cinfo_tags.append(key)\n        object.__setattr__(self, key, value)\n\n    def clean_widgets(self):\n        for widget_name in self.__dict__:\n            widget = self.get_widget(widget_name)\n            widget.set_default()\n            if isinstance(widget, type(ComboBoxWidget)):\n                widget.widget['values'] = widget.default_vals or []\n\n    def toggle_widgets(self, enabled=True):\n        for widget_name in self.__dict__:\n            widget = self.get_widget(widget_name)\n            if isinstance(widget, type(OptionMenuWidget)):\n                widget.widget_slave.configure(state=\"normal\" if enabled else \"disabled\")\n            elif isinstance(widget, LongTextWidget):\n                #widget.widget_slave.configure(state=\"normal\" if enabled else \"readonly\")\n                pass\n            elif isinstance(widget, ComboBoxWidget):\n                widget.widget.configure(state=\"normal\" if enabled else \"disabled\")\n\n    def get_tags(self):\n        return [tag for tag in self.cinfo_tags]"
  },
  {
    "path": "MangaManager/src/MetadataManager/GUI/widgets/__init__.py",
    "content": "from .MMWidget import MMWidget\nfrom .OptionMenuWidget import OptionMenuWidget\nfrom .LongTextWidget import LongTextWidget\nfrom .ScrolledFrameWidget import ScrolledFrameWidget\nfrom .FileMultiSelectWidget import FileMultiSelectWidget\nfrom .ProgressBarWidget import ProgressBarWidget\nfrom .ButtonWidget import ButtonWidget\nfrom .WidgetManager import WidgetManager\nfrom .ComboBoxWidget import ComboBoxWidget\nfrom .AutocompleteComboboxWidget import AutocompleteComboboxWidget\nfrom .HyperlinkLabelWidget import HyperlinkLabelWidget\nfrom .CanvasCoverWidget import CanvasCoverWidget\n"
  },
  {
    "path": "MangaManager/src/MetadataManager/GUI/windows/AboutWindow.py",
    "content": "import logging\nimport tkinter\nfrom typing import NamedTuple\n\nimport requests\n\nfrom src.MetadataManager.GUI.widgets import HyperlinkLabelWidget, ButtonWidget\nfrom src.__version__ import __version__\n\nlog = logging.getLogger('AboutWindow')\n\nVersions = NamedTuple('Versions', [('prev_latest', str), ('latest', str), ('prev_nightly', str)])\n\ndef get_release_tag() -> Versions:\n    \"\"\"\n    Get the latest release tag from GitHub\n    :return: previous latest release tag, latest release tag\n    \"\"\"\n    # Replace these values with your own\n    username = 'MangaManagerOrg'\n    repo_name = 'Manga-Manager'\n\n    # Make a GET request to the GitHub API endpoint to get the releases\n    response = requests.get(f'https://api.github.com/repos/{username}/{repo_name}/releases', params={'per_page': 100})\n\n    # Parse the JSON response\n    data = response.json()\n\n    # Filter out pre-releases\n\n    latest_release:dict = None\n    prev_latest_release:dict = None\n    nightly_release:dict = None\n\n\n    for release in data:\n        if latest_release and prev_latest_release and nightly_release:\n            break\n\n        if release['draft']:\n            continue\n\n        if release['prerelease']:\n            if not nightly_release:\n                nightly_release = release\n        else:\n            if not latest_release:\n                latest_release = release\n            else:\n                prev_latest_release = latest_release\n\n    if nightly_release['published_at'] < latest_release['published_at']:\n        nightly_release = latest_release\n\n    return Versions(prev_latest_release[\"tag_name\"], latest_release[\"tag_name\"],nightly_release[\"tag_name\"])\n\n\nclass AboutWindow:\n    top_level = None\n    frame = None\n\n    def __init__(self, parent):\n        self.top_level = tkinter.Toplevel(parent)\n        self.frame = tkinter.Frame(self.top_level)\n        self.frame.pack(pady=30, padx=30, fill=\"both\")\n        HyperlinkLabelWidget(self.frame, \"Github repo:\", url_text=\"Go to Github rework main page\",\n                             url=\"https://github.com/MangaManagerORG/Manga-Manager/tree/rework/master\") \\\n            .pack(fill=\"x\", expand=True, side=\"top\", anchor=\"center\")\n        HyperlinkLabelWidget(self.frame, \"Get support:\", url_text=\"Join MangaManager channel in Kavita discord\",\n                             url=\"https://discord.gg/kavita-821879810934439936\")\\\n            .pack(fill=\"x\", expand=True, side=\"top\", anchor=\"center\")\n        HyperlinkLabelWidget(self.frame, \"Report issue in GitHub\", url_text=\"Create GitHub Issue\",\n                             url=\"https://github.com/MangaManagerORG/Manga-Manager/issues/new?assignees=ThePromidius&labels=Rework+Issue&template=rework_issue.md&title=%5BRework+Issue%5D\").pack(\n            fill=\"x\", expand=True, side=\"top\", anchor=\"center\")\n        HyperlinkLabelWidget(self.frame, \"Donate in Ko-fi\",\n                             \"https://ko-fi.com/thepromidius\")\\\n            .pack(fill=\"x\", expand=True, side=\"top\", anchor=\"center\")\n        tkinter.Label(self.frame, text=\"\", font=(\"Helvetica\", 12), justify=\"left\")\\\n            .pack(fill=\"x\", expand=True, side=\"top\", anchor=\"center\")\n\n        tkinter.Label(self.frame, text=\"Software licensed under the GNU General Public License v3.0\",\n                      font=(\"Helvetica\", 12), justify=\"left\").pack(fill=\"x\", expand=True, side=\"top\", anchor=\"center\")\n\n        version_url = \"https://github.com/MangaManagerORG/Manga-Manager/releases/latest\"\n        parsed_version = __version__.split(\":\")\n        version = __version__\n        releases = get_release_tag()\n        if len(parsed_version) > 2:\n            if parsed_version[1].startswith(\"nightly\"):\n                version_url = f\"https://github.com/MangaManagerOrg/Manga-Manager/compare/{releases.prev_nightly}...{parsed_version[2]}\"\n            if parsed_version[1].startswith(\"stable\"):\n\n                version_url = f\"https://github.com/MangaManagerOrg/Manga-Manager/compare/{releases.prev_latest}...{releases.latest}\"\n                version = f\"{parsed_version[0]}:stable\"\n        HyperlinkLabelWidget(self.frame, \"Version number\", url_text=version,\n                             url=version_url).pack(fill=\"x\", expand=True, side=\"top\", anchor=\"center\", pady=10)\n        # create close button\n        ButtonWidget(master=self.frame, text=\"Close\", command=self.close).pack()\n\n    def close(self):\n        self.top_level.destroy()\n"
  },
  {
    "path": "MangaManager/src/MetadataManager/GUI/windows/DragAndDrop.py",
    "content": "import re\nimport tkinter as tk\nfrom tkinterdnd2 import DND_FILES, TkinterDnD\ndef extract_paths_from_string(input_string):\n    return re.findall(r'{(.*?)}', input_string)\nclass DragAndDropFilesApp(TkinterDnD.Tk):\n    def __init__(self):\n        super().__init__()\n\n        self.title(\"Drag and Drop Files\")\n        self.geometry(\"400x400\")\n\n        self.lb = tk.Listbox(self)\n        self.lb.insert(1, \"Drag files here\")\n\n        # Register the Listbox as a drop target\n        self.lb.drop_target_register(DND_FILES)\n        self.lb.dnd_bind('<<Drop>>', self.on_drop)\n\n        self.lb.pack(fill=\"both\")\n\n    def on_drop(self, event):\n        # Get the list of filenames from the dropped data\n        files_str = event.data\n        files = extract_paths_from_string(files_str)\n\n        # Split the string into individual filenames\n        # Process the dropped files and add their filenames to the Listbox\n        for file in files:\n            self.lb.insert(tk.END, file)\n\nif __name__ == \"__main__\":\n    app = DragAndDropFilesApp()\n    app.mainloop()"
  },
  {
    "path": "MangaManager/src/MetadataManager/GUI/windows/LoadingWindow.py",
    "content": "import tkinter\nfrom unittest.mock import Mock\n\nfrom src.Common.progressbar import ProgressBar\nfrom src.MetadataManager.GUI.utils import center\nfrom src.MetadataManager.GUI.widgets import ProgressBarWidget\n\n\nclass LoadingWindow(tkinter.Toplevel):\n    initialized: bool = False\n    # abort_flag:bool = None\n    def __new__(cls, total, *args, **kwargs):\n        if total <=1:\n            a = Mock()\n            a.is_abort = lambda :False\n            a.abort_flag = False\n            return a\n\n        else:\n            return super(LoadingWindow, cls).__new__(cls)\n\n    def __init__(self, total):\n        super().__init__()\n        content = tkinter.Frame(self,background=\"white\",borderwidth=3,border=3,highlightcolor=\"black\",highlightthickness=2,highlightbackground=\"black\")\n        content.pack(ipadx=20, ipady=20,expand=False,fill=\"both\")\n\n        self.title = \"Loading Files\"\n        self.loading_label_value = tkinter.StringVar(content, name=\"Loading_label\")\n        self.loading_label = tkinter.Label(content, textvariable=self.loading_label_value)\n        # Removing titlebar from the Dialogue\n        self.geometry(\"300x100+30+30\")\n        # Make the windows always on top\n        self.attributes(\"-topmost\", True)\n        self.lift()\n        self.abort_flag = False\n        # Force focus on this window\n        self.grab_set()\n        center(self)\n\n        self.overrideredirect(True)\n\n        self.pb = ProgressBarWidget(content)\n\n        self.pb.pb_label.configure(justify=\"center\",background=\"white\")\n        self.pb.pb_label.pack(expand=False, fill=\"x\", side=\"top\")\n        self.pb.set_template(f\"Loaded:{ProgressBar.PROCESSED_TAG}/{ProgressBar.TOTAL_TAG}\\n\")\n        self.pb.start(total)\n\n        abort_btn = tkinter.Button(content,text=\"Abort\",command=self.set_abort)\n        abort_btn.pack()\n        self.initialized = True\n    def is_abort(self):\n        return self.abort_flag\n    def set_abort(self,*_):\n        if self.initialized:\n            self.abort_flag = True\n            self.pb.set_template(\"Aborting...\\n\")\n            self.after(2000, self.finish_loading)\n    #\n    #     self.pb = ProgressBar()\n    #     self.pb.set_template(f\"Loaded:{ProgressBar.PROCESSED_TAG}/{ProgressBar.TOTAL_TAG}\\n\")\n\n    def loaded_file(self, value: str):\n        if self.initialized:\n            self.pb.set_template(f\"Loading: {ProgressBar.PROCESSED_TAG}/{ProgressBar.TOTAL_TAG}\\nLast loaded: '{value}'\")\n            self.pb.increase_processed()\n\n    def finish_loading(self):\n        if self.initialized:\n            self.grab_release()\n            self.destroy()\n\n\n\nif __name__ == '__main__':\n    root = tkinter.Tk()\n    a = LoadingWindow(2,False)\n    a.loaded_file(\"asda\")\n    root.mainloop()\n"
  },
  {
    "path": "MangaManager/src/MetadataManager/GUI/windows/MainWindow.py",
    "content": "import json\nimport re\nimport tkinter\nfrom tkinter import Frame, ttk\nfrom tkinter.ttk import Notebook\n\nfrom tkinterdnd2 import DND_FILES\n\nfrom src.__version__ import __version__\nfrom common.models import Formats, AgeRating\nfrom src.Common import ResourceLoader\nfrom src.MetadataManager.CoverManager.CoverManager import CoverManager\nfrom src.MetadataManager.GUI.ExceptionWindow import ExceptionFrame\nfrom src.MetadataManager.GUI.widgets import ScrolledFrameWidget, ButtonWidget, FileMultiSelectWidget, ProgressBarWidget, \\\n    ComboBoxWidget, LongTextWidget, OptionMenuWidget\nfrom src.MetadataManager.GUI.widgets.CanvasCoverWidget import CoverFrame\nfrom src.MetadataManager.MetadataManagerGUI import GUIApp\n\nwith open(ResourceLoader.get('languages.json'), 'r', encoding=\"utf-8\") as f:\n    data = json.loads(f.read())\n    languages = [language[\"isoCode\"] for language in data]\n\nEXTRACT_PATHS = re.compile(r'{(.*?)}')\nclass MainWindow(GUIApp):\n    # The clear button\n    clear_btn = None\n    fetch_online_btn = None\n    process_btn = None\n    fill_from_filename_btn = None\n    cover_manager_btn = None\n\n    def __init__(self):\n        super().__init__()\n        self.title(\"Manga Manager: v\" + __version__.split(':')[0])\n\n        #########################################################\n        # GUI Display Methods\n        ############\n\n        # Overview LAYOUT\n        self.control_frame_top = Frame(self.main_frame,name=\"control_frame\")\n        self.control_frame_top.pack(fill=\"x\", side=\"top\",padx=30,pady=3)\n        self.display_menu_bar()\n        ttk.Separator(self.main_frame, orient='horizontal').pack(fill=\"x\")\n\n        mid_content_frame = Frame(self.main_frame,name=\"mid_content_frame\")\n        mid_content_frame.pack(fill=\"both\", expand=True)\n        self.file_selection_frame_left = Frame(mid_content_frame,name=\"file_selection_frame\")\n        self.file_selection_frame_left.pack(side=\"left\", padx=30, expand=False, fill=\"both\")\n        self.display_side_bar()\n\n        self.main_content_frame_right = Frame(mid_content_frame,pady=10,name=\"main_content_frame_right\")\n        self.main_content_frame_right.pack(fill=\"both\", side=\"right\", expand=True, padx=(0, 20))\n        self.init_main_content_frame()\n        self.display_main_content_widgets()\n\n        ttk.Separator(self.main_frame, orient='horizontal').pack(fill=\"x\")\n        self.selection_progress_frame_bottom = Frame(self.main_frame)\n        self.selection_progress_frame_bottom.pack(fill=\"x\", side=\"bottom\", pady=(5, 2))\n        self.display_bottom_frame()\n\n    def display_side_bar(self) -> None:\n        ################\n        # Sidebar actions and covers\n        ################\n        self.side_info_frame = self.file_selection_frame_left\n\n        # Show Selected Files - ListBox\n        self.files_selected_frame = tkinter.LabelFrame(self.side_info_frame)\n\n        self.files_selected_frame.selected_files_label = tkinter.Label(self.files_selected_frame, text=\"Opened Files:\")\n        self.files_selected_frame.selected_files_label.pack(expand=False, fill=\"x\")\n        self.selected_files_treeview = FileMultiSelectWidget\n        self.selected_files_treeview.open_in_explorer = self._treeview_open_explorer\n        self.selected_files_treeview.reset_loadedcinfo_changes = self._treview_reset\n        self.selected_files_treeview = self.selected_files_treeview(self.files_selected_frame)#, padding=[-15, 0, 0, 0])  # padding -15 to remove the left indent\n\n        self.selected_files_treeview.drop_target_register(DND_FILES)\n        self.selected_files_treeview.dnd_bind('<<Drop>>', self.on_drop)\n\n        self.selected_files_treeview.pack(expand=True, fill=\"both\")\n        # Selected Covers\n        self.image_cover_frame = CoverFrame(self.side_info_frame)\n\n        self.selected_files_treeview.add_hook_item_selected(self.on_file_selection_preview)\n\n        # 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\n        self.image_cover_frame.pack(expand=False, fill='x')\n        self.files_selected_frame.pack(expand=True, fill=\"both\", pady=(20, 0))\n\n    def display_menu_bar(self) -> None:\n        # Action Buttons\n        control_frame = self.control_frame_top\n\n        btn = ButtonWidget(master=control_frame, text=\"Open Files\",\n                           tooltip=\"Load the metadata and cover to edit them (Ctrl+O)\")\n        btn.configure(image=self.open_file_icon, command=self.select_files, compound=\"left\")\n        btn.pack(side=\"left\", fill=\"y\", padx=(0, 5))\n        self.control_mngr.append(btn)\n\n        btn = ButtonWidget(master=control_frame, text=\"Open Folder\")\n        btn.configure(image=self.open_folder_icon, command=self.select_folder, compound=\"left\")\n        btn.pack(side=\"left\", fill=\"y\", padx=(0, 5))\n        self.control_mngr.append(btn)\n\n        self.clear_btn = ButtonWidget(master=control_frame, text=\"Clear\", tooltip=\"Clean the metadata from the current view\")\n        self.clear_btn.configure(image=self.clear_icon, command=self.widget_mngr.clean_widgets, compound=\"left\")\n        self.clear_btn['state'] = 'disabled'\n        self.clear_btn.pack(side=\"left\", fill=\"y\", padx=(0, 5))\n        self.control_mngr.append(self.clear_btn)\n\n        self.fetch_online_btn = ButtonWidget(master=control_frame, text=\"Fetch\\n  Online\")\n        self.fetch_online_btn.configure(image=self.fetch_online_icon, command=self.process_fetch_online, compound=\"left\")\n        self.fetch_online_btn['state'] = 'disabled'\n        self.fetch_online_btn.pack(side=\"left\", fill=\"y\", padx=(0, 5))\n        self.control_mngr.append(self.fetch_online_btn)\n\n        self.process_btn = ButtonWidget(master=control_frame, text=\"Process\", tooltip=\"Save the metadata and cover changes (Ctrl+S)\")\n        self.process_btn.configure(command=self.pre_process, image=self.save_icon, compound=\"left\")\n        self.process_btn['state'] = 'disabled'\n        self.process_btn.pack(side=\"left\", fill=\"y\", padx=(0, 5))\n        self.control_mngr.append(self.process_btn)\n\n        self.fill_from_filename_btn = ButtonWidget(master=control_frame, text=\"Filename Fill\", tooltip=\"Fill data from Filename\")\n        self.fill_from_filename_btn.configure(image=self.filename_fill_icon, command=self.fill_from_filename, compound=\"left\")\n        self.fill_from_filename_btn['state'] = 'disabled'\n        self.fill_from_filename_btn.pack(side=\"left\", fill=\"y\", padx=(0, 5))\n        self.control_mngr.append(self.fill_from_filename_btn)\n\n        self.cover_manager_btn = ButtonWidget(master=control_frame, text=\"Cover Manager\", tooltip=\"Opens covermanager for the loaded files\")\n        self.cover_manager_btn.configure(command=lambda: CoverManager(self, self))\n        self.cover_manager_btn['state'] = 'disabled'\n        self.cover_manager_btn.pack(side=\"left\", fill=\"y\", padx=(0, 5))\n        self.control_mngr.append(self.cover_manager_btn)\n\n    def init_main_content_frame(self) -> None:\n        self.notebook = Notebook(self.main_content_frame_right)\n        self.notebook.pack(expand=True, fill=\"both\")\n\n        tab_1 = ScrolledFrameWidget(self.notebook, scrolltype=\"vertical\")\n        self.basic_info_frame = tab_1.create_frame()\n\n        self.notebook.add(tab_1, text=\"Basic Info\")\n\n        tab_2 = ScrolledFrameWidget(self.notebook, scrolltype=\"vertical\")\n        self.people_info_frame = tab_2.create_frame()\n        # self.people_info_frame.configure(padx=20)\n        self.notebook.add(tab_2, text=\"People Info\")\n\n        tab_3 = ScrolledFrameWidget(self.notebook, scrolltype=\"vertical\")\n        self.numbering_info_frame = tab_3.create_frame()\n        # self.numbering_info_frame.configure(padx=20)\n        self.notebook.add(tab_3, text=\"Extended\")\n\n        extension_tab = ScrolledFrameWidget(self.notebook, scrolltype=\"Vertical\")\n        self.extensions_tab_frame = extension_tab.create_frame()\n        self.notebook.add(extension_tab, text=\"Extensions\")\n\n        errors_tab = ScrolledFrameWidget(self.notebook, scrolltype=\"Vertical\")\n        errors_tab.pack(fill=\"both\",expand=True)\n        errors_tab.paned_window.pack(fill=\"both\",expand=True)\n        self.errors_tab_frame = errors_tab.create_frame(fill=\"both\",expand=True)\n        self.notebook.add(errors_tab, text=\"Errors\")\n        ExceptionFrame(master=self.errors_tab_frame,is_test=self.is_test).pack(fill=\"both\",expand=True)\n\n        self.display_extensions(self.extensions_tab_frame)\n\n        self.changes_saved = tkinter.Label(master=self, text=\"Changes are not saved\", font=('Arial', 10))\n        self.focus()\n\n    def display_main_content_widgets(self) -> None:\n\n        #################\n        # Basic info - first column\n        #################\n        parent_frame = Frame(self.basic_info_frame, padx=20)\n        parent_frame.pack(side=\"right\", expand=True, fill=\"both\")\n        frame = Frame(parent_frame)\n        frame.pack(fill=\"both\", side=\"top\")\n        label = tkinter.Label(frame, text=\"Series\")\n        label.pack(fill=\"x\", expand=False, side=\"top\")\n        self.widget_mngr.Series = ComboBoxWidget(frame, cinfo_name=\"Series\", label_text=\"\",\n                                                 tooltip=\"The name of the series\").pack(side=\"left\", expand=True,\n                                                                                        fill=\"x\")\n        self.widget_mngr.Series.label = label\n        btn = ButtonWidget(master=frame, text=\"⋯\", tooltip=\"If one file selected, load the filename\",\n                     command=self._fill_filename)\n        btn.pack(side=\"right\")\n        self.control_mngr.append(btn)\n        btn = ButtonWidget(master=frame, text=\"⋯F\", tooltip=\"If one file selected, load the FOLDER name\",\n                     command=self._fill_foldername)\n        btn.pack(side=\"right\")\n        self.control_mngr.append(btn)\n        self.widget_mngr.LocalizedSeries = ComboBoxWidget(parent_frame, cinfo_name=\"LocalizedSeries\",\n                                                          label_text=\"LocalizedSeries\",\n                                                          tooltip=\"The translated series name\").pack()\n        self.widget_mngr.SeriesSort = ComboBoxWidget(parent_frame, cinfo_name=\"SeriesSort\",\n                                                     label_text=\"Series Sort\").pack()\n\n        self.widget_mngr.Title = ComboBoxWidget(parent_frame, cinfo_name=\"Title\",\n                                                tooltip=\"The title of the chapter\").pack()\n\n        # Summary and Review widget\n        long_text_notebook = Notebook(parent_frame, height=95)\n        long_text_notebook.pack(fill=\"x\", expand=False, pady=(14, 5))\n\n        tab = ScrolledFrameWidget(long_text_notebook, scrolltype=\"vertical\")\n        summary_frame = tab.create_frame(fill=\"both\", expand=True)\n        long_text_notebook.add(tab, text=\"Summary\")\n        self.widget_mngr.Summary = LongTextWidget(summary_frame, cinfo_name=\"Summary\", label_text=\"\").pack(fill=\"both\",\n                                                                                                           expand=\"True\")\n\n        tab = ScrolledFrameWidget(long_text_notebook, scrolltype=\"vertical\",)\n        review_frame = tab.create_frame(fill=\"both\",expand=True)\n        long_text_notebook.add(tab, text=\"Review\")\n        self.widget_mngr.Review = LongTextWidget(review_frame, cinfo_name=\"Review\", label_text=\"\").pack(fill=\"both\",\n                                                                                                        expand=\"True\")\n\n        self.widget_mngr.Genre = ComboBoxWidget(parent_frame, cinfo_name=\"Genre\").pack()\n        self.widget_mngr.Tags = ComboBoxWidget(parent_frame, cinfo_name=\"Tags\").pack()\n        self.widget_mngr.Web = ComboBoxWidget(parent_frame, cinfo_name=\"Web\").pack()\n\n        combo_width = 17\n        numbering = Frame(parent_frame)\n        numbering.columnconfigure(\"all\", weight=0)\n        numbering.pack(fill=\"both\", expand=True)\n\n        self.widget_mngr.Number = ComboBoxWidget(numbering, \"Number\", width=combo_width,\n                                                 tooltip=\"The chapter absolute number\") \\\n            .pack(side=\"left\", expand=False, fill=\"x\")\n        self.widget_mngr.Volume = ComboBoxWidget(numbering, \"Volume\", width=combo_width,\n                                                 validation=\"int\", default=\"-1\") \\\n            .pack(side=\"left\", expand=False, fill=\"x\", padx=(10, 0))\n\n        self.widget_mngr.Count = ComboBoxWidget(numbering, \"Count\", width=combo_width,\n                                                validation=\"int\", default=\"-1\") \\\n            .pack(side=\"left\", expand=False, fill=\"x\", padx=(10, 0))\n        self.widget_mngr.Format = OptionMenuWidget(numbering, \"Format\", \"Format\", combo_width, 18, \"\",\n                                                   Formats) \\\n            .pack(side=\"left\", expand=False, fill=\"x\", padx=(10, 0))\n        self.widget_mngr.Manga = OptionMenuWidget(numbering, \"Manga\", \"Manga\", combo_width, 18,\n                                                  \"Unknown\", (\"Unknown\", \"Yes\", \"No\", \"YesAndRightToLeft\")) \\\n            .pack(side=\"left\", expand=False, fill=\"x\", padx=(10, 0))\n\n        numbering2 = Frame(parent_frame)\n        numbering2.columnconfigure(\"all\", weight=0)\n        numbering2.pack(fill=\"both\", expand=True)\n\n        self.widget_mngr.Year = ComboBoxWidget(numbering2, \"Year\", width=combo_width,\n                                               validation=\"int\", default=\"-1\") \\\n            .pack(side=\"left\", expand=False, fill=\"x\")\n        self.widget_mngr.Month = ComboBoxWidget(numbering2, \"Month\", width=combo_width,\n                                                validation=\"int\", default=\"-1\") \\\n            .pack(side=\"left\", expand=False, fill=\"x\", padx=(10, 0))\n        self.widget_mngr.Day = ComboBoxWidget(numbering2, \"Day\", width=combo_width,\n                                              validation=\"int\", default=\"-1\") \\\n            .pack(side=\"left\", expand=False, fill=\"x\", padx=(10, 0))\n        self.widget_mngr.AgeRating = OptionMenuWidget(numbering2, \"AgeRating\", \"Age Rating\", combo_width, 18,\n                                                      \"Unknown\", AgeRating.list()) \\\n            .pack(side=\"left\", expand=False, fill=\"x\", padx=(10, 0))\n\n        self.widget_mngr.LanguageISO = ComboBoxWidget(numbering2, \"LanguageISO\", label_text=\"Language ISO\",\n                                                      width=combo_width + 1, default=\"\", default_values=languages) \\\n            .pack(side=\"left\", expand=False, fill=\"x\", padx=(10, 0))\n\n        self.widget_mngr.Notes = ComboBoxWidget(parent_frame, cinfo_name=\"Notes\").pack()\n\n        #################\n        # People column\n        #################\n        parent_frame = Frame(self.people_info_frame, padx=20)\n        parent_frame.pack(side=\"right\", expand=True, fill=\"both\")\n        self.widget_mngr.Writer = ComboBoxWidget(parent_frame, \"Writer\").pack()\n        self.widget_mngr.Penciller = ComboBoxWidget(parent_frame, \"Penciller\").pack()\n        self.widget_mngr.Inker = ComboBoxWidget(parent_frame, \"Inker\").pack()\n        self.widget_mngr.Colorist = ComboBoxWidget(parent_frame, \"Colorist\").pack()\n        self.widget_mngr.Letterer = ComboBoxWidget(parent_frame, \"Letterer\").pack()\n        self.widget_mngr.CoverArtist = ComboBoxWidget(parent_frame, \"CoverArtist\", label_text=\"Cover Artist\").pack()\n        self.widget_mngr.Editor = ComboBoxWidget(parent_frame, \"Editor\").pack()\n        self.widget_mngr.Translator = ComboBoxWidget(parent_frame, \"Translator\").pack()\n        self.widget_mngr.Publisher = ComboBoxWidget(parent_frame, \"Publisher\").pack()\n        self.widget_mngr.Imprint = ComboBoxWidget(parent_frame, \"Imprint\").pack()\n        self.widget_mngr.Characters = ComboBoxWidget(parent_frame, \"Characters\").pack()\n        self.widget_mngr.Teams = ComboBoxWidget(parent_frame, \"Teams\").pack()\n        self.widget_mngr.Locations = ComboBoxWidget(parent_frame, \"Locations\").pack()\n        self.widget_mngr.MainCharacterOrTeam = ComboBoxWidget(parent_frame, \"MainCharacterOrTeam\",\n                                                              label_text=\"Main Character Or Team\").pack()\n        self.widget_mngr.Other = ComboBoxWidget(parent_frame, \"Other\").pack()\n\n        #################\n        # Numbering column\n        # #################\n        # parent_frame = Frame(self.numbering_info_frame, padx=20)\n        # parent_frame.pack(side=\"right\", expand=False, fill=\"both\")\n        parent_frame = Frame(self.numbering_info_frame,padx=20)\n        parent_frame.pack(side=\"right\", expand=True, fill=\"both\")\n\n        self.widget_mngr.SeriesGroup = ComboBoxWidget(parent_frame, cinfo_name=\"SeriesGroup\",\n                                                      label_text=\"Series Group\").pack()\n\n        self.widget_mngr.AlternateSeries = ComboBoxWidget(parent_frame, cinfo_name=\"AlternateSeries\",\n                                                          label_text=\"Alternate Series\").pack()\n        self.widget_mngr.StoryArc = ComboBoxWidget(parent_frame, \"StoryArc\", label_text=\"Story Arc\").pack()\n\n        numbering = Frame(parent_frame)\n        numbering.pack(fill=\"x\")\n        self.widget_mngr.AlternateCount = ComboBoxWidget(numbering, \"AlternateCount\",\n                                                         label_text=\"Alt Count\", tooltip=\"Alternate Count\",\n                                                         width=combo_width,\n                                                         validation=\"int\", default=\"-1\")\\\n            .pack(side=\"left\", expand=False, fill=\"x\")\n        self.widget_mngr.AlternateNumber = ComboBoxWidget(numbering, \"AlternateNumber\", width=combo_width,\n                                                          label_text=\"Alt Number\", tooltip=\"Alternate Number\",\n                                                          validation=\"int\")\\\n            .pack(side=\"left\", expand=False, fill=\"x\", padx=(10, 0))\n\n        self.widget_mngr.StoryArcNumber = ComboBoxWidget(numbering, \"StoryArcNumber\", width=combo_width,\n                                                         label_text=\"Story Arc Number\")\\\n            .pack(side=\"left\", expand=False, fill=\"x\", padx=(10, 0))\n\n        self.widget_mngr.CommunityRating = ComboBoxWidget(numbering, cinfo_name=\"CommunityRating\",\n                                                          label_text=\"Community Rating\",\n                                                          width=combo_width,\n                                                          validation=\"rating\")\\\n            .pack(side=\"left\", expand=False, fill=\"x\", padx=(10, 0))\n        self.widget_mngr.BlackAndWhite = OptionMenuWidget(numbering, \"BlackAndWhite\", \"Black And White\", combo_width, 18,\n                                                          \"Unknown\", (\"Unknown\", \"Yes\", \"No\"))\\\n            .pack(side=\"left\", expand=False, fill=\"x\", padx=(10, 0))\n\n        self.widget_mngr.PageCount = ComboBoxWidget(parent_frame, \"PageCount\", label_text=\"Page Count\",\n                                                    width=combo_width,\n                                                    validation=\"int\", default=\"0\")\n        self.widget_mngr.ScanInformation = ComboBoxWidget(parent_frame, cinfo_name=\"ScanInformation\",\n                                                          label_text=\"Scan Information\").pack()\n        self.widget_mngr.GTIN = ComboBoxWidget(parent_frame, cinfo_name=\"GTIN\",\n                                                          label_text=\"GTIN\").pack()\n\n    def display_bottom_frame(self):\n\n        frame = self.selection_progress_frame_bottom\n        tkinter.Label(frame, text=\"No files selected\", textvariable=self.image_cover_frame.selected_file_path_var)\\\n            .pack(side=\"left\")\n\n        progress_bar_frame = tkinter.Frame(frame)\n        pb = self.pb = ProgressBarWidget(progress_bar_frame)\n        pb.progress_bar.configure(length=200)\n        pb.set_template(f\"\"\"Processed: {pb.PROCESSED_TAG}/{pb.TOTAL_TAG} - {pb.ERRORS_TAG} errors\"\"\")\n        progress_bar_frame.pack(expand=False, fill=\"both\", side=\"right\")\n        self.pb.pb_label.pack(side=\"right\")\n        self.pb.progress_bar.pack(side=\"right\", fill=\"x\", expand=True)\n\n    # Implementations\n    def on_file_selection_preview(self, *args):\n        \"\"\"\n        Method called when the user selects one or more files to preview the metadata\n        Called dynamically\n        :return:\n        \"\"\"\n        new_selection, old_selection = args\n\n        if not self.inserting_files:\n            self.process_gui_update(old_selection, new_selection)\n        self.image_cover_frame.update_cover_image(new_selection)\n\n        # When a file is selected (at least one), then enable the buttons\n        for btn in [self.fetch_online_btn, self.clear_btn, self.process_btn, self.fill_from_filename_btn]:\n            btn['state'] = 'normal'\n\n    def on_drop(self,event):\n        files_str = event.data\n        files = EXTRACT_PATHS.findall(files_str)\n\n        self.load_selected_files(files,is_event_dragdrop=True)"
  },
  {
    "path": "MangaManager/src/MetadataManager/GUI/windows/SettingsWindow.py",
    "content": "from __future__ import annotations\n\nimport logging\nimport re\nimport tkinter\nfrom tkinter import ttk, Frame\nfrom tkinter.ttk import LabelFrame, Label, Notebook, Combobox\n\nfrom ExternalSources.MetadataSources import ScraperFactory\nfrom common.models import ComicInfo\nfrom src import MM_PATH\nfrom src.Common.LoadedComicInfo.LoadedComicInfo import LoadedComicInfo\nfrom src.Common.utils import open_folder\nfrom src.DynamicLibController.models import IMetadataSource\nfrom src.MetadataManager.GUI.utils import center\nfrom src.MetadataManager.GUI.widgets import ButtonWidget\nfrom src.MetadataManager.GUI.widgets.FormBundleWidget import FormBundleWidget\nfrom src.Settings import SettingHeading\nfrom src.Settings.SettingControl import SettingControl\nfrom src.Settings.SettingControlType import SettingControlType\nfrom src.Settings.SettingSection import SettingSection\nfrom src.Settings.Settings import Settings\n\nlogger = logging.getLogger(\"SettingsWidgetManager\")\n\n\ndef template_validation(key_list):\n    return [keyword for keyword in key_list if\n            keyword not in LoadedComicInfo(None, ComicInfo, False).get_template_values().keys()]\n\n\nsetting_control_map = {\n    SettingHeading.Main: {\n        \"library_path\": SettingControl(\"library_path\", \"Library Path\", SettingControlType.Text, \"\",\n                                       \"The path to your library. This location will be opened by default when choosing files\"),\n        \"covers_folder_path\": SettingControl(\"covers_folder_path\", \"Covers folder path\", SettingControlType.Text, \"\",\n                                             \"The path to your covers. This location will be opened by default when choosing covers\"),\n        \"cache_cover_images\": SettingControl(\"cache_cover_images\", \"Cache cover images\", SettingControlType.Bool, True,\n                                             \"If enabled, the covers of the file will be cached and shown in the ui\"),\n        \"create_backup_comicinfo\": SettingControl(\"create_backup_comicinfo\", \"Create Backup XML\",\n                                                  SettingControlType.Bool, True,\n                                                  \"If enabled, all ComicInfo.xml existing within an archive will be backed up as Old_ComicInfo.xml.bak\"),\n        \"move_to_template\": SettingControl(\"move_to_template\", \"Rename filename\", SettingControlType.Text, \"\",\n                                           tooltip=f\"Leave empty to not set.\\nAvailable tags: {', '.join(['{' + key + '}' for key in LoadedComicInfo(None, ComicInfo, False).get_template_values().keys()])}\",\n                                           validate=lambda key, value: '[' + \", \".join(template_validation(\n                                               re.findall(r'\\{(\\w+)\\}', value))) + \"] are not valid tags\" if len(\n                                               template_validation(re.findall(r'\\{(\\w+)\\}', value))) != 0 else \"\"),\n        \"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\")\n    },\n    SettingHeading.WebpConverter: {\n        \"default_base_path\": SettingControl(\"default_base_path\", \"Default base path\", SettingControlType.Text, \"\",\n                                            \"The starting point where the glob will begin looking for files that match the pattern\"),\n\n    },\n    SettingHeading.ExternalSources: {\n        \"default_metadata_source\": SettingControl(\"default_metadata_source\", \"Default metadata source\",\n                                                  SettingControlType.Options,\n                                                  \"The source that will be hit when looking for metadata\"),\n        \"default_cover_source\": SettingControl(\"default_cover_source\", \"Default cover source\",\n                                               SettingControlType.Options,\n                                               \"The source that will be hit when looking for cover images\"),\n    },\n    SettingHeading.MessageBox: {\n    }\n}\n\n# TODO: Load dynamically loaded extensions (this will be moved in another PR)\nproviders: list[IMetadataSource] = [ScraperFactory().get_scraper(\"MangaUpdates\"),\n                                    ScraperFactory().get_scraper(\"AniList\")]\n\n\ndef populate_default_settings():\n    default_settings = {}\n\n    for section in setting_control_map:\n        if section not in default_settings:\n            controls = []\n            for (key, value) in setting_control_map[section].items():\n                setting = Settings().get(section, key)\n                if setting is None:\n                    continue\n\n                controls.append(value)\n        default_settings[section] = SettingSection(section, section, controls)\n\n    # Setup extension based settings\n    for metadata_source in default_settings[SettingHeading.ExternalSources].values:\n        if metadata_source.key == 'default_metadata_source':\n            metadata_source.set_values([p.name for p in providers])\n\n    return default_settings\n\n\nclass SettingsWindow:\n    def __init__(self, parent):\n        self.strings_vars: list[tkinter.Variable] = []\n        self.bundles: list[FormBundleWidget] = []\n        self.default_settings = populate_default_settings()\n\n        settings_window = self.settings_window = tkinter.Toplevel(parent, pady=10, padx=30)\n        settings_window.geometry(\"900x420\")\n        settings_window.title(\"Settings\")\n\n        main_frame = tkinter.Frame(settings_window)\n        main_frame.pack(fill=\"both\")\n\n        # There is nothing that requires a restart yet, so I'm removing this\n        # frame = Label(master=main_frame, text=\"\\nNote: Fields marked with * need a restart to take effect\")\n        # frame.pack(expand=True, fill=\"both\")\n\n        style = ttk.Style(main_frame)\n        style.configure('lefttab.TNotebook', tabposition='ws')\n        self.widgets_frame = Notebook(main_frame, style='lefttab.TNotebook')\n        self.widgets_frame.pack(expand=True, fill=\"both\")\n\n        control_frame = tkinter.Frame(settings_window)\n        ButtonWidget(master=control_frame, text=\"Save\", tooltip=\"Saves the settings to the config file\",\n                     command=self.save_settings) \\\n            .pack(side=\"right\", padx=(0, 5))\n        ButtonWidget(master=control_frame, text=\"Open Settings Folder\",\n                     tooltip=\"Opens the folder where Manga Manager stores it's files\",\n                     command=lambda x=None: open_folder(folder_path=MM_PATH)) \\\n            .pack()\n        control_frame.pack(side=\"right\")\n\n        self.settings_widget = {}\n        logger.info('Setting up settings for Manga Manager')\n\n        for setting_section in self.default_settings:\n            section = self.default_settings[setting_section]\n\n            logger.info('Setting up settings for ' + section.pretty_name)\n            section_frame = Frame(master=self.widgets_frame, name=\"default_\" + setting_section.name)\n            section_frame.pack(expand=True, fill=\"both\")\n\n            self.settings_widget[section.pretty_name] = {}\n            self.build_setting_entries(section_frame, section.values, section)\n            self.widgets_frame.add(section_frame, text=section.pretty_name)\n\n        logger.info('Setting up settings for Extensions')\n        for provider in providers:\n            settings = provider.settings\n            for section in settings:\n                logger.info('Setting up settings for ' + provider.name)\n                section_frame = LabelFrame(master=self.widgets_frame, text=section.pretty_name,\n                                           name=\"provider_\" + provider.name)\n                section_frame.pack(expand=True, fill=\"both\")\n\n                self.settings_widget[self.default_settings[SettingHeading.ExternalSources].pretty_name][\n                    section.pretty_name] = {}\n                self.build_setting_entries(section_frame, section.values, section)\n                self.widgets_frame.add(section_frame, text=section.pretty_name)\n\n        # Display checkbox toggles\n        frame = self.widgets_frame.children.get(\"default_MessageBox\")\n        for entry in list(Settings.config_parser[SettingHeading.MessageBox]):\n            control = SettingControl(key=entry, name=entry, control_type=SettingControlType.Bool)\n            self.build_setting_entry(frame, control=control, section=self.default_settings[SettingHeading.MessageBox])\n\n        center(settings_window)\n\n\n    def build_setting_entry(self, parent_frame, control: SettingControl, section):\n        # Update the control's value from Settings\n        control.value = Settings().get(section.key, control.key)\n\n        row = FormBundleWidget(parent_frame, self.setting_control_to_widget, name=control.key) \\\n            .with_label(title=control.name, tooltip=control.tooltip) \\\n            .with_input(control=control, section=section) \\\n            .build()\n\n        self.bundles.append(row)\n\n    def build_setting_entries(self, parent_frame, settings, section):\n        for i, setting in enumerate(settings):\n            self.build_setting_entry(parent_frame, setting, section)\n\n    def save_settings(self):\n        \"\"\"\n        Saves the settings from the GUI to Setting provider and extensions that dynamically loaded their settings\n        \"\"\"\n        # Validate the setting is correct before allowing any persistence\n        is_errors = False\n        for bundle in self.bundles:\n            if bundle.control:\n                if not bundle.validate():\n                    is_errors = True\n        if is_errors:\n            return\n\n        for bundle in self.bundles:\n            if bundle.control:\n                Settings().set(bundle.section.key, bundle.control.key, bundle.format_output())\n\n        # Tell Extensions that an update to Settings has occurred\n        for provider in providers:\n            provider.save_settings()\n\n        Settings().save()\n        self.settings_window.destroy()\n\n    @staticmethod\n    def setting_control_to_widget(parent_frame: tkinter.Frame, control: SettingControl, section: SettingSection):\n        match control.control_type:\n            case SettingControlType.Text:\n                string_var = tkinter.StringVar(value=control.value, name=f\"{section.pretty_name}.{control.key}\")\n                entry = tkinter.Entry(master=parent_frame, width=80, textvariable=string_var)\n                entry.pack(side=\"right\", expand=True, fill=\"x\", padx=(5, 30))\n            case SettingControlType.Bool:\n                if isinstance(control.value,bool):\n                    value = control.value\n                else:\n                    value = control.value == 'True'\n                string_var = tkinter.BooleanVar(value=value, name=f\"{section.pretty_name}.{control.key}\")\n                entry = tkinter.Checkbutton(parent_frame, variable=string_var, onvalue=1, offvalue=0)\n                entry.pack(side=\"left\")\n            case SettingControlType.Options:\n                string_var = tkinter.StringVar(value=\"default\", name=f\"{section.pretty_name}.{control.key}\")\n                entry = Combobox(master=parent_frame, textvariable=string_var, width=30, state=\"readonly\")\n                entry[\"values\"] = control.values\n                entry.set(str(control.value))\n                entry.pack(side=\"left\", expand=False, fill=\"x\", padx=(5, 30))\n                entry.set(control.value)\n\n        return entry, string_var"
  },
  {
    "path": "MangaManager/src/MetadataManager/GUI/windows/__init__.py",
    "content": "from .AboutWindow import AboutWindow\n"
  },
  {
    "path": "MangaManager/src/MetadataManager/MetadataManagerCLI.py",
    "content": "import itertools\nimport logging\nimport shutil\nimport sys\nimport textwrap\nimport time\n\nimport prompt_toolkit\nfrom prompt_toolkit import prompt\nfrom prompt_toolkit.application.current import get_app\nfrom prompt_toolkit.completion import WordCompleter\nfrom prompt_toolkit.key_binding import KeyBindings\nfrom prompt_toolkit.validation import Validator\n\nfrom common.models import ComicInfo\nfrom src.Common.LoadedComicInfo.LoadedComicInfo import LoadedComicInfo\nfrom src.Common.utils import ShowPathTreeAsDict\nfrom src.MetadataManager.MetadataManagerLib import MetadataManagerLib\n\n\ndef prompt_autocomplete():\n    app = get_app()\n    b = app.current_buffer\n    if b.complete_state:\n        b.complete_next()\n    else:\n        b.start_completion(select_first=False)\n\n\ndef grouper(n, iterable, fillvalue=None):\n    \"Collect data into fixed-length chunks or blocks\"\n    # grouper(3, 'ABCDEFG', 'x') --> ABC DEF Gxx\"\n    args = [iter(iterable)] * n\n    return itertools.zip_longest(fillvalue=fillvalue, *args)\n\n\nclass bcolors:\n    HEADER = '\\033[95m'\n    OKBLUE = '\\033[94m'\n    OKCYAN = '\\033[96m'\n    OKGREEN = '\\033[92m'\n    WARNING = '\\033[93m'\n    FAIL = '\\033[91m'\n    ENDC = '\\033[0m'\n    BOLD = '\\033[1m'\n    UNDERLINE = '\\033[4m'\n\n\nbindings = KeyBindings()\napp = None\n\n\n@bindings.add('c-q')\ndef _(event: prompt_toolkit.key_binding.KeyPressEvent):\n    \"\"\"Exit when `c-q` is pressed. \"\"\"\n    event.app.running = False\n    app.quit()\n\n\n@bindings.add('c-p')\ndef _(event: prompt_toolkit.key_binding.KeyPressEvent):\n    \"\"\"Exit when `c-q` is pressed.\"\"\"\n    event.app.running = False\n    event.app.exit()\n    # app.quit()\n    app.process()\n\n\n@bindings.add('c-l')\ndef _(event: prompt_toolkit.key_binding.KeyPressEvent):\n    \"\"\"Exit when `c-l` is pressed.\"\"\"\n    app.restart()\n    event.app.exit()\n    app.quit()\n    print(\"Selected files will be shown for 10 seconds\")\n    print(\"\\033[%d;%dH\" % (0, 0))\n    print(f\"{' '*int(shutil.get_terminal_size().columns)}\\n\"* int(shutil.get_terminal_size().lines))\n    print(\"\\033[%d;%dH\" % (0, 0))\n    # print(\"\\033[%d;%dH\" % (row, 0))\n    print(\"Selected files will be shown for 10 seconds\")\n    time.sleep(10)\n\n\nlogger = logging.getLogger()\n\n\nclass App(MetadataManagerLib):\n    def on_processed_item(self, loaded_info: LoadedComicInfo):\n        pass\n\n    def on_manga_not_found(self, exception, series_name):\n        pass\n\n    def __init__(self, file_paths: list[str]):\n        self.is_cli = True\n        self._restart = False\n        self.terminal_height = int(shutil.get_terminal_size().lines)\n        self.terminal_width = int(shutil.get_terminal_size().columns)\n        self.selected_files_path = file_paths\n        self.serve_ui()\n\n    def _parse_lcinfo_list_to_gui(self, loaded_cinfo_list) -> ComicInfo:\n\n        displayed_gui = self.new_edited_cinfo = ComicInfo()\n\n        for cinfo_tag in self.cinfo_tags:\n            tag_values = set()\n            for loaded_cinfo in loaded_cinfo_list:\n                tag_value = str(loaded_cinfo.cinfo_object.get_by_tag_name(cinfo_tag))\n                tag_values.add(tag_value if tag_value not in (\"\",-1,0,\"-1\",\"0\") else None)\n            tag_values = tuple(tag_values)\n            tag_values_len = len(tag_values)\n\n            # All files have the same content for this field\n\n            if tag_values_len == 1 and tag_values[0] not in (\"\", -1, 0, \"-1\", \"0\", None):\n                displayed_gui.get_by_tag_name(cinfo_tag, tag_values[0])\n            # Multiple values across different files for this field\n            elif tag_values_len > 1:\n                # Append \"multiple_values\" string to the suggestion listbox\n                tag_values = (self.MULTIPLE_VALUES_CONFLICT,) + tag_values\n                displayed_gui.get_by_tag_name(cinfo_tag, self.MULTIPLE_VALUES_CONFLICT)\n\n    def serve_ui(self):\n        self.open_cinfo_list()\n        # self.merge_changed_metadata()\n        global app\n        app = self\n        self.terminal_width_half = int(self.terminal_width / 2 - 40)\n        custom_entered_values = []\n        self.clear()\n\n        self._parse_lcinfo_list_to_gui(self.loaded_cinfo_list)\n        self.process()\n        self.running = True\n        # Set the validator for the user prompts.\n        is_valid_tag = Validator.from_callable(\n            self._is_valid_tool,\n            error_message='Not a valid tool. Select one in the list',\n            move_cursor_to_end=False)\n        while self.running:\n            # Clear terminal, so it will redraw because of the loop with the modified values.\n            self.clear()\n            # Display current values\n            for tag_1, tag_2 in grouper(2, self.cinfo_tags, fillvalue=None):\n\n                # We get 2 different values to support the 2 column layout and also support wrapping the text if multiline\n                value_1 = textwrap.wrap(str(self.new_edited_cinfo.get_by_tag_name(tag_1)), width=self.terminal_width_half) or [\"\"]\n                if tag_2:\n                    value_2 = textwrap.wrap(str(self.new_edited_cinfo.get_by_tag_name(tag_2)), width=self.terminal_width_half) or [\"\"]\n                else:\n                    value_2 = [\"\"]\n                print_once = False\n                print(\" \" * self.terminal_width, end=\"\\r\")\n                # Divide/wrap both strings. Make a list of each line.\n                # Add empty strings to the list of lines with less lines to allow consistent wrapping\n                for val_1, val2 in itertools.zip_longest(value_1, value_2, fillvalue=\" \"):\n                    # Print one allow to not print the label on each row if the text is being wrapped in multiple lines\n                    if not print_once:\n                        print(f\"{bcolors.OKBLUE if tag_1 in custom_entered_values else ''}\"+f\"{tag_1}\".ljust(16) +\n                              f\"{bcolors.ENDC if tag_1 in custom_entered_values else ''}:  \", end='')\n                    else:\n                        print(f\" \".ljust(19), end=\"\")\n                    # Print the actual value inline:\n                    print(f\"{val_1.ljust(self.terminal_width_half)}\", end='  ')\n\n                    if not print_once:\n                        print(f\"{bcolors.OKBLUE if tag_2 in custom_entered_values else ''}\" + f\"{tag_2}\".ljust(16) +\n                              f\"{bcolors.ENDC if tag_2 in custom_entered_values else ''}:  \", end='')\n                    else:\n                        print(f\" \".ljust(19), end=\"\")\n                    print(f\"{val2}\")\n                    # Set the flag that the labels have been printed\n                    print_once = True\n\n            # Prompt to have the user select what tag to edit\n            choosed_tag = prompt(\"Select tag to edit (Use arrow keys to navigate) \",\n                                 completer=WordCompleter(self.cinfo_tags),\n                                 validator=is_valid_tag, pre_run=prompt_autocomplete,\n                                 bottom_toolbar=\"Exit:ctrl+q - Process:ctrl+p - Show Selected:ctrl+l\",\n                                 key_bindings=bindings)\n            if choosed_tag is None:\n                logger.warning(\"No tag selected. Restarting\")\n                continue\n            if self._restart:\n                self._restart = False\n                continue\n            if not self.running:\n                return\n            # User selected one tag. If it has prefefined values, show a list of them and let the user select those.\n            # If one field has multiple values from different files show a list of those.\n            # Adds an option \"Custom\" to custom enter the value\n            print(f\"You selected {bcolors.HEADER}{choosed_tag}{bcolors.ENDC}\")\n            choosed_value = \"\"\n            if self.new_edited_cinfo.get_by_tag_name(choosed_tag) == self.MULTIPLE_VALUES_CONFLICT:\n                print(\"Multiple values conflict. Select one value to keep.\"\n                      f\" '{bcolors.HEADER}Cancel{bcolors.ENDC}' to cancel editing.\"\n                      f\" '{bcolors.HEADER}Custom{bcolors.ENDC}' to manually enter a new value.\"\n                      f\" '{bcolors.HEADER}None{bcolors.ENDC}' to clear the content\")\n                if choosed_tag == \"AgeRating\":\n                    validation_vals = ComicInfo.AgeRating.list()\n                elif choosed_tag == \"Manga\":\n                    validation_vals = ComicInfo.Manga.list()\n                elif choosed_tag == \"BlackAndWhite\":\n                    validation_vals = ComicInfo.YesNo.list()\n                elif choosed_tag == \"CommunityRating\":\n                    validation_vals = range(1,5)\n                else:\n                    validation_vals = [\"Cancel\", \"None\", \"Custom\",\n                     *[lcinfo.cinfo_object.get_by_tag_name(choosed_tag)\n                       for lcinfo in self.loaded_cinfo_list\n                       if lcinfo.cinfo_object.get_by_tag_name(choosed_tag)]]\n\n                choosed_value = prompt(f\"Value to keep as '{choosed_tag}': \",\n                                       completer=WordCompleter(validation_vals),\n                                       pre_run=prompt_autocomplete,\n                                       validator=Validator.from_callable(lambda value: value in validation_vals,\n                                                                         error_message=\"Invalid value. Select one in the list\",\n                                                                         move_cursor_to_end=False))\n            if choosed_value == \"Cancel\":\n                continue\n            elif choosed_value == \"None\":\n                choosed_value = None\n            elif choosed_value == \"Custom\" or self.new_edited_cinfo.get_by_tag_name(choosed_tag) != self.MULTIPLE_VALUES_CONFLICT:\n                alt_enter = \" (Alt+Enter to save)\" if choosed_tag == \"Summary\" else \"\"\n                # Make the prompt to edit the value. Make it multiline if the tag is \"Summary\"\n                choosed_value = prompt(f\"Write new value for {choosed_tag}{alt_enter}: \", multiline=choosed_tag == \"Summary\")\n            # Mark the field as modified.\n            custom_entered_values.append(choosed_tag)\n            # Edit the field in the \"\"Gui\"\"\n            self.new_edited_cinfo.get_by_tag_name(choosed_tag, choosed_value)\n    def restart(self):\n        self._restart = True\n\n    def clear(self):\n        sys.stdout.write(\"\\033[F\" * int(self.terminal_height))\n        sys.stdout.flush()\n\n    def quit(self):\n        self.clear()\n        self.running = False\n        exit()\n\n    def process(self):\n        self.clear()\n        self.running = False\n        # export = StringIO(self.new_edited_cinfo.to_xml())\n        # print(export.getvalue())\n        self.merge_changed_metadata(self.loaded_cinfo_list)\n        super(App, self).process()\n        self.new_edited_cinfo = ComicInfo()\n        self._parse_lcinfo_list_to_gui(self.loaded_cinfo_list)\n\n    def tree_selected(self) -> int:\n        print(\"\")\n        # print(path)\n        paths = ShowPathTreeAsDict([lcinfo.file_path for lcinfo in self.loaded_cinfo_list])\n        return paths.display_tree()\n\n    def _is_valid_tool(self, value):\n        return True if value in self.cinfo_tags else False\n\n\n    def on_badzipfile_error(self, exception, file_path):\n        pass\n\n    def on_corruped_metadata_error(self, exception, loaded_info: LoadedComicInfo):\n        pass\n\n    def on_writing_error(self, exception, loaded_info: LoadedComicInfo):\n        pass\n\n    def on_writing_exception(self, exception, loaded_info: LoadedComicInfo):\n        pass\n\n"
  },
  {
    "path": "MangaManager/src/MetadataManager/MetadataManagerGUI.py",
    "content": "from __future__ import annotations\n\nimport glob\nimport logging\nimport os\nimport tkinter\nfrom tkinter import Tk, Frame\n\nfrom common.models import ComicInfo\nfrom src.Common import ResourceLoader\nfrom src.Common.parser import parse_volume, parse_series, parse_number\nfrom src.Common.utils import get_platform, open_folder\nfrom src.MetadataManager.GUI.ControlManager import ControlManager\nfrom src.MetadataManager.GUI.MessageBox import MessageBoxWidgetFactory as mb\nfrom src.MetadataManager.GUI.windows.AboutWindow import AboutWindow\nfrom src.MetadataManager.GUI.windows.LoadingWindow import LoadingWindow\nfrom src.Settings import SettingHeading\nfrom src.Settings.Settings import Settings\n\nif get_platform() == \"linux\":\n    from src.MetadataManager.GUI.FileChooserWindow import askopenfiles, askdirectory\nelse:\n    from tkinter.filedialog import askopenfiles, askdirectory\nfrom _tkinter import TclError\nfrom tkinterdnd2.TkinterDnD import Tk\nfrom src.Common.LoadedComicInfo.LoadedComicInfo import LoadedComicInfo\nfrom src.MetadataManager.GUI.widgets import ComboBoxWidget, OptionMenuWidget, WidgetManager, ButtonWidget\nfrom src.MetadataManager.GUI.windows.SettingsWindow import SettingsWindow\nfrom src.MetadataManager.MetadataManagerLib import MetadataManagerLib\n\n\nclass GUIApp(Tk, MetadataManagerLib):\n    \"\"\"\n    This is the main logic and app\n    \"\"\"\n    main_frame: Frame\n    \"\"\"\n    A loading indicator to help not process changes in MainWindow when GUI is performing loading of files\n    \"\"\"\n    inserting_files = False\n    widget_mngr = WidgetManager()\n    control_mngr = ControlManager()  # widgets that should be disabled while processing\n    loading_window: LoadingWindow | None = None\n\n    def __init__(self):\n        super(GUIApp, self).__init__()\n        self.last_folder = \"\"\n\n        # self.wm_minsize(1000, 660)\n        self.tk.eval('package require tile')\n        self.geometry(\"1000x820\")\n        self.title(\"Manga Manager\")\n\n        self.selected_files_path = None\n        self.loaded_cinfo_list: list[LoadedComicInfo] = []\n        self.log = logging.getLogger(\"MetaManager.GUI\")\n\n        # MENU\n        self.main_frame = Frame(self)\n        self.main_frame.pack(expand=True, fill=\"both\")\n\n\n        # Add binds\n        self.bind('<Control-o>', lambda x: self.select_files())\n        self.bind('<Control-s>', lambda x: self.pre_process())\n        self.bind('<Control-f>', self.process_fetch_online)\n\n        # Icons\n        icon_path = ResourceLoader.get('settings.png')\n        self.settings_icon = tkinter.PhotoImage(name=\"settings_icon\", master=self, file=icon_path)\n\n        icon_path = ResourceLoader.get('clear_icon.png')\n        self.clear_icon = tkinter.PhotoImage(name=\"clear_icon\", master=self, file=icon_path)\n\n        icon_path = ResourceLoader.get('fetch_online_ico.png')\n        self.fetch_online_icon = tkinter.PhotoImage(name=\"fetch_online_icon\", master=self, file=icon_path)\n\n        icon_path = ResourceLoader.get('save_icon.png')\n        self.save_icon = tkinter.PhotoImage(name=\"save_icon\", master=self, file=icon_path)\n\n        icon_path = ResourceLoader.get('filename_fill_icon.png')\n        self.filename_fill_icon = tkinter.PhotoImage(name=\"filename_fill\", master=self, file=icon_path)\n\n        icon_path = ResourceLoader.get('open_folder.png')\n        self.open_folder_icon = tkinter.PhotoImage(name=\"open_folder\", master=self, file=icon_path)\n\n        icon_path = ResourceLoader.get('open_file.png')\n        self.open_file_icon = tkinter.PhotoImage(name=\"open_file\", master=self, file=icon_path)\n\n        # Floating icons\n        frame = Frame(self)\n        frame.place(anchor=tkinter.NE, relx=1,rely=0.003)\n        ButtonWidget(master=frame, text=\"Settings\", image=self.settings_icon, font=('Arial', 10), compound=\"left\",\n                     command=self.show_settings).pack(side=\"left\", fill=\"y\", padx=(0, 5))\n\n        ButtonWidget(master=frame, text=\"About\", font=('Arial', 10), command=self.show_about).pack(side=\"left\", fill=\"y\", padx=(0, 5))\n    def report_callback_exception(self, *_):\n        \"\"\"\n        Overrides builtin method so exceptions get loged and are not silent\n        :param _:\n        :return:\n        \"\"\"\n        self.log.exception(\"Unhandled exception\")\n\n    @property\n    def cinfo_tags(self):\n        return self.widget_mngr.cinfo_tags\n\n    @property\n    def selected_items(self):\n        \"\"\"\n        Returns the list of selected loaded_cinfo if any is selected. Else returns loaded_cinfo list\n        :return:\n        \"\"\"\n        return self.selected_files_treeview.get_selected() or self.loaded_cinfo_list\n\n    #########################################################\n    # GUI Control Methods\n    ############\n\n    def select_files(self):\n        # These are some tricks to make it easier to select files.\n        # Saves last opened folder to not have to browse to it again\n        if not self.last_folder:\n            initial_dir = Settings().get(SettingHeading.Main, 'library_path')\n        else:\n            initial_dir = self.last_folder\n        self.log.debug(\"Selecting files\")\n        # Open select files dialog\n        selected_paths_list = askopenfiles(parent=self, initialdir=initial_dir,\n                                           title=\"Select file(s)\",\n                                           filetypes=((\"CB* Files\", (\".cbz\", \".cbr\")), (\"CBZ Files\", \".cbz\"),\n                                                      (\"CBR Files\", \".cbr\"), (\"All Files\", \"*\"), (\"Zip files\", \".zip\"))\n                                           # (\"Zip files\", \".zip\"))\n                                           ) or []\n\n        if selected_paths_list:\n            selected_parent_folder = os.path.dirname(selected_paths_list[0].name)\n            if self.last_folder != selected_parent_folder or not self.last_folder:\n                self.last_folder = selected_parent_folder\n\n        self.selected_files_path = [file.name for file in selected_paths_list]\n        self.load_selected_files()\n\n    def select_folder(self):\n        # These are some tricks to make it easier to select files.\n        # Saves last opened folder to not have to browse to it again\n        if not self.last_folder:\n            initial_dir = Settings().get(SettingHeading.Main, 'library_path')\n        else:\n            initial_dir = self.last_folder\n        self.log.debug(\"Selecting files\")\n        # Open select files dialog\n\n        folder_path = askdirectory(initialdir=initial_dir)\n        self.selected_files_path = glob.glob(root_dir=folder_path, pathname=os.path.join(folder_path, \"**/*.cbz\"),\n                                             recursive=True)\n        # TODO: Auto select recursive or not\n        # self.selected_files_path = [str(Path(folder_path, file)) for file in os.listdir(folder_path) if file.endswith(\".cbz\")]\n        self.load_selected_files()\n\n    def load_selected_files(self,new_selection:list=None,is_event_dragdrop = False):\n\n        self.control_mngr.lock()\n        self.widget_mngr.toggle_widgets(enabled=False)\n        append_and_keep = is_event_dragdrop and not Settings().get(SettingHeading.Main,\"remove_old_selection_on_drag_drop\")\n        if append_and_keep: # Should keep previously selected files. Just load the new ones in selection\n            self.selected_files_path = list(set((self.selected_files_path or []) + new_selection))\n        else:\n            # Append new files and keep the old ones\n            self.widget_mngr.clean_widgets()  # New file selection. Proceed to clean the ui to a new state\n            self.image_cover_frame.clear()\n            self.selected_files_path = self.selected_files_path if new_selection is None else new_selection\n            self.selected_files_treeview.clear()\n        self.selected_files_path = sorted(self.selected_files_path)\n        self.log.debug(f\"Selected files [{', '.join(self.selected_files_path)}]\")\n        self.inserting_files = True\n        self.loading_window = LoadingWindow(len(self.selected_files_path))\n\n        if self.open_cinfo_list(self.loading_window.is_abort,append_and_keep):\n            self._serialize_cinfolist_to_gui()\n        else:\n            self.clean_selected()\n        self.loading_window.finish_loading()\n        self.loading_window = None\n        self.inserting_files = False\n        self.control_mngr.unlock()\n        self.widget_mngr.toggle_widgets(enabled=True)\n\n    def show_settings(self):\n        SettingsWindow(self)\n\n    def show_about(self):\n        AboutWindow(self)\n\n    def are_unsaved_changes(self, exist_unsaved_changes=False):\n        \"\"\"\n        Displays the text \"unsaved changes\"\n        :return:\n        \"\"\"\n        if exist_unsaved_changes:  # Place the warning sign\n            self.changes_saved.place(anchor=tkinter.NE, relx=0.885)\n        else:  # remove the warning sign\n            self.changes_saved.place_forget()\n\n    def update_item_saved_status(self, loaded_cinfo):\n        \"\"\"\n        Adds a warning in the filename if the loadedcinfo has changes\n        :param loaded_cinfo:\n        :return:\n        \"\"\"\n        try:\n            self.selected_files_treeview.item(loaded_cinfo.file_path,\n                                              text=f\"{'⚠' if loaded_cinfo.has_changes else ''}{loaded_cinfo.file_name}\")\n        except TclError:  # Tests fails due to not being correctly populated. Log and skip\n            self.log.error(f\"Error updating saved status for item {loaded_cinfo.file_path}\")\n\n    def show_not_saved_indicator(self, loaded_cinfo_list=None):\n        \"\"\"\n        Shows a litle triangle when files are not saved and are modified\n        :param loaded_cinfo_list:\n        :param mark_saved:\n        :return:\n        \"\"\"\n        if loaded_cinfo_list is None:\n            loaded_cinfo_list = self.loaded_cinfo_list\n        any_has_changes = False\n        for loaded_cinfo in loaded_cinfo_list:\n            self.update_item_saved_status(loaded_cinfo)\n            if loaded_cinfo.has_changes:\n                any_has_changes = True\n        self.are_unsaved_changes(any_has_changes)\n\n    #########################################################\n    # INTERFACE IMPLEMENTATIONS\n    ############\n\n    def on_item_loaded(self, loaded_cinfo: LoadedComicInfo, cursor, total) -> bool:\n        \"\"\"\n        Called by backend when an item gets added to the loaded comic info list\n        :param loaded_cinfo:\n        :return:\n        \"\"\"\n        if self.loading_window.initialized:\n            self.loading_window.update()\n            self.loading_window.loaded_file(loaded_cinfo.file_name)\n        self.selected_files_treeview.insert(loaded_cinfo)\n        self.image_cover_frame.update_cover_image([loaded_cinfo])\n        self.update()\n        return self.loading_window.abort_flag\n    #########################################################\n    # Errors handling / hooks implementations\n    ############\n\n    def on_processed_item(self, loaded_info: LoadedComicInfo):\n        self.pb.increase_processed()\n        self.update_item_saved_status(loaded_info)\n        self.update()\n\n    def on_badzipfile_error(self, exception, file_path: LoadedComicInfo):  # pragma: no cover\n        mb.showerror(self.main_frame, \"Error loading file\",\n                     f\"Failed to read the file '{file_path}'.\\nThis can be caused by wrong file format\"\n                     f\" or broken file.\\n\"\n                     f\"Read the logs for more information.\\n\"\n                     f\"Skipping file...\")\n\n    def on_writing_exception(self, exception, loaded_info: LoadedComicInfo):  # pragma: no cover\n        self.pb.increase_failed()\n        mb.showerror(self.main_frame, \"Unhandled exception\",\n                     \"There was an exception that was not handled while writing the changes to the file.\"\n                     \"Please check the logs and raise an issue so this can be investigated\")\n\n    def on_writing_error(self, exception, loaded_info: LoadedComicInfo):  # pragma: no cover\n        self.pb.increase_failed()\n        mb.showerror(self.main_frame, \"Error writing to file\",\n                     \"There was an error writing to the file. Please check the logs.\")\n\n    def on_corruped_metadata_error(self, exception, loaded_info: LoadedComicInfo):  # pragma: no cover\n        mb.showwarning(self.main_frame, f\"Error reading the metadata from file\",\n                       f\"Failed to read metadata from '{loaded_info.file_path}'\\n\"\n                       \"The file data couldn't be parsed probably because of corrupted data or bad format.\\n\"\n                       f\"Recovery was attempted and failed.\\nCreating new metadata object...\")\n\n    def on_manga_not_found(self, exception, series_name):  # pragma: no cover\n        mb.showerror(self.main_frame, \"Couldn't find matching series\",\n                     f\"The metadata source couldn't find the series '{series_name}'\")\n\n    def on_missing_rar_tools(self, exception):\n        box = mb.get_onetime_messagebox()(\"missing_rar_tools\")\n        box.with_title(\"Missing Rar Tools\"). \\\n            with_description(\"CBR files can't be read because third party rar tools are missing. Skipping files\"). \\\n            with_icon(mb.get_onetime_messagebox().icon_error). \\\n            with_actions([mb.get_box_button()(0, \"Ok\")]). \\\n            build().prompt()\n\n    #########################################################\n    # Processing Methods\n    ############\n\n    def _serialize_cinfolist_to_gui(self, loaded_cinfo_list=None):\n        \"\"\"\n        Display the loaded cinfo values in the ui.\n        If multiple values for one field, shows conflict (keeping values)\n        :param loaded_cinfo_list:\n        :return:\n        \"\"\"\n        # Clear current values\n        self.widget_mngr.clean_widgets()\n        if loaded_cinfo_list is None:\n            loaded_cinfo_list = self.selected_items\n        if Settings().get(SettingHeading.Main, 'cache_cover_images'):\n            self.image_cover_frame.update_cover_image(loaded_cinfo_list)\n\n        # Iterate all cinfo tags. Should there be any values that are not equal. Show \"different values selected\"\n\n        for cinfo_tag in self.widget_mngr.get_tags():\n            widget = self.widget_mngr.get_widget(cinfo_tag)\n            tag_values = set()\n            for loaded_cinfo in loaded_cinfo_list:\n                tag_value = str(loaded_cinfo.cinfo_object.get_by_tag_name(cinfo_tag))\n                tag_values.add(tag_value if tag_value != widget.default else \"\")\n            tag_values = tuple(tag_values)\n            tag_values_len = len(tag_values)\n\n            # All files have the same content for this field\n\n            if tag_values_len == 1 and tag_values[0] != widget.default:\n                widget.set(tag_values[0])\n\n            # Multiple values across different files for this field\n            elif tag_values_len > 1:\n                # Append \"multiple_values\" string to the suggestion listbox\n                tag_values = (self.MULTIPLE_VALUES_CONFLICT,) + tag_values\n                widget.widget.set(self.MULTIPLE_VALUES_CONFLICT)\n\n            # If it's a combobox update the suggestions listbox with the loaded values\n            if isinstance(widget, ComboBoxWidget):\n                widget.widget['values'] = list(tag_values)\n            elif isinstance(widget, OptionMenuWidget):\n                if tag_values_len == 1:\n                    widget.update_listed_values(tag_values[0], widget.get_options())\n                elif tag_values_len > 1:\n                    widget.append_first(self.MULTIPLE_VALUES_CONFLICT)\n\n    def _serialize_gui_to_cinfo(self) -> ComicInfo:\n        \"\"\"\n        Parses current UI values to a 'new_edited_cinfo'\n        :return:\n        \"\"\"\n        # is_metadata_modified\n        LOG_TAG = \"[UI->CINFO] \"\n        ci = ComicInfo()\n        for cinfo_tag in self.widget_mngr.get_tags():\n            widget = self.widget_mngr.get_widget(cinfo_tag)\n            widget_value = widget.widget.get()\n\n            match widget_value:\n                case self.MULTIPLE_VALUES_CONFLICT:\n                    self.log.trace(LOG_TAG + f\"Omitting {cinfo_tag}. Keeping original\")\n                    ci.set_by_tag_name(cinfo_tag, self.MULTIPLE_VALUES_CONFLICT)\n                case \"None\":\n                    if widget.name == \"Format\":\n                        ci.set_by_tag_name(cinfo_tag, \"\")\n                case widget.default:  # If it matches the default then do nothing\n                    self.log.trace(LOG_TAG + f\"Omitting {cinfo_tag}. Has default value\")\n                case \"\":\n                    ci.set_by_tag_name(cinfo_tag, \"\")\n                    self.log.trace(LOG_TAG + f\"Tag '{cinfo_tag}' content was reset or was empty\")\n                case _:\n                    ci.set_by_tag_name(cinfo_tag, widget_value)\n                    self.log.trace(LOG_TAG + f\"Tag '{cinfo_tag}' has overwritten content: '{widget_value}'\")\n                    # self.log.warning(f\"Unhandled case: {widget_value}\")\n        return ci\n\n    def process_gui_update(self, old_selection: list[LoadedComicInfo], new_selection: list[LoadedComicInfo]):\n        self.new_edited_cinfo = self._serialize_gui_to_cinfo()\n        self.merge_changed_metadata(old_selection)\n\n        self.show_not_saved_indicator(old_selection)\n        self.widget_mngr.clean_widgets()\n        # Display new selection data\n        self._serialize_cinfolist_to_gui(new_selection)\n\n\n    def fill_from_filename(self) -> None:\n        \"\"\"Handles taking the currently selected file and parsing any information out of it and writing to Empty fields\"\"\"\n        if not self.selected_files_path:\n            mb.showwarning(self.main_frame, \"No files selected\", \"No files were selected.\")\n            self.log.warning(\"No files selected\")\n            return\n\n        self.control_mngr.toggle(enabled=False)\n        self.changes_saved.place_forget()\n        self.pb.start(len(self.loaded_cinfo_list))\n        # Make sure current view is saved:\n        self.process_gui_update(self.selected_items, self.selected_items)\n        any_items_changed = False\n        try:\n            for item in self.selected_items:\n                # We can parse Series, Volume, Number, and Scan Info\n                if not item.cinfo_object.volume:\n                    vol = parse_volume(item.file_name)\n                    if vol:\n                        item.cinfo_object.volume = vol\n                        item.has_changes = True\n                        any_items_changed = True\n\n                if not item.cinfo_object.series:\n                    series = parse_series(item.file_name)\n                    if series:\n                        item.cinfo_object.series = series\n                        item.has_changes = True\n                        any_items_changed = True\n\n                if not item.cinfo_object.number:\n                    number = parse_number(item.file_name)\n                    if number:\n                        item.cinfo_object.number = number\n                        item.has_changes = True\n                        any_items_changed = True\n        finally:\n            self.pb.stop()\n        self.show_not_saved_indicator(self.loaded_cinfo_list)\n        if any_items_changed:\n            self.show_not_saved_indicator(self.selected_items)\n            self._serialize_cinfolist_to_gui(self.selected_items)\n        self.control_mngr.toggle(enabled=True)\n\n    def pre_process(self) -> None:\n        \"\"\"\n        Handles UI stuff to be started prior to processing such as converting ui data to comicinfo and starting the timer\n        \"\"\"\n        if not self.selected_files_path:\n            mb.showwarning(self.main_frame, \"No files selected\", \"No files were selected.\")\n            self.log.warning(\"No files selected\")\n            return\n        self.control_mngr.toggle(enabled=False)\n        self.changes_saved.place_forget()\n        self.pb.start(len(self.loaded_cinfo_list))\n        # Make sure current view is saved:\n        self.process_gui_update(self.selected_items, self.selected_items)\n        try:\n            self.process()\n        finally:\n            self.pb.stop()\n        self.show_not_saved_indicator(self.loaded_cinfo_list)\n        self.new_edited_cinfo = None  # Nulling value to be safe\n        self.control_mngr.toggle(enabled=True)\n\n    # Unique methods\n    def _fill_filename(self):\n        if len(self.selected_items) == 1:\n            self.widget_mngr.get_widget(\"Series\").set(self.selected_items[0].file_name)\n\n    def _fill_foldername(self):\n        if len(self.selected_items) == 1:\n            self.widget_mngr.get_widget(\"Series\").set(\n                os.path.basename(os.path.dirname(self.selected_items[0].file_path)))\n        else:\n            for loaded_cinfo in self.selected_items:\n                _ = loaded_cinfo.cinfo_object\n                loaded_cinfo.cinfo_object.series = os.path.basename(os.path.dirname(loaded_cinfo.file_path))\n                loaded_cinfo.has_changes = True\n            self.show_not_saved_indicator(self.selected_items)\n            self.widget_mngr.clean_widgets()\n            # Display new selection data\n            self._serialize_cinfolist_to_gui(self.selected_items)\n\n    def _treeview_open_explorer(self, file):\n        open_folder(os.path.dirname(file), file)\n        ...\n\n    def _treview_reset(self, event=None):\n        ...\n\n    def display_extensions(self, parent_frame):\n        from src import loaded_extensions\n        for loaded_extension in loaded_extensions:\n            tkinter.Button(parent_frame, text=loaded_extension.name, command=lambda load_ext=loaded_extension:\n            load_ext(parent_frame, super_=self)).pack(side=\"top\")\n\n    def process_fetch_online(self, *_):\n        series_name = self.widget_mngr.get_widget(\"Series\").get().strip()\n        if series_name == self.MULTIPLE_VALUES_CONFLICT:\n            mb.showwarning(self.main_frame, \"Not a valid series name. Multiple values conflict.\")\n            self.log.info(\"Not a valid series name - Conflic with other series name in selection\")\n            return\n        if series_name in (None, \"\") and self.widget_mngr.get_widget(\"Web\").get() in (None,\"\"):\n            mb.showwarning(self.main_frame, \"Not a valid series name\", \"The current series name is empty or not valid.\")\n            self.log.info(\"Not a valid series name - The current series name is empty or not valid.\")\n            return\n\n        # If multiple files are selected, validate that all files have the same series name\n        if len(self.selected_items) > 1:\n            if not all(series_name == item.cinfo_object.series.strip() for item in self.selected_items):\n                mb.showwarning(self.main_frame, \"All series MUST match and may not contain blanks\",\n                               \"All files' series names are not the same.\")\n                self.log.info(\n                    \"All series MUST match and may not contain blanks - All files' series names are not the same.\")\n                return\n\n        cinfo = self.fetch_online(self._serialize_gui_to_cinfo())\n        if cinfo is None:\n            return\n\n        self._serialize_cinfolist_to_gui([LoadedComicInfo(None, cinfo, load_default_metadata=False)])\n\n    def clean_selected(self):\n\n        self.widget_mngr.clean_widgets()\n        self.image_cover_frame.clear()\n        self.selected_files_path = list()\n        self.selected_files_treeview.clear()"
  },
  {
    "path": "MangaManager/src/MetadataManager/MetadataManagerLib.py",
    "content": "from __future__ import annotations\n\nimport abc\nimport logging\nfrom abc import ABC\n\nfrom ExternalSources.MetadataSources import ScraperFactory\nfrom common.models import ComicInfo\nfrom src.Common.LoadedComicInfo.LoadedComicInfo import LoadedComicInfo\nfrom src.Common.errors import EditedCinfoNotSet, MangaNotFoundError, MissingRarTool\nfrom src.Common.errors import NoComicInfoLoaded, CorruptedComicInfo, BadZipFile\nfrom src.Common.terminalcolors import TerminalColors as TerCol\nfrom src.Settings import SettingHeading\nfrom src.Settings.Settings import Settings\n\nlogger = logging.getLogger(\"MetaManager.Core\")\n\n\nclass _IMetadataManagerLib(abc.ABC):\n    def on_item_loaded(self, loaded_cinfo: LoadedComicInfo,cursor,total):\n        \"\"\"\n        Called when a loadedcomicinfo is loaded\n        :return:\n        \"\"\"\n\n    @abc.abstractmethod\n    def on_badzipfile_error(self, exception, file_path):\n        \"\"\"\n        Called while loading a file, and it's not a valid zip or it's broken\n        \"\"\"\n\n    @abc.abstractmethod\n    def on_processed_item(self, loaded_info: LoadedComicInfo):\n        \"\"\"\n        Called when  a file is successfully processed\n        \"\"\"\n\n    @abc.abstractmethod\n    def on_corruped_metadata_error(self, exception, loaded_info: LoadedComicInfo):\n        \"\"\"\n        Called while loading a file, and it's metadata can't be read.\n        \"\"\"\n\n    @abc.abstractmethod\n    def on_writing_error(self, exception, loaded_info: LoadedComicInfo):\n        \"\"\"\n        Called while trying to save to the file.\n        Posible callees (but not limited to): FailedBackup,\n        \"\"\"\n\n    @abc.abstractmethod\n    def on_writing_exception(self, exception, loaded_info: LoadedComicInfo):\n        \"\"\"\n        Called when an unhandled exception occurred trying to save the file\n        \"\"\"\n\n    @abc.abstractmethod\n    def on_manga_not_found(self, exception, series_name):\n        \"\"\"\n        Called when a series is not found in the api\n        \"\"\"\n    @abc.abstractmethod\n    def on_missing_rar_tools(self,exception):\n        \"\"\"\n        Caññed whem rar tools are not available\n        \"\"\"\n\nclass MetadataManagerLib(_IMetadataManagerLib, ABC):\n    \"\"\"\n    The core of metadata editor.\n    It has the logic to merge all the data of each fields across multiple files.\n    \"\"\"\n    is_cli = False\n    is_test = False\n    selected_files_path = None\n    new_edited_cinfo: ComicInfo | None = None\n    loaded_cinfo_list: list[LoadedComicInfo] = list()\n    cinfo_tags: list[str] = [\"Title\", \"Series\", \"LocalizedSeries\", \"Number\", \"Count\", \"Volume\", \"AlternateSeries\", \"AlternateNumber\",\n                             \"AlternateCount\", \"Summary\", \"Notes\", \"Year\", \"Month\", \"Day\", \"Writer\", \"Penciller\",\n                             \"Inker\", \"Colorist\", \"Letterer\", \"CoverArtist\", \"Editor\", \"Translator\", \"Publisher\",\n                             \"Imprint\", \"Genre\", \"Tags\", \"Web\", \"PageCount\", \"LanguageISO\", \"Format\", \"BlackAndWhite\",\n                             \"Manga\", \"Characters\", \"Teams\", \"Locations\", \"ScanInformation\", \"StoryArc\",\n                             \"StoryArcNumber\", \"SeriesGroup\", \"AgeRating\", \"CommunityRating\",\n                             \"MainCharacterOrTeam\", \"Other\", \"Review\",\"GTIN\"\n    ]\n    MULTIPLE_VALUES_CONFLICT = \"~~## Keep Original Value ##~~\"\n    tags_with_multiple_values = []\n\n    @property\n    def loaded_cinfo_list_to_process(self) -> list[LoadedComicInfo]:\n        return [loaded_cinfo for loaded_cinfo in self.loaded_cinfo_list if loaded_cinfo.has_changes]\n\n    def process(self):\n        \"\"\"\n        Iterates the list of loaded_cinfo.\n        Skips cinfo that do not have modified metadata\n\n\n        :return: list of loadedcinfo that failed to update :\n        \"\"\"\n        LOG_TAG = \"[Processing] \"\n        if not self.loaded_cinfo_list:\n            self.loaded_cinfo_list_to_proces: list[LoadedComicInfo] = list()\n            raise NoComicInfoLoaded()\n        try:\n            for loaded_cinfo in self.loaded_cinfo_list:\n                if not loaded_cinfo.has_changes:\n                    logger.info(LOG_TAG + f\"Skipping file processing. No changes to it. File: '{loaded_cinfo.file_name}'\")\n                    self.on_processed_item(loaded_cinfo)\n                    continue\n                # noinspection PyBroadException\n                self.preview_export(loaded_cinfo)\n                try:\n                    loaded_cinfo.write_metadata()\n                    loaded_cinfo.has_changes = False\n                    self.on_processed_item(loaded_cinfo)\n                except PermissionError as e:\n                    logger.error(\"Failed to write changes because of missing permissions \"\n                                 \"or because other program has the file opened\", exc_info=True)\n                    self.on_writing_error(exception=e, loaded_info=loaded_cinfo)\n                    # failed_processing.append(loaded_info)\n                except Exception as e:\n                    logger.exception(\"Unhandled exception saving changes\")\n                    self.on_writing_exception(exception=e, loaded_info=loaded_cinfo)\n        finally:\n            self.loaded_cinfo_list_to_proces: list[LoadedComicInfo] = list()\n\n    def merge_changed_metadata(self, loaded_cinfo_list: list[LoadedComicInfo]) -> bool:\n        \"\"\"\n        Merges new_edited_cinfo with each individual loaded_cinfo.\n        If field is ~~Multiple...Values~~, nothing will be changed.\n        Else new_cinfo value will be saved\n        :return: True if any loaded_cinfo has changes\n        \"\"\"\n        LOG_TAG = \"[Merging] \"\n        any_has_changes = False\n\n        if not self.new_edited_cinfo:\n            raise EditedCinfoNotSet()\n        if loaded_cinfo_list is None:\n            return False\n        for loaded_cinfo in loaded_cinfo_list:\n            logger.debug(LOG_TAG + f\"Merging changes to {loaded_cinfo.file_path}\")\n            for cinfo_tag in self.cinfo_tags:\n                if cinfo_tag == \"PageCount\":\n                    continue\n                # Check if the ui has $Multiple_files_selected$\n                new_value = str(self.new_edited_cinfo.get_by_tag_name(cinfo_tag))\n                if new_value == self.MULTIPLE_VALUES_CONFLICT:\n                    logger.trace(LOG_TAG + f\"Ignoring {cinfo_tag}. Keeping old values\")\n                    continue\n\n                # Check if the new value in the ui is the same as the one in the comicinfo\n                old_value = str(loaded_cinfo.cinfo_object.get_by_tag_name(cinfo_tag))\n                if old_value == new_value:\n                    logger.trace(LOG_TAG + f\"Ignoring {cinfo_tag}. Field has not changed\")\n                    continue\n\n                # Nothing matches so overriding comicinfo value with whatever is in the ui\n                if cinfo_tag not in loaded_cinfo.changed_tags:\n                    loaded_cinfo.changed_tags.append((cinfo_tag, old_value, new_value))\n                logger.debug(LOG_TAG + f\"[{cinfo_tag:15s}] {TerCol.GREEN}Updating{TerCol.RESET} - Old '{TerCol.RED}{old_value}{TerCol.RESET}' vs \"\n                             f\"New: '{TerCol.YELLOW}{new_value}{TerCol.RESET}' - Keeping {TerCol.YELLOW}new{TerCol.RESET} value\")\n                loaded_cinfo.cinfo_object.set_by_tag_name(cinfo_tag, new_value)\n                loaded_cinfo.has_changes = True\n                any_has_changes = True\n\n            # Check if covers are modified\n            if any((loaded_cinfo.cover_action is not None, loaded_cinfo.backcover_action is not None)):\n                any_has_changes = True\n                loaded_cinfo.has_changes = True\n\n        self.new_edited_cinfo = None\n        return any_has_changes\n\n    def open_cinfo_list(self, abort_load_check:callable,append_items=False) -> bool:\n        \"\"\"\n        Creates a list of comicinfo with the comicinfo metadata from the selected files.\n\n        :raises CorruptedComicInfo: If the data inside ComicInfo.xml could not be read after trying to fix the data\n        :raises BadZipFile: If the provided zip is not a valid zip or is broken\n        \"\"\"\n\n        logger.debug(\"Loading files\")\n        if append_items is False:\n            self.loaded_cinfo_list: list[LoadedComicInfo] = list()\n\n        # Skip warnings if one was already displayed\n        missing_rar_tool = False\n        total_files = len(self.selected_files_path)\n        if total_files == 0:\n            return False\n        for i, file_path in enumerate(self.selected_files_path):\n            if any(file_path in comic.file_path for comic in self.loaded_cinfo_list):\n                logger.warning(\"Skipped loading file: File already loaded\",extra={'processed_filename':file_path})\n                continue\n\n            if abort_load_check():\n                logger.info(\"Abort loading\")\n                self.loaded_cinfo_list: list[LoadedComicInfo] = list()\n                return False\n            try:\n                loaded_cinfo = LoadedComicInfo(path=file_path)\n                if Settings().get(SettingHeading.Main, 'cache_cover_images') and not self.is_cli:\n                    loaded_cinfo.load_all()\n                else:\n                    loaded_cinfo.load_metadata()\n            except CorruptedComicInfo as e:\n                # Logging is handled already in LoadedComicInfo load_metadata method\n                loaded_cinfo = LoadedComicInfo(path=file_path, comicinfo=ComicInfo()).load_metadata()\n                self.on_corruped_metadata_error(e, loaded_info=loaded_cinfo or file_path)\n                continue\n            except BadZipFile as e:\n                logger.error(\"Bad zip file. Either the format is not correct or the file is broken\", exc_info=False)\n                self.on_badzipfile_error(e, file_path=file_path)\n                continue\n            except EOFError as e:\n                logger.error(\"Bad zip file. The file seems to be broken\", exc_info=True)\n                self.on_badzipfile_error(e, file_path=file_path)\n                continue\n            except MissingRarTool as e:\n                if not missing_rar_tool:\n                    logger.exception(\"Error loading the metadata for some files. No rar tools available\", exc_info=False)\n                    self.on_missing_rar_tools(e)\n                missing_rar_tool = True\n                continue\n\n            self.loaded_cinfo_list.append(loaded_cinfo)\n            self.on_item_loaded(loaded_cinfo=loaded_cinfo, cursor=i, total=total_files)\n\n            # self.on_item_loaded(loaded_cinfo)\n        logger.debug(\"Files selected\")\n        return True\n\n    def preview_export(self, loaded_cinfo):\n        \"\"\"\n        Debug function to preview loaded_cinfo in terminal\n        :param loaded_cinfo:\n        :return:\n        \"\"\"\n        ...\n\n    def fetch_online(self, partial_comic_info):\n        selected_source = ScraperFactory().get_scraper(Settings().get(SettingHeading.ExternalSources, 'default_metadata_source'))\n        if not selected_source:\n            raise Exception(\"Unhandled exception. Metadata sources are not loaded or there's a bug in it.\"\n                            \"Raise an issue if this happens.\")\n        try:\n            return selected_source.get_cinfo(partial_comic_info)\n        except MangaNotFoundError as e:\n            logger.exception(str(e))\n            self.on_manga_not_found(e, partial_comic_info)\n            return None\n\n\n"
  },
  {
    "path": "MangaManager/src/MetadataManager/__init__.py",
    "content": "import logging\nimport src\nfrom src.Common import ResourceLoader\nfrom src.MetadataManager.GUI.windows.MainWindow import MainWindow\nfrom src.MetadataManager.GUI.OneTimeMessageBox import OneTimeMessageBox\nfrom src.MetadataManager.GUI.widgets.MessageBoxWidget import MessageBoxButton\nfrom src.Settings import Settings, SettingHeading\n\nlogger = logging.getLogger()\n\nicon_path = ResourceLoader.get('icon.ico')\n\n\ndef load_extensions():\n    from src.DynamicLibController.extension_manager import load_extensions\n    try:\n        src.loaded_extensions = load_extensions(src.EXTENSIONS_DIR)\n    except Exception:\n        logger.exception(\"Exception loading the extensions\")\n\ndef execute_gui():\n    # Ensure there are some settings, if not, set them as the default\n    Settings().set_default(SettingHeading.ExternalSources, 'default_metadata_source', \"AniList\")\n    Settings().set_default(SettingHeading.ExternalSources, 'default_cover_source', \"MangaDex\")\n\n    app = MainWindow()\n\n    try:\n        app.iconbitmap(icon_path)\n    except:\n        logger.exception(\"Exception loading icon\")\n\n    OneTimeMessageBox(\"test_welcome_to_mm\"). \\\n        with_title(\"Welcome to MangaManager\"). \\\n        with_actions([MessageBoxButton(0, \"Thanks\")]). \\\n        build().prompt()\n\n    app.mainloop()\n"
  },
  {
    "path": "MangaManager/src/Settings/SettingControl.py",
    "content": "import abc\nfrom typing import Callable\n\nfrom .SettingControlType import SettingControlType\n\n\nclass SettingControl(abc.ABC):\n    key: str = ''\n    name: str = ''\n    tooltip: str | None = ''\n    control_type: SettingControlType\n    value = ''\n    \"\"\"\n    Only applicable for SettingControlType.Options\n    \"\"\"\n    values: list = []\n    # Used to run validations on the control\n    validate: Callable[[str], str] = None\n    # Used to format the value before saving to persistence layer\n    format_value: Callable[[str], str] = None\n\n    def __init__(self, key, name, control_type, value='', tooltip='', validate=None, format_value=None):\n        self.key = key\n        self.name = name\n        self.control_type: SettingControlType = control_type\n        self.value = value\n        self.tooltip = tooltip\n        self.validate = validate\n        self.format_value = format_value\n\n        if value == '' and type is SettingControlType.Bool:\n            self.value = False\n\n    def set_values(self, values):\n        if self.control_type is not SettingControlType.Options:\n            return\n        self.values = values\n"
  },
  {
    "path": "MangaManager/src/Settings/SettingControlType.py",
    "content": "from enum import Enum\n\n\nclass SettingControlType(Enum):\n    Bool = 0,\n    Text = 1,\n    Options = 2"
  },
  {
    "path": "MangaManager/src/Settings/SettingSection.py",
    "content": "from .SettingControl import SettingControl\n\n\nclass SettingSection:\n    \"\"\"\n     A section of config controls. Will render under a group in Settings window\n    \"\"\"\n    pretty_name: str = ''\n    values: list[SettingControl] = []\n\n    def __init__(self, name, key, values=None):\n        if values is None:\n            values = list()\n        self.pretty_name = name\n        self.key = key\n        self.values = values\n\n    def get_control(self, key):\n        for v in self.values:\n            if v.key == key:\n                return v\n        return None\n"
  },
  {
    "path": "MangaManager/src/Settings/Settings.py",
    "content": "import configparser\nimport logging\nimport os\nfrom pathlib import Path\n\nfrom src.Settings.SettingsDefault import default_settings\n\nlogger = logging.getLogger()\nSETTING_FILE = \"settings.ini\"\n\nclass Settings:\n    \"\"\" This is a singleton that holds settings.ini key/values \"\"\"\n    __instance = None\n    config_parser = configparser.ConfigParser(interpolation=None)\n    _config_file: Path = Path(Path.home(), \"MangaManager/\" + SETTING_FILE)\n\n    @property\n    def config_file(self):\n        return Settings._config_file\n    def __new__(cls):\n        if Settings.__instance is None:\n            Settings.__instance = object.__new__(cls)\n            # Settings._config_file= os.path.abspath(config_file)\n\n        if len(Settings.__instance.config_parser.sections()) == 0:\n            logger.info('Loading Config from: {}'.format(Settings.__instance.config_file))\n            Settings.__instance.load()\n\n        return Settings.__instance\n\n    def __init__(self):\n        # self.config_file = config_file\n        if os.path.exists(self.config_file):\n            self.load()\n        else:\n            if not os.path.exists(SETTING_FILE):\n                self.save()\n                self.load()\n            else:\n                self.load(SETTING_FILE)\n                self.save()\n    def save(self):\n        \"\"\"Save the current settings from memory to disk\"\"\"\n        with open(self._config_file, 'w') as configfile:\n            self.config_parser.write(configfile)\n\n    def load(self,override_settings_from=None):\n        \"\"\"Load the data from file and populate DefaultSettings\"\"\"\n\n        self.config_parser.read(override_settings_from or self._config_file) # migration, change file location\n\n        # Ensure all default settings exists, else add them\n        for section in default_settings:\n            if section not in self.config_parser.sections():\n                self.config_parser.add_section(section)\n            for item in default_settings[section]:\n                for (key, value) in item.items():\n                    if key not in self.config_parser[section] or self.config_parser.get(section, key) == \"\":\n                        self.config_parser.set(section, key, str(value))\n\n        self.save()\n\n    def get(self, section, key):\n        \"\"\"Get a key's value, None if not present\"\"\"\n        if not self.config_parser.has_section(section) or not self.config_parser.has_option(section, key):\n            logger.error('Section or Key did not exist in settings: {}.{}'.format(section, key))\n            return None\n        value = self.config_parser.get(section, key).strip()\n        match value.lower():\n            case \"true\":\n                return True\n            case \"false\":\n                return False\n            case _:\n                return value\n\n    def set_default(self, section, key, value):\n        \"\"\"Sets a key's value only if it doesn't exist\"\"\"\n        self._create_section(section)\n        if key not in self.config_parser[section]:\n            self.config_parser.set(section, key, str(value))\n\n    def get_default(self, section, key, default_value):\n        \"\"\"\n        Returns default value and creates the key if it doesn't exist\n        \"\"\"\n        self.set_default(section, key, default_value)\n        return self.get(section, key)\n\n    def set(self, section, key, value):\n        \"\"\"Sets a key's value. Will Save to disk and reload Settings\"\"\"\n        self._create_section(section)\n        self.config_parser.set(section, key, str(value))\n        self.save()\n        self.load()\n\n    def _create_section(self, section):\n        if section not in self.config_parser:\n            self.config_parser.add_section(section)\n    def _load_test(self):\n        Settings._config_file = \"test_settings.ini\"\n        Settings.config_parser = configparser.ConfigParser(interpolation=None)\n        self.save()\n        self.load()\n\n"
  },
  {
    "path": "MangaManager/src/Settings/SettingsDefault.py",
    "content": "from enum import StrEnum\n\n\nclass SettingHeading(StrEnum):\n    Main = \"Main\",\n    WebpConverter = \"Webp Converter\",\n    ExternalSources = \"External Sources\",\n    MessageBox = \"Message Box\"\n\n\ndefault_settings = {\n    SettingHeading.Main: [\n        {\"library_path\": \"\"},\n        {\"covers_folder_path\": \"\"},\n        {\"cache_cover_images\": True},\n        {\"create_backup_comicinfo\": True},\n        # {\"selected_layout\": \"default\"},\n        {\"move_to_template\": \"\"},\n        {\"remove_old_selection_on_drag_drop\":True}\n\n    ],\n    SettingHeading.WebpConverter: [\n        {\"default_base_path\": \"\"},\n    ],\n    SettingHeading.ExternalSources: [\n        {\"default_metadata_source\": \"AniList\"},\n        {\"default_cover_source\": \"MangaDex\"},\n    ],\n    SettingHeading.MessageBox: {}\n}\n\n\n"
  },
  {
    "path": "MangaManager/src/Settings/__init__.py",
    "content": "from .SettingControl import SettingControl\nfrom .SettingControlType import SettingControlType\nfrom .SettingSection import SettingSection\nfrom .Settings import Settings\nfrom .SettingsDefault import SettingHeading\n"
  },
  {
    "path": "MangaManager/src/__init__.py",
    "content": "import logging\nfrom os import environ\nfrom os.path import abspath\nfrom pathlib import Path\n\nimport requests  # Needed for sources to work\n\nrequests.s3423 = \"\"  # Random patch so import does not get cleaned up\n\nfrom pkg_resources import resource_filename\n\nlogger = logging.getLogger()\nMM_PATH = Path(Path.home(), \"MangaManager\")\nMM_PATH.mkdir(exist_ok=True, parents=True)\nDEV_BUILD = f'{environ.get(\"$$_ENV_DEVELOPMENT_MM_$$\")}'\nDEV_BUILD = DEV_BUILD.lower() == \"true\"\n\nsub_mm_path = abspath(resource_filename(__name__, '../'))\nlogger.error(f\"sub_mm_path:{sub_mm_path}\")\nEXTENSIONS_DIR = Path(sub_mm_path, \"Extensions\")\nEXTENSIONS_DIR.mkdir(exist_ok=True)\n\nSOURCES_DIR = Path(sub_mm_path, \"ExternalSources\")\nSOURCES_DIR.mkdir(exist_ok=True)\n\nloaded_extensions =  []\n\n"
  },
  {
    "path": "MangaManager/src/__version__.py",
    "content": "__version__ = \"1.0.4:stable:fd7b72b0\"\n"
  },
  {
    "path": "MangaManager/tests/Common/__init__.py",
    "content": ""
  },
  {
    "path": "MangaManager/tests/Common/test_ComicInfo.py",
    "content": "import unittest\n\nfrom common.models import ComicInfo\nfrom tests.common import is_valid_xml\n\n\nclass ComicInfoTests(unittest.TestCase):\n    def test_sample_xml_isvalid(self):\n        cinfo = ComicInfo()\n        cinfo.series = \"SeriesName\"\n        cinfo.writer = \"WriterName\"\n\n        self.assertTrue(is_valid_xml(cinfo.to_xml()))\n    def test_valid_xml(self):\n        TEST_COMIC_INFO_STRING = \"\"\"<ComicInfo>\n            <Title>Title</Title>\n            <AlternateSeries>AlternateSeries</AlternateSeries>\n            <Summary>Summary</Summary>\n            <Notes>Notes</Notes>\n            <Writer>Writer</Writer>\n            <Inker>Inker</Inker>\n            <Colorist>Colorist</Colorist>\n            <Letterer>Letterer</Letterer>\n            <CoverArtist>CoverArtist</CoverArtist>\n            <Editor>Editor</Editor>\n            <Translator>Translator</Translator>\n            <Publisher>Publisher</Publisher>\n            <Imprint>Imprint</Imprint>\n            <Genre>Genre</Genre>\n            <Tags>Tags</Tags>\n            <Web>Web</Web>\n            <Characters>Characters</Characters>\n            <Teams>Teams</Teams>\n            <Locations>Locations</Locations>\n            <ScanInformation>ScanInformation</ScanInformation>\n            <StoryArc>StoryArc</StoryArc>\n            <SeriesGroup>SeriesGroup</SeriesGroup>\n            <AgeRating>Unknown</AgeRating>\n            <CommunityRating>3</CommunityRating>\n            <Other>Other field</Other>\n        </ComicInfo>\n        \"\"\"\n        self.assertTrue(is_valid_xml(TEST_COMIC_INFO_STRING))\n\nif __name__ == '__main__':\n    unittest.main()"
  },
  {
    "path": "MangaManager/tests/Common/test_utils.py",
    "content": "import unittest\n\nfrom common.models import ComicInfo\nfrom src.DynamicLibController.models.IMetadataSource import IMetadataSource\n\n\nclass MyTestCase(unittest.TestCase):\n    def test_update_people_from_mapping(self):\n        people_mapping = {\n            \"Author\": [\n                \"Writer\"\n            ],\n            \"Artist\": [\n                \"Penciller\",\n                \"Inker\",\n            ]\n        }\n\n        data = {\n            \"authors\": [\n                {\n                    \"name\": \"Author 1\",\n                    \"role\": \"Author\"\n                },\n                {\n                    \"name\": \"Artist 1\",\n                    \"role\": \"Artist\"\n                },\n            ]\n        }\n\n        comicinfo = ComicInfo()\n\n        IMetadataSource.update_people_from_mapping(data[\"authors\"], people_mapping, comicinfo,\n                                   lambda item: item[\"name\"],\n                                   lambda item: item[\"role\"])\n\n        self.assertEqual(\"Author 1\", comicinfo.writer)\n        self.assertEqual(\"Artist 1\", comicinfo.penciller)\n        self.assertEqual(\"Artist 1\", comicinfo.inker)\n\n\nif __name__ == '__main__':\n    unittest.main()\n"
  },
  {
    "path": "MangaManager/tests/ExtensionsTests/__init__.py",
    "content": ""
  },
  {
    "path": "MangaManager/tests/ExtensionsTests/test_WebpConverter.py",
    "content": "# from src.Common.loadedcomicinfo import LoadedComicInfo\nfrom logging_setup import add_trace_level\n\nadd_trace_level()\n\n# Fixme reimplement extensions\n# class LoadedComicInfoConversToWebpTests(unittest.TestCase):\n#     def setUp(self) -> None:\n#         print(os.getcwd())\n#         # Make sure there are no test files else delete them:\n#         leftover_files = [listed for listed in os.listdir() if listed.startswith(\"Test__\") and listed.endswith(\".cbz\")\n#                           or listed.startswith(\"tmp\")]\n#         for file in leftover_files:\n#             os.remove(file)\n#         self.test_files_names = []\n#         print(\"\\n\", self._testMethodName)\n#         print(\"Setup:\")\n#         self.random_int = random.random() + random.randint(1, 40)\n#         for ai in range(3):\n#             out_tmp_zipname = f\"Test__{ai}_{random.randint(1, 6000)}.cbz\"\n#             self.test_files_names.append(out_tmp_zipname)\n#             self.temp_folder = tempfile.mkdtemp()\n#             print(f\"    Creating: {out_tmp_zipname}\")  # , self._testMethodName)\n#             # Create a random int so the values in the cinfo are unique each test\n#\n#             with zipfile.ZipFile(out_tmp_zipname, \"w\") as zf:\n#                 for i in range(5):\n#                     image = Image.new('RGB', size=(20, 20), color=(255, 73, 95))\n#                     image.format = \"JPEG\"\n#                     # file = tempfile.NamedTemporaryFile(suffix=f'.jpg', prefix=str(i).zfill(3), dir=self.temp_folder)\n#                     imgByteArr = io.BytesIO()\n#                     image.save(imgByteArr, format=image.format)\n#                     imgByteArr = imgByteArr.getvalue()\n#                     zf.writestr(os.path.basename(f\"{str(i).zfill(3)}.jpg\"), imgByteArr)\n#             self.initial_dir_count = len(os.listdir(os.getcwd()))\n#\n#     def tearDown(self) -> None:\n#         print(\"Teardown:\")\n#         for filename in self.test_files_names:\n#             print(f\"    Deleting: {filename}\")  # , self._testMethodName)\n#             try:\n#                 os.remove(filename)\n#             except Exception as e:\n#                 print(e)\n#\n#     def test_processing_should_convert_to_webp(self):\n#         file_name = self.test_files_names[0]\n#         loaded_cinfo = LoadedComicInfo(file_name).load_cover_info()\n#         loaded_cinfo.convert_to_webp()\n#\n#         with zipfile.ZipFile(file_name, \"r\") as zf:\n#             for filename in zf.namelist():\n#                 with zf.open(filename) as imagebytes:\n#                     image = Image.open(imagebytes)\n#                     self.assertEqual(\"WEBP\", image.format)\n\n\n# if __name__ == '__main__':\n#     unittest.main()\n"
  },
  {
    "path": "MangaManager/tests/ExternalMetadataTests/__init__.py",
    "content": ""
  },
  {
    "path": "MangaManager/tests/ExternalMetadataTests/test_AniList.py",
    "content": "import unittest\n\nfrom common.models import ComicInfo\n\n\nclass TestSources(unittest.TestCase):\n    def test_AnilistReturnMatches(self):\n        from ExternalSources.MetadataSources import ScraperFactory\n        scraper = ScraperFactory().get_scraper(\"AniList\")\n        cinfo = ComicInfo()\n        cinfo.series = \"tensei shitara datta ken\"\n\n        ret_cinfo = scraper.get_cinfo(cinfo)\n        print(\"Assert series name matches\")\n        self.assertEqual(\"Tensei Shitara Slime Datta Ken\", ret_cinfo.series)\n        print(\"Assert loc series name matches\")\n        self.assertEqual(\"That Time I Got Reincarnated as a Slime\", ret_cinfo.localized_series)\n\n    def test_AnilistReturnMatches_url(self):\n        from ExternalSources.MetadataSources import ScraperFactory\n        scraper = ScraperFactory().get_scraper(\"AniList\")\n        cinfo = ComicInfo()\n        cinfo.web = \"https://anilist.co/manga/98797/Adachi-to-Shimamura/\"\n\n        ret_cinfo = scraper.get_cinfo(cinfo)\n        print(\"Assert series name matches\")\n        self.assertIn(\"https://anilist.co/manga/98797\", [s.strip() for s in ret_cinfo.web.split(\",\")])\n"
  },
  {
    "path": "MangaManager/tests/LoadedComicInfo/__init__.py",
    "content": ""
  },
  {
    "path": "MangaManager/tests/LoadedComicInfo/test_Covers.py",
    "content": "import io\nimport unittest\nimport zipfile\n\nfrom PIL import Image\n\nfrom src.Common.LoadedComicInfo.LoadedComicInfo import CoverActions, LoadedComicInfo\nfrom src.Common.utils import obtain_cover_filename\nfrom tests.common import CBZManipulationTests, create_test_cbz\n\n\nclass LoadedCInfo_Utils(unittest.TestCase):\n    def test_CoverParsing(self):\n        list_filenames_to_test = [\n            (\"000001.jpg\", (\"0_ not valid image file 00001.jpg\", \"000001.jpg\", \"000002.jpg\",\n                            \"this is a random image from page 4.png\")),\n            (\"cover_0001.jpg\", (\"cover_0001.jpg\", \"0_ not valid image file 00001.jpg\", \"000001.jpg\", \"000002.jpg\",\n                                \"this is a random image from page 4.png\"))\n        ]\n        print(\"Running unit tests for cover filename parsing\")\n        for filename in list_filenames_to_test:\n            with self.subTest(f\"Subtest - Parsed name should match {filename[0]}\"):\n                selected = str(obtain_cover_filename(filename[1])[0])\n                print(f\"Selected file is: {selected}\")\n                self.assertEqual(filename[0], selected)\n\n        # self.assertEqual(True, False)  # add assertion here\n\nclass CoverHandling_Recompressing_Tests(CBZManipulationTests):\n    def setUp(self) -> None:\n        super().setUp()\n        self.test_files_names = create_test_cbz(2)\n        image = Image.new('RGB', size=(20, 20), color=(255, 73, 95))\n        image.format = \"JPEG\"\n        # imgByteArr = io.BytesIO()\n        self.test_image_file = \"Test__new_cover.jpeg\"\n        image.save(\"Test__new_cover.jpeg\", format=image.format)\n        self.test_files_names.append(\"Test__new_cover.jpeg\")\n\n    def test_delete_cover(self):\n        for file in self.test_files_names:\n            if not file.endswith(\".cbz\"):\n                continue\n            lcinfo = LoadedComicInfo(file).load_cover_info(False)\n            lcinfo.cover_action = CoverActions.DELETE\n            lcinfo._process(False, False)\n            with zipfile.ZipFile(file, \"r\") as zf:\n                print(\"Asserting the processed file has one image less\")\n                self.assertEqual(3, len(zf.namelist()))\n\n    def test_delete_backcover(self):\n        for file in self.test_files_names:\n            if not file.endswith(\".cbz\"):\n                continue\n            lcinfo = LoadedComicInfo(file).load_cover_info(False)\n            lcinfo.backcover_action = CoverActions.DELETE\n            lcinfo._process(False, False)\n            with zipfile.ZipFile(file, \"r\") as zf:\n                print(\"Asserting the processed file has one image less\")\n                self.assertEqual(3, len(zf.namelist()))\n\n    def test_append_cover(self):\n        for file in self.test_files_names:\n            if not file.endswith(\".cbz\"):\n                continue\n            lcinfo = LoadedComicInfo(file).load_cover_info(False)\n            lcinfo.cover_action = CoverActions.APPEND\n            lcinfo.new_cover_path = self.test_image_file\n            lcinfo._process(False, False)\n            with zipfile.ZipFile(file, \"r\") as zf:\n                print(\"Asserting the processed file has one image more\")\n                self.assertEqual(5, len(zf.namelist()))\n\n    def test_append_backcover(self):\n        for file in self.test_files_names:\n            if not file.endswith(\".cbz\"):\n                continue\n            lcinfo = LoadedComicInfo(file).load_cover_info(False)\n            lcinfo.backcover_action = CoverActions.APPEND\n            lcinfo.new_backcover_path = self.test_image_file\n            lcinfo._process(False, False)\n            with zipfile.ZipFile(file, \"r\") as zf:\n                print(\"Asserting the processed file has one image more\")\n                self.assertEqual(5, len(zf.namelist()))\n                self.assertTrue(obtain_cover_filename(zf.namelist())[1].startswith(\"~99\"))\n\n\n    def test_replace_cover(self):\n        for file in self.test_files_names:\n            if not file.endswith(\".cbz\"):\n                continue\n            lcinfo = LoadedComicInfo(file).load_cover_info(False)\n            lcinfo.cover_action = CoverActions.REPLACE\n            lcinfo.new_cover_path = self.test_image_file\n            lcinfo._process(False, False)\n            with zipfile.ZipFile(file, \"r\") as zf:\n                print(\"Asserting the processed file has one image more\")\n                self.assertEqual(4, len(zf.namelist()))\n                image_data = zf.read(obtain_cover_filename(zf.namelist())[0])\n                image = Image.open(io.BytesIO(image_data))\n                image_color = image.getpixel((0,0))\n                self.assertFalse(image_color == (255,255,255))\n\n    def test_replace_backcover(self):\n        for file in self.test_files_names:\n            if not file.endswith(\".cbz\"):\n                continue\n            lcinfo = LoadedComicInfo(file).load_cover_info(False)\n            lcinfo.backcover_action = CoverActions.REPLACE\n            lcinfo.new_backcover_path = self.test_image_file\n            lcinfo._process(False, False)\n            with zipfile.ZipFile(file, \"r\") as zf:\n                print(\"Asserting the processed file has one image more\")\n                self.assertEqual(4, len(zf.namelist()))\n                image_data = zf.read(obtain_cover_filename(zf.namelist())[1])\n                image = Image.open(io.BytesIO(image_data))\n                image_color = image.getpixel((0,0))\n                self.assertFalse(image_color == (255,255,255))\n\n"
  },
  {
    "path": "MangaManager/tests/LoadedComicInfo/test_LoadedCInfo.py",
    "content": "import os\nimport random\nimport tempfile\nimport unittest\nimport zipfile\nfrom unittest import skip\n\nfrom common.models import ComicInfo\nfrom src.Common.LoadedComicInfo.LoadedComicInfo import LoadedComicInfo\n\nTEST_COMIC_INFO_STRING = \"\"\"\n<ComicInfo>\n    <Title>Title</Title>\n    <AlternateSeries>AlternateSeries</AlternateSeries>\n    <Summary>Summary</Summary>\n    <Notes>Notes</Notes>\n    <Writer>Writer</Writer>\n    <Inker>Inker</Inker>\n    <Colorist>Colorist</Colorist>\n    <Letterer>Letterer</Letterer>\n    <CoverArtist>CoverArtist</CoverArtist>\n    <Editor>Editor</Editor>\n    <Translator>Translator</Translator>\n    <Publisher>Publisher</Publisher>\n    <Imprint>Imprint</Imprint>\n    <Genre>Genre</Genre>\n    <Tags>Tags</Tags>\n    <Web>Web</Web>\n    <Characters>Characters</Characters>\n    <Teams>Teams</Teams>\n    <Locations>Locations</Locations>\n    <ScanInformation>ScanInformation</ScanInformation>\n    <StoryArc>StoryArc</StoryArc>\n    <SeriesGroup>SeriesGroup</SeriesGroup>\n    <AgeRating>Unknown</AgeRating>\n    <CommunityRating>3</CommunityRating>\n</ComicInfo>\n\"\"\"\n\n\nclass LoadedComicInfo_MetadataTests(unittest.TestCase):\n    \"\"\"\n    The purpose of this Test case is to test the LoadedComicInfo class against simple scenarios where\n    it's only the comicinfo.xml file\n    \"\"\"\n\n\n\n    def setUp(self) -> None:\n        print(os.getcwd())\n        # Make sure there are no test files else delete them:\n        leftover_files = [listed for listed in os.listdir() if listed.startswith(\"Test__\") and listed.endswith(\".cbz\")\n                          or listed.startswith(\"tmp\")]\n        for file in leftover_files:\n            os.remove(file)\n        self.test_files_names = []\n        print(\"\\n\", self._testMethodName)\n        print(\"Setup:\")\n        self.random_int = random.random() + random.randint(1, 40)\n        for ai in range(3):\n            out_tmp_zipname = f\"Test__{ai}_{random.randint(1, 6000)}.cbz\"\n            self.test_files_names.append(out_tmp_zipname)\n            self.temp_folder = tempfile.mkdtemp()\n            print(f\"     Creating: {out_tmp_zipname}\")  # , self._testMethodName)\n            # Create a random int so the values in the cinfo are unique each test\n\n            with zipfile.ZipFile(out_tmp_zipname, \"w\") as zf:\n                zf.writestr(\"Dummyfile1.ext\", \"Dummy\")\n                zf.writestr(\"Dummyfile2.ext\", \"Dummy\")\n                zf.writestr(\"Dummyfile3.ext\", \"Dummy\")\n                zf.writestr(\"Dummyfile4.ext\", \"Dummy\")\n                cinfo = ComicInfo()\n                cinfo.series = f\"Series-{ai}-{self.random_int}\"\n                cinfo.writer = f\"Writer-{ai}-{self.random_int}\"\n                zf.writestr(\"ComicInfo.xml\", str(cinfo.to_xml()))\n\n            with zipfile.ZipFile(f\"Test__nometadata.cbz\", \"w\") as zf:\n                zf.writestr(\"Dummyfile1.ext\", \"Dummy\")\n                zf.writestr(\"Dummyfile2.ext\", \"Dummy\")\n                zf.writestr(\"Dummyfile3.ext\", \"Dummy\")\n                zf.writestr(\"Dummyfile4.ext\", \"Dummy\")\n                cinfo = ComicInfo()\n                cinfo.series = f\"Series-{ai}-{self.random_int}\"\n                cinfo.writer = f\"Writer-{ai}-{self.random_int}\"\n            self.initial_dir_count = len(os.listdir(os.getcwd()))\n\n    def tearDown(self) -> None:\n        print(\"Teardown:\")\n        self.test_files_names.append(\"Test__nometadata.cbz\")\n        for filename in self.test_files_names:\n            print(f\"     Deleting: {filename}\")  # , self._testMethodName)\n            try:\n                os.remove(filename)\n            except Exception as e:\n                print(e)\n\n    def test_simple_read(self):\n        for i, file_names in enumerate(self.test_files_names):\n            with self.subTest(f\"Testing individual file read metadata - {i + 1}/{len(self.test_files_names)}\"):\n                cinfo = LoadedComicInfo(file_names).load_metadata()\n                self.assertEqual(f\"Series-{i}-{self.random_int}\", cinfo.cinfo_object.series)\n                self.assertEqual(f\"Writer-{i}-{self.random_int}\", cinfo.cinfo_object.writer)\n\n    def test_simple_write(self):\n        print(\"Writing new values\")\n        for i, file_names in enumerate(self.test_files_names):\n            with self.subTest(f\"Testing individual file read metadata - {i + 1}/{len(self.test_files_names)}\"):\n                cinfo = LoadedComicInfo(file_names).load_metadata()\n                cinfo.cinfo_object.notes = f\"This text was modified - {self.random_int}\"\n\n                cinfo.write_metadata()\n\n\n        print(\"Test files keep all images\")\n        for file_names in self.test_files_names:\n            with zipfile.ZipFile(file_names, \"r\") as zf:\n                self.assertAlmostEqual(len(zf.namelist()), 5,delta=1)\n        # check changes are saved\n        print(\"Testing reading saved values\")\n        for i, file_names in enumerate(self.test_files_names):\n            with self.subTest(f\"Testing individual write metadata - {i + 1}/{len(self.test_files_names)}\"):\n                cinfo = LoadedComicInfo(file_names).load_metadata()\n                self.assertEqual(f\"This text was modified - {self.random_int}\", cinfo.cinfo_object.notes)\n\n    @skip\n    def test_simple_backup(self):\n        for i, file_name in enumerate(self.test_files_names):\n            with self.subTest(f\"Backing up individual metadata - {i + 1}/{len(self.test_files_names)}\"):\n                cinfo = LoadedComicInfo(file_name).load_metadata()\n                # No backup will be created if no modified metadata\n                cinfo.cinfo_object.notes = \"Notes modified\"\n                cinfo.write_metadata()\n                with zipfile.ZipFile(file_name, \"r\") as zf:\n                    print(\"Asserting backup is in the file\")\n                    # In this test there should only be the backed up file because the new modified metadata file gets\n                    # appended later, after the backup flow is run.\n                    self.assertTrue(\"Old_ComicInfo.xml.bak\" in zf.namelist())\n\n                    print(\"Making sure the backed up file has content and matches original values:\")\n                    cinfo = ComicInfo.from_xml(zf.open(\"Old_ComicInfo.xml.bak\").read().decode(\"utf-8\"))\n                    self.assertEqual(f\"Series-{i}-{self.random_int}\", cinfo.series)\n\n    def test_simple_backup_nometadata(self):\n        file_name = \"Test__nometadata.cbz\"\n        with self.subTest(f\"Backing up individual metadata - {file_name}\"):\n            cinfo = LoadedComicInfo(file_name).load_metadata()\n            cinfo.write_metadata()\n            with zipfile.ZipFile(file_name, \"r\") as zf:\n                print(\"Asserting backup is in the file\")\n                # In this test there should only be the backed up file because the new modified metadata file gets\n                # appended later, after the backup flow is run.\n                self.assertFalse(\"Old_ComicInfo.xml.bak\" in zf.namelist())\n\nif __name__ == '__main__':\n    unittest.main()\n"
  },
  {
    "path": "MangaManager/tests/LoadedComicInfo/test_LoadedCInfo_backup.py",
    "content": "import os\nimport random\nimport tempfile\nimport unittest\nimport zipfile\n\nfrom common.models import ComicInfo\nfrom src.Common.LoadedComicInfo.LoadedComicInfo import LoadedComicInfo\nfrom src.Settings import Settings, SettingHeading\n\nTEST_COMIC_INFO_STRING = \"\"\"\n<ComicInfo>\n    <Title>Title</Title>\n    <AlternateSeries>AlternateSeries</AlternateSeries>\n    <Summary>Summary</Summary>\n    <Notes>Notes</Notes>\n    <Writer>Writer</Writer>\n    <Inker>Inker</Inker>\n    <Colorist>Colorist</Colorist>\n    <Letterer>Letterer</Letterer>\n    <CoverArtist>CoverArtist</CoverArtist>\n    <Editor>Editor</Editor>\n    <Translator>Translator</Translator>\n    <Publisher>Publisher</Publisher>\n    <Imprint>Imprint</Imprint>\n    <Genre>Genre</Genre>\n    <Tags>Tags</Tags>\n    <Web>Web</Web>\n    <Characters>Characters</Characters>\n    <Teams>Teams</Teams>\n    <Locations>Locations</Locations>\n    <ScanInformation>ScanInformation</ScanInformation>\n    <StoryArc>StoryArc</StoryArc>\n    <SeriesGroup>SeriesGroup</SeriesGroup>\n    <AgeRating>Unknown</AgeRating>\n    <CommunityRating>3</CommunityRating>\n</ComicInfo>\n\"\"\"\n\n\nclass LoadedComicInfo_SaveTests(unittest.TestCase):\n    \"\"\"\n    The purpose of this Test case is to test the LoadedComicInfo class against simple scenarios where\n    it's only the comicinfo.xml file\n    \"\"\"\n\n    s = Settings()\n\n    def setUp(self) -> None:\n        print(os.getcwd())\n        self.s.set(SettingHeading.Main, \"create_backup_comicinfo\", False)\n        # Make sure there are no test files else delete them:\n        leftover_files = [listed for listed in os.listdir() if listed.startswith(\"Test__\") and listed.endswith(\".cbz\")\n                          or listed.startswith(\"tmp\")]\n        for file in leftover_files:\n            os.remove(file)\n        self.test_files_names = []\n        print(\"\\n\", self._testMethodName)\n        print(\"Setup:\")\n        self.random_int = random.random() + random.randint(1, 40)\n        for ai in range(1):\n            out_tmp_zipname = f\"Test__{ai}_{random.randint(1, 6000)}.cbz\"\n            self.test_files_names.append(out_tmp_zipname)\n            self.temp_folder = tempfile.mkdtemp()\n            print(f\"     Creating: {out_tmp_zipname}\")  # , self._testMethodName)\n            # Create a random int so the values in the cinfo are unique each test\n\n            with zipfile.ZipFile(out_tmp_zipname, \"w\") as zf:\n                zf.writestr(\"Dummyfile1.ext\", \"Dummy\")\n                zf.writestr(\"Dummyfile2.ext\", \"Dummy\")\n                zf.writestr(\"Dummyfile3.ext\", \"Dummy\")\n                zf.writestr(\"Dummyfile4.ext\", \"Dummy\")\n                cinfo = ComicInfo()\n                cinfo.series = f\"Series-{ai}-{self.random_int}\"\n                cinfo.writer = f\"Writer-{ai}-{self.random_int}\"\n                zf.writestr(\"ComicInfo.xml\", str(cinfo.to_xml()))\n            self.initial_dir_count = len(os.listdir(os.getcwd()))\n\n    def tearDown(self) -> None:\n        print(\"Teardown:\")\n        for filename in self.test_files_names:\n            print(f\"     Deleting: {filename}\")  # , self._testMethodName)\n            try:\n                os.remove(filename)\n            except Exception as e:\n                print(e)\n\n    # I give up, I can't figure out how to do it and mock settings\n    # def test_simple_backup_doesnt_create_when_turned_off(self):\n    #     self.s.set(SettingHeading.Main, \"create_backup_comicinfo\", False)\n    #     for i, file_names in enumerate(self.test_files_names):\n    #         with self.subTest(f\"Backing up individual metadata - {i + 1}/{len(self.test_files_names)}\"):\n    #             cinfo = LoadedComicInfo(file_names).load_metadata()\n    #             cinfo.write_metadata()\n    #             with zipfile.ZipFile(file_names, \"r\") as zf:\n    #                 print(\"Asserting backup is in the file\")\n    #                 # In this test there should only be the backed up file because the new modified metadata file gets\n    #                 # appended later, after the backup flow is run.\n    #                 self.assertFalse(\"Old_ComicInfo.xml.bak\" in zf.namelist())\n\n\n\n\n\nif __name__ == '__main__':\n    unittest.main()\n"
  },
  {
    "path": "MangaManager/tests/LoadedComicInfo/test_moveto.py",
    "content": "import unittest\n\nfrom common.models import ComicInfo\nfrom src.Common.LoadedComicInfo.LoadedComicInfo import LoadedComicInfo\n\n\nclass MoveToTemplate(unittest.TestCase):\n    def test_template(self):\n        cinfo = ComicInfo()\n        cinfo.volume = 11\n        cinfo.number = 22\n        cinfo.publisher = \"Publisher33\"\n        cinfo.series = \"Series44\"\n        cinfo.title = \"Title55\"\n        filename = \"filename66.cbz\"\n        a = LoadedComicInfo(None,cinfo,False)\n        a.file_name = filename\n\n        self.assertEqual(\"Series44 - Publisher33\",a.get_template_filename(\"{series} - {publisher}\"))\n        self.assertEqual(\"Series44 - Vol.11 Ch.22 - Title55\", a.get_template_filename(\"{series} - Vol.{volume} Ch.{chapter} - {title}\"))\n\n        self.assertIsNone(a.get_template_filename(\"this {key_here} does not exist\"))"
  },
  {
    "path": "MangaManager/tests/MetadataManagerTests/GUI/__init__.py",
    "content": ""
  },
  {
    "path": "MangaManager/tests/MetadataManagerTests/GUI/test_MetadataEditorGUI.py",
    "content": "import glob\nimport importlib\nimport os\nimport random\nfrom tkinter.filedialog import askopenfiles\n\nfrom common.models import ComicInfo\nfrom logging_setup import add_trace_level\nfrom src.Common.LoadedComicInfo.LoadedComicInfo import LoadedComicInfo\nfrom src.MetadataManager import MetadataManagerGUI\nfrom src.MetadataManager.MetadataManagerLib import MetadataManagerLib\nfrom tests.common import create_dummy_files, TKinterTestCase, parameterized_class, create_test_cbz\n\nadd_trace_level()\nlayouts_path = os.path.abspath(\"src/Layouts\")\nprint(layouts_path)\n\nmodules = glob.glob(os.path.join(layouts_path, \"*.py\"))\nprint(f\"Found modules: [{', '.join(modules)}]\")\nextensions = [os.path.basename(f)[:-3] for f in modules if os.path.isfile(f) and not f.endswith('__init__.py')]\nprint(f\"Found extensions: [{', '.join(extensions)}]\")\nloaded_layouts = []\n# Note: Layout is the class\nfor ext in extensions:\n    loaded_layouts.append([importlib.import_module(f'.{ext}', package=\"src.Layouts\").Layout])\n\n\n@parameterized_class(('GUI',), loaded_layouts)\nclass UiToCinfoTest(TKinterTestCase):\n    test_files_names = None\n\n    def setUp(self) -> None:\n        leftover_files = [listed for listed in os.listdir() if listed.startswith(\"Test__\") and listed.endswith(\".cbz\")]\n        for file in leftover_files:\n            os.remove(file)\n        self.test_files_names = create_dummy_files(2)\n\n    def tearDown(self) -> None:\n        MetadataManagerGUI.askopenfiles = askopenfiles\n        print(\"Teardown:\")\n        for filename in self.test_files_names:\n            print(f\"     Deleting: {filename}\")  # , self._testMethodName)\n            try:\n                os.remove(filename)\n            except Exception as e:\n                print(e)\n\n    def test_all_ui_fields_loaded(self):\n        self.root = app = self.GUI()\n        app.is_test = True\n        app.title(\"test_all_ui_fields_loaded\")\n        for tag in MetadataManagerLib.cinfo_tags:\n            with self.subTest(f\"{tag}\"):\n                print(f\"Assert '{tag}' widget is displayed\")\n                self.assertTrue(tag in app.widget_mngr.get_tags())\n        app.destroy()\n\n    def test_all_fields_map_to_cinfo(self):\n        self.root = app = self.GUI()\n        app.is_test = True\n        app.title(\"test_all_fields_map_to_cinfo\")\n        # new_edited = comicinfo.ComicInfo()\n        # app.new_edited_cinfo = new_edited\n        app.loaded_cinfo_list = [LoadedComicInfo(filename).load_metadata() for filename in self.test_files_names]\n        self.pump_events()\n        app.focus_set()\n        random_number = random.random()\n        for cinfo_tag in app.cinfo_tags:\n            widget = app.widget_mngr.get_widget(cinfo_tag)\n            if widget.validation:\n                app.widget_mngr.get_widget(cinfo_tag).set(random_number, )\n            try:\n                app.widget_mngr.get_widget(cinfo_tag).set(cinfo_tag, )\n            except AttributeError:\n                app.widget_mngr.get_widget(cinfo_tag).set(cinfo_tag, )\n        # Set different entry types values\n\n        app.widget_mngr.get_widget(\"Summary\").widget.set(\"Summary\", )\n        app.widget_mngr.get_widget(\"AgeRating\").widget.set(\"AgeRating\", )\n        app.widget_mngr.get_widget(\"BlackAndWhite\").widget.set(\"BlackAndWhite\", )\n        app.widget_mngr.get_widget(\"Manga\").widget.set(\"Manga\", )\n        # app.serialize_gui_to_edited_cinfo()\n        # app.pre_process()\n\n        app._serialize_gui_to_cinfo()\n        for cinfo_tag in app.cinfo_tags:\n            widget = app.widget_mngr.get_widget(cinfo_tag)\n            # if not isinstance(widget, ComboBoxWidget):\n            #     continue\n            with self.subTest(f\"{cinfo_tag}\"):\n                print(f\"Comparing '{widget.get()}' vs ('{cinfo_tag}' or '{random_number}')\")\n                self.assertTrue(widget.get() == cinfo_tag or widget.get() == random_number or str(random_number))\n        # app.process()\n        app.destroy()\n\n    def test_full_flow(self):\n        def custom_askopenfiles(*_, **__):\n            return [open(filename, \"r\") for filename in self.test_files_names]\n\n        MetadataManagerGUI.askopenfiles = custom_askopenfiles\n        self.root = app = self.GUI()\n        app.is_test = True\n        app.title(\"test_full_flow\")\n        self.pump_events()\n        app.select_files()\n        app.loaded_cinfo_list = [LoadedComicInfo(filename).load_metadata() for filename in self.test_files_names]\n        self.pump_events()\n        app.focus_set()\n\n        random_number = random.random()\n        for cinfo_tag in app.cinfo_tags:\n            widget = app.widget_mngr.get_widget(cinfo_tag)\n            if widget.validation:\n                app.widget_mngr.get_widget(cinfo_tag).set(random_number, )\n            try:\n                app.widget_mngr.get_widget(cinfo_tag).set(cinfo_tag, )\n            except AttributeError:\n                app.widget_mngr.get_widget(cinfo_tag).set(cinfo_tag, )\n        # Set different entry types values\n\n        app.widget_mngr.get_widget(\"Summary\").widget.set(\"Summary\", )\n        app.widget_mngr.get_widget(\"AgeRating\").widget.set(\"AgeRating\", )\n        app.widget_mngr.get_widget(\"BlackAndWhite\").widget.set(\"BlackAndWhite\", )\n        app.widget_mngr.get_widget(\"Manga\").widget.set(\"Manga\", )\n        app.pre_process()\n        app.destroy()\n\n\n@parameterized_class(('GUI',), loaded_layouts)\nclass CinfoToUiTest(TKinterTestCase):\n    test_files_names = None\n\n    def setUp(self) -> None:\n        self.GUI.is_test = True\n        leftover_files = [listed for listed in os.listdir() if listed.startswith(\"Test__\") and listed.endswith(\".cbz\")]\n        for file in leftover_files:\n            try:\n                os.remove(file)\n            except PermissionError:\n                ...\n        self.test_files_names = create_dummy_files(2)\n\n    def tearDown(self) -> None:\n        MetadataManagerGUI.askopenfiles = askopenfiles\n        print(\"Teardown:\")\n        try:\n            self.root.destroy()\n        except:\n            ...\n        for filename in self.test_files_names:\n            print(f\"     Deleting: {filename}\")  # , self._testMethodName)\n            try:\n                os.remove(filename)\n            except Exception as e:\n                print(e)\n\n    def test_one_field_empty_should_not_be_overwritten_by_data_from_other_cinfo_with_field_filled(self):\n        # TEST DATA\n        cinfo1_series = \"This series from file 1 should be kept and not be applied to cinfo 2\"\n        cinfo2_series = \"\"\n\n        self.root = app = self.GUI()\n        app.title(\"test_one_field_empty_should_not_be_overwritten_by_data_from_other_cinfo_with_field_filled\")\n\n\n        # Create metadata objects\n        cinfo_1 = ComicInfo()\n        cinfo_1.series = cinfo1_series\n        cinfo_2 = ComicInfo()\n        cinfo_2.series = cinfo2_series\n\n        # Created loaded metadata objects\n        metadata_1 = LoadedComicInfo(self.test_files_names[0], comicinfo=cinfo_1)\n        metadata_2 = LoadedComicInfo(self.test_files_names[1], comicinfo=cinfo_2)\n        app.loaded_cinfo_list = [metadata_1, metadata_2]\n        # app.loaded_cinfo_list_to_process = app.loaded_cinfo_list\n        # There is no edited comicinfo, it should fail\n        new_cinfo = ComicInfo()\n        app.new_edited_cinfo = new_cinfo\n        app._serialize_cinfolist_to_gui()\n        app._serialize_gui_to_cinfo()\n        print(\"Assert original values will be kept\")\n        self.assertEqual(app.MULTIPLE_VALUES_CONFLICT, app.new_edited_cinfo.series)\n        # self.assertEqual(cinfo1_series, metadata_2.cinfo_object.series)\n        app.selected_files_path = self.test_files_names\n        app.pre_process()\n        # print(\"Assert final values match original\")\n        # self.assertEqual(app.MULTIPLE_VALUES_CONFLICT, app.new_edited_cinfo.series)\n        app.destroy()\n\n\n@parameterized_class(('GUI',), loaded_layouts)\nclass BulkLoadingTest(TKinterTestCase):\n\n    def setUp(self) -> None:\n        self.GUI.is_test = True\n        leftover_files = [listed for listed in os.listdir() if listed.startswith(\"Test__\") and listed.endswith(\".cbz\")]\n        for file in leftover_files:\n            os.remove(file)\n\n        self.test_files_names = create_test_cbz(4, 3)\n\n    def tearDown(self) -> None:\n        MetadataManagerGUI.askopenfiles = askopenfiles\n        print(\"Teardown:\")\n        try:\n            self.root.destroy()\n        except:\n            ...\n        for filename in self.test_files_names:\n            print(f\"     Deleting: {filename}\")  # , self._testMethodName)\n            try:\n                os.remove(filename)\n            except Exception as e:\n                print(e)\n\n    def test_bulk_selection(self):\n        \"\"\"\n        This tests the flow of loading multiple file and selecting a single file.\n        It's expected that the merged comicinfo has the right data\n        :return:\n        \"\"\"\n        def custom_askopenfiles(*_, **__):\n            return [open(filename, \"r\") for filename in self.test_files_names]\n\n        # MetadataManagerGUI.askopenfiles = custom_askopenfiles\n        self.root = app = self.GUI()\n        app.is_test = True\n        app.title(\"test_bulk_selection\")\n        self.pump_events()\n\n\n        for i, filepath in enumerate(self.test_files_names):\n            cinfo = ComicInfo()\n            cinfo.set_by_tag_name(\"Series\", f\"Series_sample - {i}\")\n            loaded_cinfo = LoadedComicInfo(filepath, comicinfo=cinfo).load_metadata()\n            app.loaded_cinfo_list.append(loaded_cinfo)\n            app.on_item_loaded(loaded_cinfo)\n\n        self.pump_events()\n        app.focus_set()\n        app.selected_files_treeview.selection_set(app.selected_files_treeview.get_children()[1])\n        self.pump_events()\n        app.focus_set()\n        self.assertFalse(any([True for lcinfo in app.loaded_cinfo_list if lcinfo.has_changes]))\n\n\n@parameterized_class(('GUI',), loaded_layouts)\nclass GenericUITest(TKinterTestCase):\n    def setUp(self):\n        self.GUI.is_test = True\n        super().setUp()\n\n    def test_settings_window_correctly_displayed(self):\n        self.root = app = self.GUI()\n\n        app.show_settings()\n"
  },
  {
    "path": "MangaManager/tests/MetadataManagerTests/GUI/test_dinamic_layouts.py",
    "content": "import glob\nimport importlib\nimport os\n\nfrom src.MetadataManager import MetadataManagerGUI\nfrom tests.common import create_dummy_files, TKinterTestCase, parameterized_class\n\nlayouts_path = os.path.abspath(\"src/Layouts\")\n\nmodules = glob.glob(os.path.join(layouts_path, \"*.py\"))\n\nextensions = [os.path.basename(f)[:-3] for f in modules if os.path.isfile(f) and not f.endswith('__init__.py')]\nloaded_layouts = []\nfor ext in extensions:\n    loaded_layouts.append([importlib.import_module(f'.{ext}',\n                                                                  package=\"src\"\n                                                                          \".Layouts\").Layout])\n\n\n@parameterized_class(('GUI',), loaded_layouts)\nclass DinamicLayoutTests(TKinterTestCase):\n    test_files_names = None\n\n    def setUp(self) -> None:\n        leftover_files = [listed for listed in os.listdir() if listed.startswith(\"Test__\") and listed.endswith(\".cbz\")]\n        for file in leftover_files:\n            os.remove(file)\n        self.test_files_names = create_dummy_files(2)\n\n        def custom_askopenfiles(*_, **__):\n            return [open(filename, \"r\") for filename in self.test_files_names]\n\n        MetadataManagerGUI.askopenfiles = custom_askopenfiles\n\n    def tearDown(self) -> None:\n\n        print(\"Teardown:\")\n        try:\n            self.root.destroy()\n        except:\n            ...\n        for filename in self.test_files_names:\n            print(f\"     Deleting: {filename}\")  # , self._testMethodName)\n            try:\n                os.remove(filename)\n            except Exception as e:\n                print(e)\n\n    def test_all_fields_are_populated(self):\n        self.root = self.GUI()\n        app: MetadataManagerGUI.GUIApp = self.root\n        app.is_test = True\n        app.title(f\"test_all_fields_are_populated_{app.name}\")\n        print(\"Assert all fields are registered\")\n        self.assertTrue(app.widget_mngr.get_tags())\n        self.pump_events()"
  },
  {
    "path": "MangaManager/tests/MetadataManagerTests/GUI/test_fetch_metadata.py",
    "content": "import glob\nimport importlib\nimport os\n\nfrom logging_setup import add_trace_level\nfrom src.MetadataManager.MetadataManagerGUI import GUIApp\nfrom tests.common import TKinterTestCase, parameterized_class\n\nadd_trace_level()\nlayouts_path = os.path.abspath(\"src/Layouts\")\nprint(layouts_path)\n\nmodules = glob.glob(os.path.join(layouts_path, \"*.py\"))\nprint(f\"Found modules: [{', '.join(modules)}]\")\nextensions = [os.path.basename(f)[:-3] for f in modules if os.path.isfile(f) and not f.endswith('__init__.py')]\nprint(f\"Found extensions: [{', '.join(extensions)}]\")\nloaded_layouts = []\n# Note: Layout is the class\nfor ext in extensions:\n    loaded_layouts.append([importlib.import_module(f'.{ext}', package=\"src.Layouts\").Layout])\n\n\n@parameterized_class(('GUI',), loaded_layouts)\nclass FetchMetadataFlowTest(TKinterTestCase):\n\n    def test_fetch_online_button_flow(self):\n\n        self.root = app = self.GUI()\n        app: GUIApp\n        app.is_test = True\n        app.title(\"test_fetch_online_button_flow\")\n\n        # Set series name in series widget\n        app.widget_mngr.get_widget(\"Series\").set(\"tensei shitara datta ken\")\n        app.process_fetch_online()\n\n        print(\"Assert series name matches\")\n        self.assertEqual(\"Tensei Shitara Slime Datta Ken\", app.widget_mngr.get_widget(\"Series\").get())\n        print(\"Assert loc series name matches\")\n        self.assertEqual(\"That Time I Got Reincarnated as a Slime\", app.widget_mngr.get_widget(\"LocalizedSeries\").get())\n\n        app.destroy()\n"
  },
  {
    "path": "MangaManager/tests/MetadataManagerTests/__init__.py",
    "content": ""
  },
  {
    "path": "MangaManager/tests/MetadataManagerTests/test_MetadataEditorCore.py",
    "content": "import os\nimport unittest\nimport zipfile\nfrom unittest.mock import patch, MagicMock\n\nimport src.Common.LoadedComicInfo.LoadedComicInfo\nfrom common.models import ComicInfo\nfrom logging_setup import add_trace_level\nfrom src.Common.LoadedComicInfo.LoadedComicInfo import LoadedComicInfo\nfrom src.Common.errors import CorruptedComicInfo, NoComicInfoLoaded\nfrom src.Common.errors import EditedCinfoNotSet, BadZipFile\nfrom src.MetadataManager import MetadataManagerLib\nfrom tests.common import create_dummy_files\n\nadd_trace_level()\n\n\nclass CoreTesting(unittest.TestCase):\n    test_files_names = None\n\n    @patch.multiple(MetadataManagerLib.MetadataManagerLib, __abstractmethods__=set())\n    def setUp(self) -> None:\n        self.instance = MetadataManagerLib.MetadataManagerLib()\n        leftover_files = [listed for listed in os.listdir() if listed.startswith(\"Test__\") and listed.endswith(\".cbz\")]\n        for file in leftover_files:\n            os.remove(file)\n\n    def tearDown(self) -> None:\n        # Some cases patch LoadedComicInfo. patchin back just in case\n        src.Common.LoadedComicInfo.LoadedComicInfo.LoadedComicInfo = LoadedComicInfo\n        print(\"Teardown:\")\n        for filename in self.test_files_names:\n            print(f\"     Deleting: {filename}\")  # , self._testMethodName)\n            try:\n                os.remove(filename)\n            except Exception as e:\n                print(e)\n\n    def test(self):\n        out_tmp_zipname = f\"random_image_1_not_image.ext.cbz\"\n        out_tmp_zipname2 = f\"random_image_1_not_image.ext.cbz\"\n        self.test_files_names = []\n        self.len_file_1 = 5\n        with zipfile.ZipFile(out_tmp_zipname, \"w\") as zf:\n            zf.writestr(\"Dummyfile.ext\", \"Dummy\")\n            zf.writestr(\"Dummyfile1.ext\", \"Dummy\")\n            zf.writestr(\"Dummyfile2.ext\", \"Dummy\")\n            zf.writestr(\"Dummyfile3.ext\", \"Dummy\")\n            zf.writestr(\"Dummyfile4.ext\", \"Dummy\")\n        self.test_files_names.append(out_tmp_zipname)\n        self.len_file_2 = 5\n        with zipfile.ZipFile(out_tmp_zipname2, \"w\") as zf:\n            zf.writestr(\"Dummyfile.ext\", \"Dummy\")\n            zf.writestr(\"Dummyfile1.ext\", \"Dummy\")\n            zf.writestr(\"Dummyfile2.ext\", \"Dummy\")\n            zf.writestr(\"Dummyfile3.ext\", \"Dummy\")\n            zf.writestr(\"Dummyfile4.ext\", \"Dummy\")\n        print(f\"     Creating: {out_tmp_zipname2}\")  # , self._testMethodName)\n        self.test_files_names.append(out_tmp_zipname2)\n        # Create a random int so the values in the cinfo are unique each test\n\n        # Create metadata objects\n        cinfo_1 = ComicInfo()\n        cinfo_1.series = \"This series from file 1 should be kept\"\n        cinfo_1.writer = \"This writer from file 1 should NOT be kept\"\n\n        cinfo_2 = ComicInfo()\n        cinfo_2.series = \"This series from file 2 should be kept\"\n        cinfo_2.writer = \"This writer from file 2 should NOT be kept\"\n\n        # Created loaded metadata objects\n        metadata_1 = LoadedComicInfo(out_tmp_zipname, comicinfo=cinfo_1)\n        metadata_2 = LoadedComicInfo(out_tmp_zipname2, comicinfo=cinfo_2)\n\n        self.instance.loaded_cinfo_list = [metadata_1, metadata_2]\n        # There is no edited comicinfo, it should fail\n        with self.assertRaises(EditedCinfoNotSet):\n            self.instance.merge_changed_metadata(self.instance.loaded_cinfo_list)\n        new_cinfo = ComicInfo()\n        new_cinfo.series = self.instance.MULTIPLE_VALUES_CONFLICT\n        new_cinfo.writer = \"This is the new writer for both cinfo\"\n        self.instance.new_edited_cinfo = new_cinfo\n        self.instance.merge_changed_metadata(self.instance.loaded_cinfo_list)\n        print(\"Assert values are kept\")\n        self.assertEqual(\"This series from file 1 should be kept\", metadata_1.cinfo_object.series)\n        self.assertEqual(\"This series from file 2 should be kept\", metadata_2.cinfo_object.series)\n        print(\"Assert values are overwritten\")\n        self.assertEqual(\"This is the new writer for both cinfo\", metadata_1.cinfo_object.writer)\n        self.assertEqual(\"This is the new writer for both cinfo\", metadata_2.cinfo_object.writer)\n\n    def test_selected_files_loaded(self):\n\n        # Setup\n        self.test_files_names = create_dummy_files(2)\n        self.instance.selected_files_path = self.test_files_names\n        self.instance.open_cinfo_list(lambda : False)\n        self.assertEqual(2, len(self.instance.loaded_cinfo_list))\n\n    def test_process_should_raise_exception_if_no_new_cinfo(self):\n        self.test_files_names = create_dummy_files(2)\n        self.instance.selected_files_path = self.test_files_names\n        self.assertRaises(NoComicInfoLoaded, self.instance.process)\n\n\n\nclass ErrorHandlingTests(unittest.TestCase):\n    \"\"\"\n    This should test that all functions in the methods in MetadataManagerLib._IMetadataManagerLib interface are called\n    \"\"\"\n    test_files_names = None\n\n    def setUp(self) -> None:\n        leftover_files = [listed for listed in os.listdir() if\n                          listed.startswith(\"Test__\") and listed.endswith(\".cbz\")]\n        for file in leftover_files:\n            os.remove(file)\n\n    def tearDown(self) -> None:\n        # Some cases patch LoadedComicInfo. patchin back just in case\n        src.Common.LoadedComicInfo.LoadedComicInfo.LoadedComicInfo = LoadedComicInfo\n        print(\"Teardown:\")\n        for filename in self.test_files_names:\n            print(f\"     Deleting: {filename}\")  # , self._testMethodName)\n            try:\n                os.remove(filename)\n            except Exception as e:\n                print(e)\n    @unittest.skip(\"Broken test\")\n    @patch.multiple(MetadataManagerLib.MetadataManagerLib, __abstractmethods__=set())\n    def test_load_files_should_handle_broken_zipfile(self):\n        self.instance = MetadataManagerLib.MetadataManagerLib()\n\n        class RaiseBadZip:\n            ...\n\n        def raise_badzip(*_, **__):\n            raise BadZipFile()\n\n        RaiseBadZip.__init__ = raise_badzip\n        src.Common.LoadedComicInfo.LoadedComicInfo.LoadedComicInfo = RaiseBadZip\n\n        self.instance.selected_files_path = self.test_files_names = create_dummy_files(2)\n\n        self.instance.on_badzipfile_error = MagicMock()\n        self.instance.open_cinfo_list()\n        self.instance.on_badzipfile_error.assert_called()\n\n    @unittest.skip(\"Broken test\")\n    @patch.multiple(MetadataManagerLib.MetadataManagerLib, __abstractmethods__=set())\n    def test_on_badzipfile_error(self):\n        self.instance = MetadataManagerLib.MetadataManagerLib()\n\n        class RaiseCorruptedMeta:\n            ...\n\n        def raise_badzip(*_, **__):\n            # Exception raised but then we create a new object with a brand new comicinfo.\n            # Fix back patched class and raise exception\n            src.Common.LoadedComicInfo.LoadedComicInfo.LoadedComicInfo = LoadedComicInfo\n            raise CorruptedComicInfo(\"\")\n\n        RaiseCorruptedMeta.__init__ = raise_badzip\n        src.Common.LoadedComicInfo.LoadedComicInfo.LoadedComicInfo = RaiseCorruptedMeta\n\n        self.instance.selected_files_path = self.test_files_names = create_dummy_files(2)\n\n        self.instance.on_corruped_metadata_error = MagicMock()\n        self.instance.open_cinfo_list()\n        self.instance.on_corruped_metadata_error.assert_called()\n\n    @patch.multiple(MetadataManagerLib.MetadataManagerLib, __abstractmethods__=set())\n    def test_on_writing_error(self):\n        self.instance = MetadataManagerLib.MetadataManagerLib()\n        called = False\n        class RaisePermissionError(LoadedComicInfo):\n            def __init__(self, *args, **kwargs):\n                super().__init__(*args, **kwargs)\n                self.has_changes = True\n\n            def write_metadata(self, auto_unmark_changes=False):\n                if not called:\n                    raise PermissionError(\"This exception is raised as part of one unit test. Safe to ignore\")\n                else:\n                    super().write_metadata(auto_unmark_changes)\n                \n\n        src.Common.LoadedComicInfo.LoadedComicInfo.LoadedComicInfo = RaisePermissionError\n\n        self.instance.selected_files_path = self.test_files_names = create_dummy_files(2)\n        self.instance.loaded_cinfo_list = [RaisePermissionError(path) for path in self.test_files_names]\n        self.instance.new_edited_cinfo = ComicInfo()\n        self.instance.on_writing_error = MagicMock()\n        self.instance.process()\n        self.instance.on_writing_error.assert_called()\n\n    @patch.multiple(MetadataManagerLib.MetadataManagerLib, __abstractmethods__=set())\n    def test_on_writing_exception(self):\n        self.instance = MetadataManagerLib.MetadataManagerLib()\n\n        class RaisePermissionError(LoadedComicInfo):\n            def __init__(self, *args, **kwargs):\n                super().__init__(*args, **kwargs)\n                self.has_changes = True\n\n            def write_metadata(self, auto_unmark_changes=False):\n                raise Exception(\"Exception. This exception is raised as part of one unit test. Safe to ignore\")\n\n        src.Common.LoadedComicInfo.LoadedComicInfo.LoadedComicInfo = RaisePermissionError\n\n        self.instance.selected_files_path = self.test_files_names = create_dummy_files(2)\n        self.instance.loaded_cinfo_list = [RaisePermissionError(path) for path in self.test_files_names]\n        self.instance.new_edited_cinfo = ComicInfo()\n        self.instance.on_writing_exception = MagicMock()\n        self.instance.process()\n        self.instance.on_writing_exception.assert_called()\n"
  },
  {
    "path": "MangaManager/tests/Settings/__init__.py",
    "content": ""
  },
  {
    "path": "MangaManager/tests/Settings/test_Settings.py",
    "content": "import os.path\nimport unittest\n\nfrom src.Settings import SettingHeading, Settings\n\n\nclass SettingsTest(unittest.TestCase):\n\n    def tearDown(self):\n        if os.path.exists('settings.ini'):\n            print('Cleaning up created settings.ini')\n            os.remove('settings.ini')\n\n    def test_Settings_will_create_if_nothing_on_disk(self):\n        s = Settings()\n        self.assertTrue(os.path.exists(s.config_file))\n\n    def test_Settings_will_set_values(self):\n        s = Settings()\n        s._load_test()\n        self.assertEqual(s.get(SettingHeading.Main, 'library_path'), '')\n\n        s.set(SettingHeading.Main, 'library_path', 'test_dir')\n        self.assertEqual(s.get(SettingHeading.Main, 'library_path'), 'test_dir')\n\n    def test_Settings_will_write_default_tag_if_not_exists(self):\n        s = Settings()\n        self.assertNotEqual(s.get(SettingHeading.ExternalSources, 'default_metadata_source'), '')\n\n\n\n\nif __name__ == '__main__':\n    unittest.main()\n"
  },
  {
    "path": "MangaManager/tests/__init__.py",
    "content": "from logging_setup import add_trace_level\n\nadd_trace_level()"
  },
  {
    "path": "MangaManager/tests/common.py",
    "content": "import configparser\nimport io\nimport os\nimport random\nimport sys\nimport unittest\nimport warnings\nimport zipfile\n\nimport _tkinter\nfrom PIL import Image\nfrom lxml import etree\n\nfrom src.Common.LoadedComicInfo.LoadedComicInfo import COMICINFO_FILE, LoadedComicInfo\n\n\ndef create_dummy_files(nfiles):\n    test_files_names = []\n    for i in range(nfiles):\n        out_tmp_zipname = f\"random_image_{i}_not_image.ext.cbz\"\n        test_files_names.append(out_tmp_zipname)\n        with zipfile.ZipFile(out_tmp_zipname, \"w\") as zf:\n            zf.writestr(\"Dummyfile.ext\", \"Dummy\")\n    return test_files_names\n\n\ndef create_test_cbz(nfiles, nimages=4, loaded_cinfo: LoadedComicInfo = None) -> list[str]:\n    image = Image.new('RGB', (100, 100), 'white')\n    buffer = io.BytesIO()\n    image.save(buffer, 'JPEG')\n\n    test_files_names = []\n    for i in range(nfiles):\n        out_tmp_zipname = f\"Test__{i}_Generated{random.randint(1, 6000)}.cbz\"\n        test_files_names.append(out_tmp_zipname)\n        with zipfile.ZipFile(out_tmp_zipname, \"w\") as zf:\n            if loaded_cinfo is not None:\n                # noinspection PyProtectedMember\n                zf.writestr(COMICINFO_FILE, loaded_cinfo._export_metadata())\n            for j in range(nimages):\n                zf.writestr(f\"{str(j).zfill(3)}.png\", buffer.getvalue())\n\n    return test_files_names\n\n\nclass CBZManipulationTests(unittest.TestCase):\n    test_files_names = []\n    root = None\n\n    def setUp(self) -> None:\n        print(\"Super setup\")\n        leftover_files = [listed for listed in os.listdir() if listed.startswith(\"Test__\") and listed.endswith(\".cbz\")]\n        for file in leftover_files:\n            os.remove(file)\n\n    def tearDown(self) -> None:\n        print(\"Super Teardown:\")\n        try:\n            self.root.destroy()\n        except:\n            pass\n        for filename in self.test_files_names:\n            print(f\"     Deleting: {filename}\")  # , self._testMethodName)\n            try:\n                os.remove(filename)\n            except Exception as e:\n                print(e)\n\n\nclass TKinterTestCase(unittest.TestCase):\n    \"\"\"These methods are going to be the same for every GUI test,\n    so refactored them into a separate class\n    \"\"\"\n    root = None\n\n    def setUp(self):\n        ...\n\n    def tearDown(self):\n        if self.root:\n            try:\n                self.root.destroy()\n                self.pump_events()\n            except:\n                pass\n\n    def pump_events(self):\n        while self.root.dooneevent(_tkinter.ALL_EVENTS | _tkinter.DONT_WAIT):\n            pass\n\n\ndef parameterized_class(attrs, input_values=None, classname_func=None,  **__):\n    \"\"\" Parameterizes a test class by setting attributes on the class.\n\n        Can be used in two ways:\n\n        1) With a list of dictionaries containing attributes to override::\n\n            @parameterized_class([\n                { \"username\": \"foo\" },\n                { \"username\": \"bar\", \"access_level\": 2 },\n            ])\n            class TestUserAccessLevel(TestCase):\n                ...\n\n        2) With a tuple of attributes, then a list of tuples of values:\n\n            @parameterized_class((\"username\", \"access_level\"), [\n                (\"foo\", 1),\n                (\"bar\", 2)\n            ])\n            class TestUserAccessLevel(TestCase):\n                ...\n\n    \"\"\"\n\n    if isinstance(attrs, str):\n        attrs = [attrs]\n\n    input_dicts = (\n        attrs if input_values is None else\n        [dict(zip(attrs, vals)) for vals in input_values]\n    )\n\n    if classname_func:\n        warnings.warn(\n            \"classname_func= is deprecated; use class_name_func= instead. \"\n            \"See: https://github.com/wolever/parameterized/pull/74#issuecomment-613577057\",\n            DeprecationWarning,\n            stacklevel=2,\n        )\n    def decorator(base_class):\n        test_class_module = sys.modules[base_class.__module__].__dict__\n        for idx, input_dict in enumerate(input_dicts):\n            test_class_dict = dict(base_class.__dict__)\n            test_class_dict.update(input_dict)\n\n            name = base_class.__name__ + \" Layout=\" + input_dict.get(\"GUI\").name\n\n            test_class_module[name] = type(name, (base_class,), test_class_dict)\n\n        # We need to leave the base class in place (see issue #73), but if we\n        # leave the test_ methods in place, the test runner will try to pick\n        # them up and run them... which doesn't make sense, since no parameters\n        # will have been applied.\n        # Address this by iterating over the base class and remove all test\n        # methods.\n        for method_name in list(base_class.__dict__):\n            if method_name.startswith(\"test\"):\n                delattr(base_class, method_name)\n        return base_class\n\n    return decorator\n\n\n# configparser patch stuff\ndef custom_get_item(key):\n    if key == 'DynamicProgramingParamaters':\n        return {'wealth_state_total': 'Just a test 3!'}\n    else:\n        raise KeyError(str(key))\n\n\nclass CustomConfigParser1(configparser.ConfigParser):\n    def __getitem__(self, key):\n        if key == 'DynamicProgramingParamaters':\n            return {'wealth_state_total': 'Just a test 4!'}\n        else:\n            raise KeyError(str(key))\n\n\nclass CustomConfigParser2(configparser.ConfigParser):\n    def read(self, filenames, *args, **kwargs):\n        # Intercept the calls to configparser -> read and replace it to read from your test data\n        if './path' == filenames:\n            # Option 1: If you want to manually write the configuration here\n            self.read_string(\"[DynamicProgramingParamaters]\\nwealth_state_total = Just a test 5!\")\n\n            # Option 2: If you have a test configuration file\n            # super().read(\"./test_path\")\n        else:\n            super().read(filenames, *args, **kwargs)\n\n\ndef is_valid_xml(xml:str) -> bool:\n    # Load the XML file and XSD schema\n    try:\n        xml_file = etree.fromstring(xml.encode(\"utf-8\"),parser=etree.XMLParser(encoding='utf-8'))\n    except ValueError:\n     print(\"dasd\")\n    xsd_schema = etree.parse('common/models/ComicInfo.xds')\n\n    # Create a validator object\n    xml_validator = etree.XMLSchema(xsd_schema)\n\n    # Validate the XML file against the XSD schema\n    is_valid = xml_validator.validate(xml_file)\n    if not is_valid:\n        print(xml)\n        for error in xml_validator.error_log:\n            print(f'{error.message} (line {error.line}, column {error.column})')\n    return is_valid\n"
  },
  {
    "path": "MangaManager/tests/data/test.py",
    "content": "from src.Common.LoadedComicInfo.ArchiveFile import ArchiveFile\n\nif __name__ == '__main__':\n    with ArchiveFile(\"!00_SAMPLE_FILE.rar\", \"r\") as rfile:\n        print(rfile.namelist())\n        print(rfile.read(\"ComicInfo.xml\"))\n    with ArchiveFile(\"!00_SAMPLE_FILE.CBZ\", \"r\") as rfile:\n        print(rfile.namelist())\n        print(rfile.read(\"ComicInfo.xml\"))\n"
  },
  {
    "path": "MangaManager/tests/test_comicinfo.py",
    "content": "import unittest\n\nfrom common.models import AgeRating, Manga, YesNo, Formats\n\n\nclass LoadedCInfo_Utils(unittest.TestCase):\n    def test_ComicInfo_ToList_methods_work(self):\n        classes_to_test_list_implementation = (AgeRating, Manga, YesNo)\n\n        for class_ in classes_to_test_list_implementation:\n            with self.subTest(f\"Testing {class_} has list method\"):\n                self.assertTrue(len(class_.list()) > 1)\n        with self.subTest(\"Testing format_list is populated\"):\n            self.assertTrue(len(Formats) > 1)\n"
  },
  {
    "path": "README.md",
    "content": "[![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)\n[![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)\n[![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)\n\n# Welcome to the Manga Manager rework\n\nThe 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.\n\nWith a lot of effort and support from the community, the rework was born.\n\nPlease 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.\nAll releases are tested but if you happen to find an error or a bug please report them with the label \"Rework Issue\".\n\n## What is Manga Manager\n\nManga Manager is an all-in-one tool to make managing your manga library easy.\nHas a built-in metadata editor as well as cover and back cover editor.\n\n# Key Features:\n\n* Select multiple files and preview the covers of each file\n* Bulk edit metadata for all files at once\n* Changes are kept in one session, allowing flexible editing\n* Apply all changes (save to file) at once when done editing\n* Edit cover or back cover from the metadata view itself\n* Cover manager for batch editing of covers\n* Online metadata scraping\n* Webp converter\n* Error and warning log within the UI itself\n* Terminal interface (supports ssh)\n\n### Does it work for comics?\n\nYes! MangaManager works on any .cbz file!\n\n# Donate\n\nIf 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.\nYou can donate through Ko-fi. Thank you for your generosity and for being a part of the MangaManager community.\n\n[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/U7U4IC14H)\n\n## Docker\n\nMangaManager provides a convenient solution for remote access to the software through a web browser.\n\nA 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).\n\nThe stable releases are built from the master branch, while nightly builds are generated from the develop branch.\n\n\n![Screenshot-1](/project-docs/Screenshot_1.png)\n\n### Art attribution\n\nWallpaper 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)\n\n## Bare metal requirements\n\nMin required version: Python 3.10.8\n\nRequirements in requirements.txt\n\n#### Additional for Linux\n- tkinter\n- idlelib\n\n## FAQ\n### No rar tools detected on windows.\nDownload [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.\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "# This Docker is based on Linuxserver.io's Webtop Base Image.\n# For information about more available options to use, please\n# refer to this page: https://docs.linuxserver.io/images/docker-webtop\n#\n# SECURITY ADVISORY: Do not expose this passwordless sudo container\n# to the public. If you want to use this remotely use a VPN.\n# Should you need to login at any point, the default user's credentials are:\n# USER: abc   PASS: abc\n\nversion: \"3.4\"\n\nservices:\n  manga-manager:\n    image: thepromidius/manga-manager:latest\n    ports:\n      # Web UI\n      - \"3000:3000\"\n      # OPTIONAL: RDP Port for browser-less connection\n      # - \"3389:3389\"\n    volumes:\n      - /your/manga/directory:/manga\n      - /your/covers_images/directory:/covers\n    environment:\n      - PUID=1000\n      - PGID=1000\n      # Background Stuff:\n      - TITLE=\"Manga Manager\"\n      # OPTIONAL:\n      - UMASK=022\n      - TZ=Europe/Berlin\n      - KEYBOARD=en-us-qwerty\n      # Specify a subfolder to use with reverse proxies, i.e.: /subfolder/\n      - SUBFOLDER=/\n    # You need this setting if your Docker version is below 20.10.10\n    # See: https://docs.linuxserver.io/faq#jammy\n    # security_opt:\n    #   - seccomp=unconfined\n"
  },
  {
    "path": "docker-root/config/.config/xfce4/panel/launcher-7/MM Launcher.desktop",
    "content": "[Desktop Entry]\nVersion=1.0\nType=Application\n# Exec=python /app/main.py\nExec=/config/Desktop/MangaManager_23_02_02_Beta_linux_01\nIcon=\nStartupNotify=true\nTerminal=False\nCategories=Utility;X-XFCE;X-Xfce-Toplevel;\nOnlyShowIn=XFCE;\nName=Manga Manager\nComment=Manga Manager Launcher\nKeywords=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;\nX-XFCE-Source=file:///usr/share/applications/xfce4-run.desktop\nPath=/app\n"
  },
  {
    "path": "docker-root/config/.config/xfce4/xfconf/xfce-perchannel-xml/xfce4-desktop.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<channel name=\"xfce4-desktop\" version=\"1.0\">\n  <property name=\"backdrop\" type=\"empty\">\n    <property name=\"screen0\" type=\"empty\">\n      <property name=\"monitorrdp0\" type=\"empty\">\n        <property name=\"workspace0\" type=\"empty\">\n          <property name=\"color-style\" type=\"int\" value=\"0\"/>\n          <property name=\"image-style\" type=\"int\" value=\"5\"/>\n          <property name=\"last-image\" type=\"string\" value=\"/config/ian-valerio-CAFq0pv9HjY-unsplash.jpg\"/>\n        </property>\n        <property name=\"workspace1\" type=\"empty\">\n          <property name=\"color-style\" type=\"int\" value=\"0\"/>\n          <property name=\"image-style\" type=\"int\" value=\"5\"/>\n          <property name=\"last-image\" type=\"string\" value=\"/usr/share/backgrounds/xfce/xfce-verticals.png\"/>\n        </property>\n        <property name=\"workspace2\" type=\"empty\">\n          <property name=\"color-style\" type=\"int\" value=\"0\"/>\n          <property name=\"image-style\" type=\"int\" value=\"5\"/>\n          <property name=\"last-image\" type=\"string\" value=\"/usr/share/backgrounds/xfce/xfce-verticals.png\"/>\n        </property>\n        <property name=\"workspace3\" type=\"empty\">\n          <property name=\"color-style\" type=\"int\" value=\"0\"/>\n          <property name=\"image-style\" type=\"int\" value=\"5\"/>\n          <property name=\"last-image\" type=\"string\" value=\"/usr/share/backgrounds/xfce/xfce-verticals.png\"/>\n        </property>\n      </property>\n    </property>\n  </property>\n  <property name=\"desktop-icons\" type=\"empty\">\n    <property name=\"show-thumbnails\" type=\"bool\" value=\"true\"/>\n    <property name=\"file-icons\" type=\"empty\">\n      <property name=\"show-home\" type=\"bool\" value=\"false\"/>\n      <property name=\"show-removable\" type=\"bool\" value=\"false\"/>\n      <property name=\"show-filesystem\" type=\"bool\" value=\"false\"/>\n    </property>\n  </property>\n  <property name=\"last\" type=\"empty\">\n    <property name=\"window-width\" type=\"int\" value=\"621\"/>\n    <property name=\"window-height\" type=\"int\" value=\"533\"/>\n  </property>\n</channel>\n"
  },
  {
    "path": "docker-root/config/.config/xfce4/xfconf/xfce-perchannel-xml/xfce4-panel.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<channel name=\"xfce4-panel\" version=\"1.0\">\n  <property name=\"configver\" type=\"int\" value=\"2\"/>\n  <property name=\"panels\" type=\"array\">\n    <value type=\"int\" value=\"1\"/>\n    <value type=\"int\" value=\"2\"/>\n    <property name=\"dark-mode\" type=\"bool\" value=\"true\"/>\n    <property name=\"panel-1\" type=\"empty\">\n      <property name=\"icon-size\" type=\"uint\" value=\"16\"/>\n      <property name=\"length\" type=\"uint\" value=\"100\"/>\n      <property name=\"plugin-ids\" type=\"array\">\n        <value type=\"int\" value=\"1\"/>\n        <value type=\"int\" value=\"2\"/>\n        <value type=\"int\" value=\"3\"/>\n        <value type=\"int\" value=\"4\"/>\n        <value type=\"int\" value=\"5\"/>\n        <value type=\"int\" value=\"6\"/>\n        <value type=\"int\" value=\"8\"/>\n        <value type=\"int\" value=\"11\"/>\n        <value type=\"int\" value=\"12\"/>\n        <value type=\"int\" value=\"13\"/>\n        <value type=\"int\" value=\"14\"/>\n      </property>\n      <property name=\"position\" type=\"string\" value=\"p=6;x=0;y=0\"/>\n      <property name=\"position-locked\" type=\"bool\" value=\"true\"/>\n      <property name=\"size\" type=\"uint\" value=\"26\"/>\n    </property>\n    <property name=\"panel-2\" type=\"empty\">\n      <property name=\"autohide-behavior\" type=\"uint\" value=\"1\"/>\n      <property name=\"background-rgba\" type=\"array\">\n        <value type=\"double\" value=\"0.149020\"/>\n        <value type=\"double\" value=\"0.635294\"/>\n        <value type=\"double\" value=\"0.411765\"/>\n        <value type=\"double\" value=\"1.000000\"/>\n      </property>\n      <property name=\"background-style\" type=\"uint\" value=\"0\"/>\n      <property name=\"length\" type=\"uint\" value=\"1\"/>\n      <property name=\"length-adjust\" type=\"bool\" value=\"true\"/>\n      <property name=\"mode\" type=\"uint\" value=\"0\"/>\n      <property name=\"nrows\" type=\"uint\" value=\"1\"/>\n      <property name=\"plugin-ids\" type=\"array\">\n        <value type=\"int\" value=\"15\"/>\n        <value type=\"int\" value=\"16\"/>\n        <value type=\"int\" value=\"7\"/>\n        <value type=\"int\" value=\"10\"/>\n        <value type=\"int\" value=\"17\"/>\n        <value type=\"int\" value=\"20\"/>\n        <value type=\"int\" value=\"23\"/>\n        <value type=\"int\" value=\"24\"/>\n        <value type=\"int\" value=\"9\"/>\n        <value type=\"int\" value=\"18\"/>\n      </property>\n      <property name=\"position\" type=\"string\" value=\"p=10;x=0;y=0\"/>\n      <property name=\"position-locked\" type=\"bool\" value=\"true\"/>\n      <property name=\"size\" type=\"uint\" value=\"48\"/>\n    </property>\n  </property>\n  <property name=\"plugins\" type=\"empty\">\n    <property name=\"plugin-1\" type=\"string\" value=\"applicationsmenu\"/>\n    <property name=\"plugin-11\" type=\"string\" value=\"separator\">\n      <property name=\"style\" type=\"uint\" value=\"0\"/>\n    </property>\n    <property name=\"plugin-12\" type=\"string\" value=\"clock\"/>\n    <property name=\"plugin-13\" type=\"string\" value=\"separator\">\n      <property name=\"style\" type=\"uint\" value=\"0\"/>\n    </property>\n    <property name=\"plugin-14\" type=\"string\" value=\"actions\"/>\n    <property name=\"plugin-15\" type=\"string\" value=\"showdesktop\"/>\n    <property name=\"plugin-16\" type=\"string\" value=\"separator\">\n      <property name=\"expand\" type=\"bool\" value=\"false\"/>\n    </property>\n    <property name=\"plugin-2\" type=\"string\" value=\"tasklist\">\n      <property name=\"grouping\" type=\"uint\" value=\"0\"/>\n    </property>\n    <property name=\"plugin-3\" type=\"string\" value=\"separator\">\n      <property name=\"expand\" type=\"bool\" value=\"true\"/>\n      <property name=\"style\" type=\"uint\" value=\"0\"/>\n    </property>\n    <property name=\"plugin-4\" type=\"string\" value=\"pager\"/>\n    <property name=\"plugin-5\" type=\"string\" value=\"separator\">\n      <property name=\"style\" type=\"uint\" value=\"0\"/>\n    </property>\n    <property name=\"plugin-6\" type=\"string\" value=\"systray\">\n      <property name=\"square-icons\" type=\"bool\" value=\"true\"/>\n    </property>\n    <property name=\"plugin-7\" type=\"string\" value=\"launcher\">\n      <property name=\"items\" type=\"array\">\n        <value type=\"string\" value=\"MM Launcher.desktop\"/>\n      </property>\n      <property name=\"show-label\" type=\"bool\" value=\"true\"/>\n    </property>\n    <property name=\"plugin-8\" type=\"string\" value=\"pulseaudio\">\n      <property name=\"enable-keyboard-shortcuts\" type=\"bool\" value=\"true\"/>\n      <property name=\"show-notifications\" type=\"bool\" value=\"true\"/>\n    </property>\n    <property name=\"plugin-9\" type=\"string\" value=\"separator\"/>\n  </property>\n</channel>\n"
  },
  {
    "path": "docker-root/config/.config/xfce4/xfconf/xfce-perchannel-xml/xsettings.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<channel name=\"xsettings\" version=\"1.0\">\n  <property name=\"Net\" type=\"empty\">\n    <property name=\"ThemeName\" type=\"string\" value=\"Greybird-dark\"/>\n    <property name=\"IconThemeName\" type=\"string\" value=\"elementary-xfce-dark\"/>\n    <property name=\"DoubleClickTime\" type=\"empty\"/>\n    <property name=\"DoubleClickDistance\" type=\"empty\"/>\n    <property name=\"DndDragThreshold\" type=\"empty\"/>\n    <property name=\"CursorBlink\" type=\"empty\"/>\n    <property name=\"CursorBlinkTime\" type=\"empty\"/>\n    <property name=\"SoundThemeName\" type=\"empty\"/>\n    <property name=\"EnableEventSounds\" type=\"bool\" value=\"false\"/>\n    <property name=\"EnableInputFeedbackSounds\" type=\"bool\" value=\"false\"/>\n  </property>\n  <property name=\"Xft\" type=\"empty\">\n    <property name=\"DPI\" type=\"empty\"/>\n    <property name=\"Antialias\" type=\"empty\"/>\n    <property name=\"Hinting\" type=\"empty\"/>\n    <property name=\"HintStyle\" type=\"empty\"/>\n    <property name=\"RGBA\" type=\"empty\"/>\n  </property>\n  <property name=\"Gtk\" type=\"empty\">\n    <property name=\"CanChangeAccels\" type=\"empty\"/>\n    <property name=\"ColorPalette\" type=\"empty\"/>\n    <property name=\"FontName\" type=\"empty\"/>\n    <property name=\"MonospaceFontName\" type=\"empty\"/>\n    <property name=\"IconSizes\" type=\"empty\"/>\n    <property name=\"KeyThemeName\" type=\"empty\"/>\n    <property name=\"ToolbarStyle\" type=\"empty\"/>\n    <property name=\"ToolbarIconSize\" type=\"empty\"/>\n    <property name=\"MenuImages\" type=\"bool\" value=\"true\"/>\n    <property name=\"ButtonImages\" type=\"bool\" value=\"true\"/>\n    <property name=\"MenuBarAccel\" type=\"empty\"/>\n    <property name=\"CursorThemeName\" type=\"empty\"/>\n    <property name=\"CursorThemeSize\" type=\"empty\"/>\n    <property name=\"DecorationLayout\" type=\"empty\"/>\n    <property name=\"DialogsUseHeader\" type=\"empty\"/>\n    <property name=\"TitlebarMiddleClick\" type=\"empty\"/>\n  </property>\n  <property name=\"Gdk\" type=\"empty\">\n    <property name=\"WindowScalingFactor\" type=\"empty\"/>\n  </property>\n</channel>\n"
  },
  {
    "path": "docker-root/config/Desktop/covers-folder-link.desktop",
    "content": "[Desktop Entry]\nVersion=1.0\nType=Application\nName=Covers Folder\nComment=Open /covers\nExec=/usr/bin/Thunar\nIcon=folder-pictures\nPath=/covers\nTerminal=false\nStartupNotify=false\n"
  },
  {
    "path": "docker-root/config/Desktop/manga-folder-link.desktop",
    "content": "[Desktop Entry]\nVersion=1.0\nType=Application\nName=Manga Folder\nComment=Open /manga\nExec=/usr/bin/Thunar\nIcon=applications-libraries\nPath=/manga\nTerminal=false\nStartupNotify=false\n"
  },
  {
    "path": "docker-root/config/Desktop/manga-manager-link.desktop",
    "content": "[Desktop Entry]\nVersion=1.0\nType=Application\nName=Manga Manager\nComment=\n# Exec=python /app/main.py\nExec=/config/Desktop/MangaManager_23_02_02_Beta_linux_01\nIcon=\nPath=/app\nTerminal=false\nStartupNotify=false"
  },
  {
    "path": "docker-root/config/custom-cont-init.d/prepare-app-permissions.sh",
    "content": "#!/bin/bash\n\necho \"Preparing permissions for /app folder\"\nchown -R abc:abc /app"
  },
  {
    "path": "docker-root/defaults/autostart",
    "content": "startxfce4\nMangaManager_23_02_02_Beta_linux_01"
  },
  {
    "path": "docker-root/defaults/startwm.sh",
    "content": "#!/bin/bash\n/startpulse.sh &\n/usr/bin/startxfce4 > /dev/null 2>&1\npython /app/main.py"
  },
  {
    "path": "requirements.txt",
    "content": "prompt_toolkit>=3.0.31\npillow>=9.4.0\nPillow\nnatsort>=8.2.0\nlxml >= 4.9.1\nsix >= 1.16.0\nrequests >= 2.31.0\nanytree~=2.8.0\nnumpy~=1.24.2\nrarfile\ntkinterdnd2"
  },
  {
    "path": "sonar-project.properties",
    "content": "sonar.projectKey=ThePromidius_Manga-Manager\nsonar.organization=thepromidius\n# This is the name and version displayed in the SonarCloud UI.\n#sonar.projectName=Manga-Manager\n#sonar.projectVersion=1.0\nsonar.python.version=3.10\n# Path is relative to the sonar-project.properties file. Replace \"\\\" by \"/\" on Windows.\n# Define separate root directories for sources and tests\nsonar.sources=MangaManager/src/\nsonar.tests=MangaManager/tests/\n# Exclude test subdirectories from source scope\n# 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\nsonar.exclusions=MangaManager/src/**/comicinfo.py\nsonar.cpd.exclusions = MangaManager/tests/**/*test*\nsonar.test.exclusions=MangaManager/test*\nsonar.python.file.suffixes=py\n\n# Encoding of the source code. Default is default system encoding\n#sonar.sourceEncoding=UTF-8"
  }
]