diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml index 93f54e61..6788cce4 100644 --- a/.github/workflows/pytest.yaml +++ b/.github/workflows/pytest.yaml @@ -5,7 +5,7 @@ on: [ push, pull_request ] jobs: pytest: name: Run tests - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Checkout repo @@ -19,13 +19,14 @@ jobs: - name: Install system dependencies run: | - # dont run update, it is slow - # sudo apt-get update + # dont run update, it is slow + sudo apt-get update sudo apt-get install -y --no-install-recommends \ libxkbcommon-x11-0 \ x11-utils \ libyaml-dev \ - libegl1-mesa \ + libgl1 \ + libegl1 \ libxcb-icccm4 \ libxcb-image0 \ libxcb-keysyms1 \ diff --git a/.gitignore b/.gitignore index 7b493926..595a3290 100644 --- a/.gitignore +++ b/.gitignore @@ -55,7 +55,6 @@ coverage.xml .hypothesis/ .pytest_cache/ cover/ -tagstudio/tests/fixtures/library/* # Translations *.mo @@ -141,6 +140,9 @@ venv.bak/ # Rope project settings .ropeproject +# macoS +*.DS_Store + # mkdocs documentation /site @@ -163,7 +165,7 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +.idea/ ### Python Patch ### # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration @@ -175,6 +177,10 @@ poetry.toml # LSP config files pyrightconfig.json +# Syncthing +.stfolder/ +.stignore + ### Qt ### # C++ objects and libs *.slo @@ -232,11 +238,11 @@ compile_commands.json ### VisualStudioCode ### .vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -!.vscode/*.code-snippets +# !.vscode/settings.json +# !.vscode/tasks.json +# !.vscode/launch.json +# !.vscode/extensions.json +# !.vscode/*.code-snippets # Local History for Visual Studio Code .history/ @@ -248,11 +254,14 @@ compile_commands.json # Ignore all local history of files .history .ionide +# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,python,qt # TagStudio .TagStudio +!*/tests/**/.TagStudio +tagstudio/tests/fixtures/library/* +tagstudio/tests/fixtures/json_library/.TagStudio/*.sqlite TagStudio.ini -# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,python,qt .envrc .direnv diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index 8838fbb3..00000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "name": "TagStudio", - "type": "python", - "request": "launch", - "program": "${workspaceRoot}/tagstudio/tag_studio.py", - "console": "integratedTerminal", - "justMyCode": true, - "args": [] - } - ] -} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 67a0019f..5c1ab5e4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,39 +1,43 @@ # Contributing to TagStudio -_Last Updated: September 8th, 2024_ +_Last Updated: December 12th, 2024_ Thank you so much for showing interest in contributing to TagStudio! Here are a set of instructions and guidelines for contributing code or documentation to the project. This document will change over time, so make sure that your contributions still line up with the requirements here before submitting a pull request. > [!CAUTION] -> **As of Pull Request [#332](https://github.com/TagStudioDev/TagStudio/pull/332) (SQLite Migration) the `main` branch will be an open test bed to get full JSON to SQL parity operational.** Existing TagStudio libraries are not yet compatible with this change, however they will **NOT be corrupted or deleted** if opened with these versions. Once parity is reached and a stable conversion tool in place, this notice will be removed. +> **As of Pull Request [#332](https://github.com/TagStudioDev/TagStudio/pull/332) (SQLite Migration) the `main` branch will marked as experimental before full JSON to SQL parity is operational.** Existing TagStudio libraries are not yet compatible with this change, however they will **NOT be corrupted or deleted** if opened with these versions. Once parity is reached and a stable conversion tool in place, this notice will be removed. UPDATE: As of November 19th, 2024, full parity is rapidly approaching. > > For the most recent stable feature release branch, see the [`Alpha-v9.4`](https://github.com/TagStudioDev/TagStudio/tree/Alpha-v9.4) branch. These v9.4 specific features are currently being backported to the SQL-ized `main` branch. (Feel free to help!) ## Getting Started -- Check the [Planned Features](https://github.com/TagStudioDev/TagStudio/blob/main/docs/updates/planned_features.md) page, [FAQ](/README.md/#faq), as well as the open [Issues](https://github.com/TagStudioDev/TagStudio/issues) and [Pull Requests](https://github.com/TagStudioDev/TagStudio/pulls). -- If you'd like to add a feature that isn't on the roadmap or doesn't have an open issue, **PLEASE create a feature request** issue for it discussing your intentions so any feedback or important information can be given by the team first. - - We don't want you wasting time developing a feature or making a change that can't/won't be added for any reason ranging from pre-existing refactors to design philosophy differences. -- If you wish to discuss TagStudio further, feel free to join the [Discord server](https://discord.com/invite/hRNnVKhF2G) +- Check the [Feature Roadmap](/docs/updates/roadmap.md) page to see what priority features there are, the [FAQ](/README.md/#faq), as well as the open [Issues](https://github.com/TagStudioDev/TagStudio/issues) and [Pull Requests](https://github.com/TagStudioDev/TagStudio/pulls). +- If you'd like to add a feature that isn't on the feature roadmap or doesn't have an open issue, **PLEASE create a feature request** issue for it discussing your intentions so any feedback or important information can be given by the team first. + - We don't want you wasting time developing a feature or making a change that can't/won't be added for any reason ranging from pre-existing refactors to design philosophy differences. +- **Please don't** create pull requests that consist of large refactors, *especially* without discussing them with us first. These end up doing more harm than good for the project by continuously delaying progress and disrupting everyone else's work. +- If you wish to discuss TagStudio further, feel free to join the [Discord Server](https://discord.com/invite/hRNnVKhF2G) ### Contribution Checklist -- I've read the [Planned Features](https://github.com/TagStudioDev/TagStudio/blob/main/docs/updates/planned_features.md) page -- I've read the [FAQ](/README.md/#faq), including the "[Features I Likely Won't Add/Pull](/README.md/#features-i-likely-wont-addpull)" section -- I've checked the open [Issues](https://github.com/TagStudioDev/TagStudio/issues) and [Pull Requests](https://github.com/TagStudioDev/TagStudio/pulls) -- **I've created a new issue for my feature _before_ starting work on it**, or have at least notified others in the relevant existing issue(s) of my intention to work on it -- I've set up my development environment including Ruff and Mypy -- I've read the [Code Guidelines](#code-guidelines) and/or [Documentation Guidelines](#documentation-guidelines) -- **_I mean it, I've found or created a new issue for my feature!_** +- I've read the [Feature Roadmap](/docs/updates/roadmap.md) page +- I've read the [FAQ](/README.md/#faq), including the "[Features I Likely Won't Add/Pull](/README.md/#features-i-likely-wont-addpull)" section +- I've checked the open [Issues](https://github.com/TagStudioDev/TagStudio/issues) and [Pull Requests](https://github.com/TagStudioDev/TagStudio/pulls) +- **I've created a new issue for my feature/fix _before_ starting work on it**, or have at least notified others in the relevant existing issue(s) of my intention to work on it +- I've set up my development environment including Ruff, Mypy, and PyTest +- I've read the [Code Guidelines](#code-guidelines) and/or [Documentation Guidelines](#documentation-guidelines) +- **_I mean it, I've found or created an issue for my feature/fix!_** + +> [!NOTE] +> If the fix is small and self-explanatory (i.e. a typo), then it doesn't require an issue to be opened first. Issue tracking is supposed to make our lives easier, not harder. Please use your best judgement to minimize the amount of work involved for everyone involved. ## Creating a Development Environment ### Prerequisites -- [Python](https://www.python.org/downloads/) 3.12 -- [Ruff](https://github.com/astral-sh/ruff) (Included in `requirements-dev.txt`) -- [Mypy](https://github.com/python/mypy) (Included in `requirements-dev.txt`) -- [PyTest](https://docs.pytest.org) (Included in `requirements-dev.txt`) +- [Python](https://www.python.org/downloads/) 3.12 +- [Ruff](https://github.com/astral-sh/ruff) (Included in `requirements-dev.txt`) +- [Mypy](https://github.com/python/mypy) (Included in `requirements-dev.txt`) +- [PyTest](https://docs.pytest.org) (Included in `requirements-dev.txt`) ### Creating a Python Virtual Environment @@ -49,35 +53,36 @@ If you wish to launch the source version of TagStudio outside of your IDE: `python3 -m venv .venv` 2. Activate your environment: -- Windows w/Powershell: `.venv\Scripts\Activate.ps1` -- Windows w/Command Prompt: `.venv\Scripts\activate.bat` -- Linux/macOS: `source .venv/bin/activate` +- Windows w/Powershell: `.venv\Scripts\Activate.ps1` +- Windows w/Command Prompt: `.venv\Scripts\activate.bat` +- Linux/macOS: `source .venv/bin/activate` 3. Install the required packages: -- `pip install -r requirements.txt` -- If developing (includes Ruff and Mypy): `pip install -r requirements-dev.txt` +- `pip install -r requirements.txt` +- If developing (includes Ruff and Mypy): `pip install -r requirements-dev.txt` _Learn more about setting up a virtual environment [here](https://docs.python.org/3/tutorial/venv.html)._ ### Manually Launching (Outside of an IDE) -- **Windows** (start_win.bat) +- **Windows** (start_win.bat) - - To launch TagStudio, launch the `start_win.bat` file. You can modify this .bat file or create a shortcut and add one or more additional arguments if desired. + - To launch TagStudio, launch the `start_win.bat` file. You can modify this .bat file or create a shortcut and add one or more additional arguments if desired. -- **Linux/macOS** (TagStudio.sh) +- **Linux/macOS** (TagStudio.sh) - - Run the "TagStudio.sh" script and the program should launch! (Make sure that the script is marked as executable if on Linux). Note that launching from the script from outside of a terminal will not launch a terminal window with any debug or crash information. If you wish to see this information, just launch the shell script directly from your terminal with `./TagStudio.sh`. + - Run the "TagStudio.sh" script and the program should launch! (Make sure that the script is marked as executable if on Linux). Note that launching from the script from outside of a terminal will not launch a terminal window with any debug or crash information. If you wish to see this information, just launch the shell script directly from your terminal with `./TagStudio.sh`. - - **NixOS** (Nix Flake) - > [!WARNING] - > Support for NixOS is still a work in progress. - - Use the provided [Flake](https://nixos.wiki/wiki/Flakes) to create and enter a working environment by running `nix develop`. Then, run the program via `python3 tagstudio/tag_studio.py` from the root directory. + - **NixOS** (Nix Flake) + - Use the provided [Flake](https://nixos.wiki/wiki/Flakes) to create and enter a working environment by running `nix develop`. Then, run the program via `python3 tagstudio/tag_studio.py` from the root directory. -- **Any** (No Scripts) +> [!WARNING] +> Support for NixOS is still a work in progress. - - Alternatively, with the virtual environment loaded, run the python file at `tagstudio\tag_studio.py` from your terminal. If you're in the project's root directory, simply run `python3 tagstudio/tag_studio.py`. +- **Any** (No Scripts) + + - Alternatively, with the virtual environment loaded, run the python file at `tagstudio\tag_studio.py` from your terminal. If you're in the project's root directory, simply run `python3 tagstudio/tag_studio.py`. ## Workflow Checks @@ -92,8 +97,13 @@ A Python linter and code formatter. Ruff uses the `pyproject.toml` as its config #### Running Locally -- Lint code by moving into the `/tagstudio` directory with `cd tagstudio` and running `ruff --config ../pyproject.toml`. -- Format code with `ruff format` inside the repository directory +Inside the root repository directory: + +- Lint code with `ruff check` + - Some linting suggestions can be automatically formatted with `ruff check --fix` +- Format code with `ruff format` + +Ruff should automatically discover the configuration options inside the [pyproject.toml](https://github.com/TagStudioDev/TagStudio/blob/main/pyproject.toml) file. For more information, see the [ruff configuration discovery docs](https://docs.astral.sh/ruff/configuration/#config-file-discovery). Ruff is also available as a VS Code [extension](https://marketplace.visualstudio.com/items?itemName=charliermarsh.ruff), PyCharm [plugin](https://plugins.jetbrains.com/plugin/20574-ruff), and [more](https://docs.astral.sh/ruff/integrations/). @@ -103,72 +113,76 @@ Mypy is a static type checker for Python. It sure has a lot to say sometimes, bu #### Running Locally -- **First time only:** Move into the `/tagstudio` directory with `cd tagstudio` and run the following: - - `mkdir -p .mypy_cache` - - `mypy --install-types --non-interactive` -- Check code by moving into the `/tagstudio` directory with `cd tagstudio` _(if you aren't already inside)_ and running `mypy --config-file ../pyproject.toml .`. _(Don't forget the `.` at the end!)_ - -> [!CAUTION] -> There's a known issue between PySide v6.6.3 and Mypy where Mypy will detect issues with the `.pyi` files inside of PySide and prematurely stop checking files. This issue is not present in PySide v6.6.2, which _should_ be compatible with everything else if you wish to try using that version in the meantime. +- **First time only:** Move into the `/tagstudio` directory with `cd tagstudio` and run the following: + - `mkdir -p .mypy_cache` + - `mypy --install-types --non-interactive` +- Check code by moving into the `/tagstudio` directory with `cd tagstudio` _(if you aren't already inside)_ and running `mypy --config-file ../pyproject.toml .`. _(Don't forget the `.` at the end!)_ Mypy is also available as a VS Code [extension](https://marketplace.visualstudio.com/items?itemName=matangover.mypy), PyCharm [plugin](https://plugins.jetbrains.com/plugin/11086-mypy), and [more](https://plugins.jetbrains.com/plugin/11086-mypy). ### PyTest -- Run all tests by moving into the `/tagstudio` directory with `cd tagstudio` and running `pytest tests/`. +- Run all tests by moving into the `/tagstudio` directory with `cd tagstudio` and running `pytest tests/`. -## Code Guidelines - -### Style +## Code Style Most of the style guidelines can be checked, fixed, and enforced via Ruff. Older code may not be adhering to all of these guidelines, in which case _"do as I say, not as I do"..._ -- Do your best to write clear, concise, and modular code. -- Try to keep a maximum column with of no more than **100** characters. -- Code comments should be used to help describe sections of code that don't speak for themselves. -- Use [Google style](https://google.github.io/styleguide/pyguide.html#s3.8-comments-and-docstrings) docstrings for any classes and functions you add. - - If you're modifying an existing function that does _not_ have docstrings, you don't _have_ to add docstrings to it... but it would be pretty cool if you did ;) -- Imports should be ordered alphabetically (in newly created python files). -- When writing text for window titles, form titles, or dropdown options, use "[Title Case](https://apastyle.apa.org/style-grammar-guidelines/capitalization/title-case)" capitalization. Your IDE may have a command to format this for you automatically, although some may incorrectly capitalize short prepositions. In a pinch you can use a website such as [capitalizemytitle.com](https://capitalizemytitle.com/) to check. -- If it wasn't mentioned above, then stick to [**PEP-8**](https://peps.python.org/pep-0008/)! - > [!WARNING] - > Column width limits, docstring formatting, and import sorting aren't currently checked in the Ruff workflow but likely will be in the near future. +- Do your best to write clear, concise, and modular code. +- Keep a maximum column with of no more than **100** characters. +- Code comments should be used to help describe sections of code that can't speak for themselves. +- Use [Google style](https://google.github.io/styleguide/pyguide.html#s3.8-comments-and-docstrings) docstrings for any classes and functions you add. + - If you're modifying an existing function that does _not_ have docstrings, you don't _have_ to add docstrings to it... but it would be pretty cool if you did ;) +- Imports should be ordered alphabetically. +- Lists of values should be ordered using their [natural sort order](https://en.wikipedia.org/wiki/Natural_sort_order). + - Some files have their methods ordered alphabetically as well (i.e. [`thumb_renderer`](https://github.com/TagStudioDev/TagStudio/blob/main/tagstudio/src/qt/widgets/thumb_renderer.py)). If you're working in a file and notice this, please try and keep to the pattern. +- When writing text for window titles or form titles, use "[Title Case](https://apastyle.apa.org/style-grammar-guidelines/capitalization/title-case)" capitalization. Your IDE may have a command to format this for you automatically, although some may incorrectly capitalize short prepositions. In a pinch you can use a website such as [capitalizemytitle.com](https://capitalizemytitle.com/) to check. +- If it wasn't mentioned above, then stick to [**PEP-8**](https://peps.python.org/pep-0008/)! -### Implementations +### Modules & Implementations -- Avoid direct calls to `os` - - Use `Pathlib` library instead of `os.path` - - Use `sys.platform` instead of `os.name` -- Don't prepend local imports with `tagstudio`, stick to `src` -- Use `logging` instead of `print` statements -- Avoid nested `f-string`s +- Avoid direct calls to `os` + - Use `Pathlib` library instead of `os.path` + - Use `platform.system()` instead of `os.name` and `sys.platform` +- Don't prepend local imports with `tagstudio`, stick to `src` +- Use the `logger` system instead of `print` statements +- Avoid nested f-strings +- Use HTML-like tags inside Qt widgets over stylesheets where possible. -#### Runtime +### Commit and Pull Request Style -- Code must function on supported versions of Windows, macOS, and Linux: - - Windows: 10, 11 - - macOS: 12.0+ - - Linux: TBD -- Avoid use of unnecessary logging statements in final submitted code. -- Code should not cause unreasonable slowdowns to the program outside of a progress-indicated task. +- Use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0) as a guideline for commit messages. This allows us to easily generate changelogs for releases. + - See some [examples](https://www.conventionalcommits.org/en/v1.0.0/#examples) of what this looks like in practice. +- Use clear and concise commit messages. If your commit does too much, either consider breaking it up into smaller commits or providing extra detail in the commit description. +- Pull requests should have an adequate title and description which clearly outline your intentions and changes/additions. Feel free to provide screenshots, GIFs, or videos, especially for UI changes. +- Pull requests should ideally be limited to **a single** feature or fix. -#### Git/GitHub Specifics +> [!TIP] +> If you're unsure where to stop the scope of your PR, ask yourself: _"If I broke this up, could any parts of it still be used by the project in the meantime?"_ -- [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0) is used as a guideline for commit messages. This allows us to easily generate changelogs for releases. - - See some [examples](https://www.conventionalcommits.org/en/v1.0.0/#examples) of what this looks like in practice. -- Use clear and concise commit messages. If your commit does too much, either consider breaking it up into smaller commits or providing extra detail in the commit description. -- Pull Requests should have an adequate title and description which clearly outline your intentions and changes/additions. Feel free to provide screenshots, GIFs, or videos, especially for UI changes. +### Runtime Requirements + +- Final code must function on supported versions of Windows, macOS, and Linux: + - Windows: 10, 11 + - macOS: 12.0+ + - Linux: _Varies_ +- Final code must **_NOT:_** + - Contain superfluous or unnecessary logging statements. + - Cause unreasonable slowdowns to the program outside of a progress-indicated task. + - Cause undesirable visual glitches or artifacts on screen. ## Documentation Guidelines -Documentation contributions include anything inside of the `doc/` folder, as well as the `README.md` and `CONTRIBUTING.md` files. +Documentation contributions include anything inside of the `docs/` folder, as well as the `README.md` and `CONTRIBUTING.md` files. Documentation inside the `docs/` folder is built and hosted on our static documentation site, [docs.tagstud.io](https://docs.tagstud.io/). -- Use "[snake_case](https://developer.mozilla.org/en-US/docs/Glossary/Snake_case)" for file and folder names -- Follow the folder structure pattern -- Don't add images or other media with excessively large file sizes -- Provide alt text for all embedded media -- Use "[Title Case](https://apastyle.apa.org/style-grammar-guidelines/capitalization/title-case)" for title capitalization +- Use "[snake_case](https://developer.mozilla.org/en-US/docs/Glossary/Snake_case)" for file and folder names +- Follow the folder structure pattern +- Don't add images or other media with excessively large file sizes +- Provide alt text for all embedded media +- Use "[Title Case](https://apastyle.apa.org/style-grammar-guidelines/capitalization/title-case)" for title capitalization ## Translation Guidelines -_TBA_ +Translations are performed on the TagStudio [Weblate project](https://hosted.weblate.org/projects/tagstudio/). + +_Translation guidelines coming soon._ diff --git a/README.md b/README.md index 49b18077..2317af95 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,19 @@ # TagStudio: A User-Focused Document Management System +[![Translation](https://hosted.weblate.org/widget/tagstudio/strings/svg-badge.svg)](https://hosted.weblate.org/projects/tagstudio/strings/) +[![PyTest](https://github.com/TagStudioDev/TagStudio/actions/workflows/pytest.yaml/badge.svg)](https://github.com/TagStudioDev/TagStudio/actions/workflows/pytest.yaml) +[![MyPy](https://github.com/TagStudioDev/TagStudio/actions/workflows/mypy.yaml/badge.svg)](https://github.com/TagStudioDev/TagStudio/actions/workflows/mypy.yaml) +[![Ruff](https://github.com/TagStudioDev/TagStudio/actions/workflows/ruff.yaml/badge.svg)](https://github.com/TagStudioDev/TagStudio/actions/workflows/ruff.yaml) +[![Downloads](https://img.shields.io/github/downloads/TagStudioDev/TagStudio/total.svg?maxAge=2592001)](https://github.com/TagStudioDev/TagStudio/releases) +

-TagStudio is a photo & file organization application with an underlying system that focuses on giving freedom and flexibility to the user. No proprietary programs or formats, no sea of sidecar files, and no complete upheaval of your filesystem structure. **Read the documentation and more at [docs.tagstud.io](https://docs.tagstud.io)!** +TagStudio is a photo & file organization application with an underlying tag-based system that focuses on giving freedom and flexibility to the user. No proprietary programs or formats, no sea of sidecar files, and no complete upheaval of your filesystem structure. **Read the documentation and more at [docs.tagstud.io](https://docs.tagstud.io)!** > [!CAUTION] -> **As of Pull Request [#332](https://github.com/TagStudioDev/TagStudio/pull/332) (SQLite Migration) the `main` branch will be an open test bed to get full JSON to SQL parity operational.** Existing TagStudio libraries are not yet compatible with this change, however they will **NOT be corrupted or deleted** if opened with these versions. Once parity is reached and a stable conversion tool in place, this notice will be removed. +> As of Pull Request [#332](https://github.com/TagStudioDev/TagStudio/pull/332) (SQLite Migration) the `main` branch will be an open test bed to get full JSON to SQL parity operational. This notice will be removed once parity between v9.4 and v9.5 is reached. > > For the most recent stable feature release branch, see the [`Alpha-v9.4`](https://github.com/TagStudioDev/TagStudio/tree/Alpha-v9.4) branch. These v9.4 specific features are currently being backported to the SQL-ized `main` branch. [Feel free to help!](/CONTRIBUTING.md) @@ -20,11 +26,12 @@ TagStudio is a photo & file organization application with an underlying system t > - Ask you to recreate your tags or libraries after new releases. It's our highest priority to ensure that your data safely and smoothly transfers over to newer versions. > - Cause you to suddenly be able to recall your 10 trillion downloaded images that you probably haven't even seen firsthand before. You're in control here, and even tools out there that use machine learning still needed to be verified by human eyes before being deemed accurate. -
- TagStudio Screenshot - -
TagStudio Alpha v9.1.0 running on Windows 10.
-
+

+ TagStudio Screenshot +

+

+ TagStudio Alpha v9.4.2 running on Windows 10. +

## Contents @@ -38,53 +45,81 @@ TagStudio is a photo & file organization application with an underlying system t ## Goals -- To achieve a portable, privacy-oriented, open, extensible, and feature-rich system of organizing and rediscovering files. -- To provide powerful methods for organization, notably the concept of tag composition, or “taggable tags”. -- To create an implementation of such a system that is resilient against a user’s actions outside the program (modifying, moving, or renaming files) while also not burdening the user with mandatory sidecar files or otherwise requiring them to change their existing file structures and workflows. +- To achieve a portable, private, extensible, open-format, and feature-rich system of organizing and rediscovering files. +- To provide powerful methods for organization, notably the concept of tag inheritance, or “taggable tags” _(and in the near future, the combination of composition-based tags)._ +- To create an implementation of such a system that is resilient against a user’s actions outside the program (modifying, moving, or renaming files) while also not burdening the user with mandatory sidecar files or requiring them to change their existing file structures and workflows. - To support a wide range of users spanning across different platforms, multi-user setups, and those with large (several terabyte) libraries. - To make the darn thing look like nice, too. It’s 2024, not 1994. ## Priorities -1. **The concept.** Even if TagStudio as a project or application fails, I’d hope that the idea lives on in a superior project. The [goals](#goals) outlined above don’t reference TagStudio once - _TagStudio_ is what references the _goals._ +1. **The concept.** Even if TagStudio as an application fails, I’d hope that the idea lives on in a superior project. The [goals](#goals) outlined above don’t reference TagStudio once - _TagStudio_ is what references the _goals._ 2. **The system.** Frontends and implementations can vary, as they should. The core underlying metadata management system is what should be interoperable between different frontends, programs, and operating systems. A standard implementation for this should settle as development continues. This opens up the doors for improved and varied clients, integration with third-party applications, and more. 3. **The application.** If nothing else, TagStudio the application serves as the first (and so far only) implementation for this system of metadata management. This has the responsibility of doing the idea justice and showing just what’s possible when it comes to user file management. -4. (The name.) I think it’s fine for an app or client, but it doesn’t really make sense for a system or standard. I suppose this will evolve with time. - -## Current Features - -- Create libraries/vaults centered around a system directory. Libraries contain a series of entries: the representations of your files combined with metadata fields. Each entry represents a file in your library’s directory, and is linked to its location. -- Add metadata to your library entries, including: - - Name, Author, Artist (Single-Line Text Fields) - - Description, Notes (Multiline Text Fields) - - Tags, Meta Tags, Content Tags (Tag Boxes) -- Create rich tags composed of a name, a list of aliases, and a list of “subtags” - being tags in which these tags inherit values from. -- Search for entries based on tags, ~~metadata~~ (TBA), or filenames/filetypes (using `filename: `) -- Special search conditions for entries that are: `untagged`/`no tags` and `empty`/`no fields`. - -> [!NOTE] -> For more information on the project itself, please see the [FAQ](#faq) section as well as the [documentation](/docs/index.md). +4. (The name.) I think it’s fine for an app or client, but it doesn’t really make sense for a system or standard. I suppose this will evolve with time... ## Contributing If you're interested in contributing to TagStudio, please take a look at the [contribution guidelines](/CONTRIBUTING.md) for how to get started! +Translation hosting generously provided by [Weblate](https://weblate.org/en/). Check out our [project page](<(https://hosted.weblate.org/projects/tagstudio/)>) to help translate TagStudio! + +## Current Features + +### Libraries + +- Create libraries/vaults centered around a system directory. Libraries contain a series of entries: the representations of your files combined with metadata fields. Each entry represents a file in your library’s directory, and is linked to its location. +- Address moved, deleted, or otherwise "unlinked" files by using the "Fix Unlinked Entries" option in the Tools menu. + +### Metadata + Tagging + +- Add metadata to your library entries, including: + - Name, Author, Artist (Single-Line Text Fields) + - Description, Notes (Multiline Text Fields) + - Tags, Meta Tags, Content Tags (Tag Boxes) +- Create rich tags composed of a name, a list of aliases, and a list of “parent tags” - being tags in which these tags inherit values from. +- Copy and paste tags and fields across file entries +- Generate tags from your existing folder structure with the "Folders to Tags" macro (NOTE: these tags do NOT sync with folders after they are created) + +### Search + +- Search for entries based on tags, ~~metadata~~ (TBA), or filenames/filetypes (using `filename: `). +- Special search conditions for entries that are: `untagged` and `empty`. + +### File Entries + +- All\* file types are supported in TagStudio libraries - just not all have dedicated thumbnail support. +- Preview most image file types, animated GIFs, videos, plain text documents, audio files\*\*, Blender projects, and more! +- Open files or file locations by right-clicking on thumbnails and previews and selecting the respective context menu options. You can also click on the preview panel image to open the file, and click the file path label to open its location. +- Delete files from both your library and drive by right-clicking the thumbnail(s) and selecting the "Move to Trash"/"Move to Recycle Bin" option. + +> _\* Weird files with no extension or files such as ".\_DS_Store" currently have limited support._ +> +> _\*\* Audio playback coming in v9.5_ + +> [!NOTE] +> For more information on the project itself, please see the [FAQ](#faq) section as well as the [documentation](https://docs.tagstud.io/). + ## Installation To download TagStudio, visit the [Releases](https://github.com/TagStudioDev/TagStudio/releases) section of the GitHub repository and download the latest release for your system under the "Assets" section. TagStudio is available for **Windows**, **macOS** _(Apple Silicon & Intel)_, and **Linux**. Windows and Linux builds are also available in portable versions if you want a more self-contained executable to move around. -For video thumbnails and playback, you'll also need [FFmpeg](https://ffmpeg.org/download.html) installed on your system. +**We do not currently publish TagStudio to any package managers. Any TagStudio distributions outside of the GitHub releases page are _unofficial_ and not maintained by us.** Installation support will not be given to users installing from unofficial sources. Use these versions at your own risk. > [!IMPORTANT] > On macOS, you may be met with a message saying _""TagStudio" can't be opened because Apple cannot check it for malicious software."_ If you encounter this, then you'll need to go to the "Settings" app, navigate to "Privacy & Security", and scroll down to a section that says _""TagStudio" was blocked from use because it is not from an identified developer."_ Click the "Open Anyway" button to allow TagStudio to run. You should only have to do this once after downloading the application. -#### Optional Arguments +### Third-Party Dependencies -Optional arguments to pass to the program. +- For video thumbnails and playback, you'll also need [FFmpeg](https://ffmpeg.org/download.html) installed on your system. + +### Optional Arguments + +Arguments available to pass to the program, either via the command line or a shortcut. > `--open ` / `-o ` > Path to a TagStudio Library folder to open on start. - +> > `--config-file ` / `-c ` > Path to the TagStudio config file to load. @@ -143,7 +178,7 @@ Inevitably, some of the files inside your library will be renamed, moved, or del ### Saving the Library -Libraries are saved upon exiting the program. To manually save, select File -> Save Library from the menu bar. To save a backup of your library, select File -> Save Library Backup from the menu bar. +Libraries are saved upon exiting the program. To manually save, select File -> Save Library from the menu bar. To save a backup of your library, select File -> Save Library Backup from the menu bar. Automatic backups are created when loading a library, and are automatically loaded from in the event of a crash or unexpected system shutdown. ### Half-Implemented Features @@ -183,74 +218,20 @@ See instructions in the "[Creating Development Environment](/CONTRIBUTING.md/#cr ### What State Is the Project Currently In? -As of writing (Alpha v9.3.0) the project is in a useable state, however it lacks proper testing and quality of life features. +As of writing (Alpha v9.4.2) the project is in a useable state, however includes several metadata field bugs and lacks several quality of life features. Focus has been on developing v9.5 with a new SQLite backend which will allow us to not only fix these bugs but also to give us a jumping off point for some [pretty cool](https://docs.tagstud.io/updates/roadmap/) features we've been wanting to add for quite a while now! ### What Features Are You Planning on Adding? -> [!IMPORTANT] -> See the [Planned Features](/docs/updates/planned_features.md) documentation for the latest feature lists. The lists here are currently being migrated over there with individual pages for larger features. +See the [Feature Roadmap](https://docs.tagstud.io/updates/roadmap/) page for the core features being planned and implemented for TagStudio. For a more up to date look on what's currently being added for upcoming releases, see our GitHub [milestones](https://github.com/TagStudioDev/TagStudio/milestones) for versioned releases. -Of the several features I have planned for the project, these are broken up into “priority” features and “future” features. Priority features were originally intended for the first public release, however are currently absent from the Alpha v9.x.x builds. - -#### Priority Features - -- Improved search - - Sortable Search - - Boolean Search - - Coexisting Text + Tag Search - - Searchable File Metadata -- Comprehensive Tag management tab -- Easier ways to apply tags in bulk - - Tag Search Panel - - Recent Tags Panel - - Top Tags Panel - - Pinned Tags Panel -- Better (stable, performant) library grid view -- Improved entry relinking -- Cached thumbnails -- Tag-like Groups -- Resizable thumbnail grid -- User-defined metadata fields -- Multiple directory support -- SQLite (or similar) save files -- Reading of EXIF and XMP fields -- Improved UI/UX -- Better internal API for accessing Entries, Tags, Fields, etc. from the library. -- Proper testing workflow -- Continued code cleanup and modularization -- Exportable/importable library data including "Tag Packs" - -#### Future Features - -- Support for multiple simultaneous users/clients -- Draggable files outside the program -- Comprehensive filetype whitelist -- A finished “macro system” for automatic tagging based on predetermined criteria. -- Different library views -- Date and time fields -- Entry linking/referencing -- Audio waveform previews -- 3D object previews -- Additional previews for miscellaneous file types -- Optional global tags and settings, spanning across libraries -- Importing & exporting libraries to/from other programs -- Port to a more performant language and modern frontend (Rust?, Tauri?, etc.) -- Plugin system -- Local OCR search -- Support for local machine learning-based tag suggestions for images -- Mobile version _(FAR future)_ - -#### Features I Likely Won’t Add/Pull +### Features That Will NOT Be Added - Native Cloud Integration - - There are plenty of services already (native or third-party) that allow you to mount your cloud drives as virtual drives on your system. Pointing TagStudio to one of these mounts should function similarly to what native integration would look like. + - There are plenty of services already (native or third-party) that allow you to mount your cloud drives as virtual drives on your system. Hosting a TagStudio library on one of these mounts should function similarly to what native integration would look like. + - Supporting native cloud integrations such as these would be an unnecessary "reinventing the wheel" burden for us that is outside the scope of this project. - Native ChatGPT/Non-Local LLM Integration - - This could mean different things depending on what you're intending. Whether it's trying to use an LLM to replace the native search, or to trying to use a model for image recognition, I'm not interested in hooking people's TagStudio libraries into non-local LLMs such as ChatGPT and/or turn the program into a "chatbot" interface (see: [Goals/Privacy](#goals)). I wouldn't, however, mind using **locally** hosted models to provide the _optional_ ability for additional searching and tagging methods (especially when it comes to facial recognition). + - This could mean different things depending on your intentions. Whether it's trying to use an LLM to replace the native search, or to trying to use a model for image recognition, I'm not interested in hooking people's TagStudio libraries into non-local LLMs such as ChatGPT and/or turn the program into a "chatbot" interface (see: [Goals/Privacy](#goals)). I wouldn't, however, mind using **locally** hosted models to provide the _optional_ ability for additional searching and tagging methods (especially when it comes to facial recognition) - but this would likely take the form of plugins external to the core program anyway. -### Why Is the Version Already v9? +### Why Is this Already Version 9? -I’ve been developing this project over several years in private, and have gone through several major iterations and rewrites in that time. This “major version” is just a number at the end of the day, and if I wanted to I couldn’t released this as “Version 0” or “Version 1.0”, but I’ve decided to stick to my original version numbers to avoid needing to go in and change existing documentation and code comments. Version 10 is intended to include all of the “Priority Features” I’ve outlined in the [previous](#what-features-are-you-planning-on-adding) section. I’ve also labeled this version as an Alpha, and will likely reset the numbers when a feature-complete beta is reached. - -### Wait, Is There a CLI Version? - -As of right now, **no**. However, I _did_ have a CLI version in the recent past before dedicating my efforts to the Qt GUI version. I’ve left in the currently-inoperable CLI code just in case anyone was curious about it. Also yes, it’s just a bunch of glorified print statements (_the outlook for some form of curses on Windows didn’t look great at the time, and I just needed a driver for the newly refactored code...)._ +Over the first few years of private development the project went through several major iterations and rewrites. These major version bumps came quickly, and by the time TagStudio was opened-sourced the version number had already reached v9.0. Instead of resetting to "v0.0" or "v1.0" for this public release I decided to keep my v9.x numbering scheme and reserve v10.0 for when all the core features on the [Feature Roadmap](https://docs.tagstud.io/updates/roadmap/) are implemented. I’ve also labeled this version as an "Alpha" and will drop this once either all of the core features are implemented or the project feels stable and feature-rich enough to be considered "Beta" and beyond. diff --git a/docs/assets/db_schema.png b/docs/assets/db_schema.png deleted file mode 100644 index f611542f..00000000 Binary files a/docs/assets/db_schema.png and /dev/null differ diff --git a/docs/assets/ffmpeg_windows_download.png b/docs/assets/ffmpeg_windows_download.png new file mode 100644 index 00000000..3a079d9e Binary files /dev/null and b/docs/assets/ffmpeg_windows_download.png differ diff --git a/docs/assets/screenshot.jpg b/docs/assets/screenshot.jpg index b506c493..581dd241 100644 Binary files a/docs/assets/screenshot.jpg and b/docs/assets/screenshot.jpg differ diff --git a/docs/help/ffmpeg.md b/docs/help/ffmpeg.md index 4678de3f..a5e52513 100644 --- a/docs/help/ffmpeg.md +++ b/docs/help/ffmpeg.md @@ -3,19 +3,29 @@ FFmpeg is required for thumbnail previews and playback features on audio and video files. FFmpeg is a free Open Source project dedicated to the handling of multimedia (video, audio, etc) files. For more information, see their official website at [ffmpeg.org](https://www.ffmpeg.org/). ## Installation on Windows + ### Prebuilt Binaries -Pre-built binaries from trusted sources are available on the [FFmpeg website](https://www.ffmpeg.org/download.html#build-windows). To install: + +Pre-built binaries from trusted sources are available on the [FFmpeg website](https://www.ffmpeg.org/download.html). Under "More downloading options" click on the Windows section, then under "Windows EXE Files" select a source to download a build from. Follow any further download instructions from whichever build website you choose. + +![Windows Download Location](../assets/ffmpeg_windows_download.png) + +!!! note + Do NOT download the source code by mistake! + +To Install: 1. Download 7z or zip file and extract it (right click > Extract All) 2. Move extracted contents to a unique folder (i.e; `c:\ffmpeg` or `c:\Program Files\ffmpeg`) -3. Add FFmpeg to your PATH +3. Add FFmpeg to your system PATH - 1. Go to "Edit the system environment variables" - 2. Under "User Variables", select "Path" then edit - 3. Click new and add `\bin` (e.g; `c:\ffmpeg\bin` or `c:\Program Files\ffmpeg\bin`) - 4. Click okay + 1. In Windows, search for or go to "Edit the system environment variables" under the Control Panel + 2. Under "User Variables", select "Path" then edit + 3. Click new and add `\bin` (e.g; `c:\ffmpeg\bin` or `c:\Program Files\ffmpeg\bin`) + 4. Click "Okay" ### Package Managers + FFmpeg is also available from: 1. WinGet (`winget install ffmpeg`) @@ -23,13 +33,15 @@ FFmpeg is also available from: 3. Chocolatey (`choco install ffmpeg-full`) ## Installation on Mac -### Homebrew -FFmpeg is available via [Homebrew](https://brew.sh/) and can be installed via: -`brew install ffmpeg` +### Homebrew + +FFmpeg is available under the macOS section of the [FFmpeg website](https://www.ffmpeg.org/download.html) or can be installed via [Homebrew](https://brew.sh/) using `brew install ffmpeg`. ## Installation on Linux + ### Package Managers + FFmpeg may be installed by default on some Linux distributions, but if not, it is available via your distro's package manager of choice: 1. Debian/Ubuntu (`sudo apt install ffmpeg`) @@ -37,4 +49,5 @@ FFmpeg may be installed by default on some Linux distributions, but if not, it i 3. Arch (`sudo pacman -S ffmpeg`) # Help + For additional help, please join the [Discord](https://discord.gg/hRNnVKhF2G) or create an Issue on the [GitHub repository](https://github.com/TagStudioDev/TagStudio) diff --git a/docs/index.md b/docs/index.md index 9f8240b4..907f74ec 100644 --- a/docs/index.md +++ b/docs/index.md @@ -9,42 +9,61 @@ title: Home ![TagStudio Alpha](assets/github_header.png) +TagStudio is a photo & file organization application with an underlying tag-based system that focuses on giving freedom and flexibility to the user. No proprietary programs or formats, no sea of sidecar files, and no complete upheaval of your filesystem structure. -TagStudio is a photo & file organization application with an underlying system that focuses on giving freedom and flexibility to the user. No proprietary programs or formats, no sea of sidecar files, and no complete upheaval of your filesystem structure. - -
+
![TagStudio screenshot](assets/screenshot.jpg) -
TagStudio Alpha v9.1.0 running on Windows 10
+
TagStudio Alpha v9.4.2 running on Windows 10
## Goals -- To achieve a portable, privacy-oriented, open, extensible, and feature-rich system of organizing and rediscovering files. -- To provide powerful methods for organization, notably the concept of tag composition, or “taggable tags”. -- To create an implementation of such a system that is resilient against a user’s actions outside the program (modifying, moving, or renaming files) while also not burdening the user with mandatory sidecar files or otherwise requiring them to change their existing file structures and workflows. +- To achieve a portable, private, extensible, open-format, and feature-rich system of organizing and rediscovering files. +- To provide powerful methods for organization, notably the concept of tag inheritance, or “taggable tags” _(and in the near future, the combination of composition-based tags)._ +- To create an implementation of such a system that is resilient against a user’s actions outside the program (modifying, moving, or renaming files) while also not burdening the user with mandatory sidecar files or requiring them to change their existing file structures and workflows. - To support a wide range of users spanning across different platforms, multi-user setups, and those with large (several terabyte) libraries. - To make the darn thing look like nice, too. It’s 2024, not 1994. ## Priorities -1. **The concept.** Even if TagStudio as a project or application fails, I’d hope that the idea lives on in a superior project. The [goals](#goals) outlined above don’t reference TagStudio once - _TagStudio_ is what references the _goals._ +1. **The concept.** Even if TagStudio as an application fails, I’d hope that the idea lives on in a superior project. The [goals](#goals) outlined above don’t reference TagStudio once - _TagStudio_ is what references the _goals._ 2. **The system.** Frontends and implementations can vary, as they should. The core underlying metadata management system is what should be interoperable between different frontends, programs, and operating systems. A standard implementation for this should settle as development continues. This opens up the doors for improved and varied clients, integration with third-party applications, and more. 3. **The application.** If nothing else, TagStudio the application serves as the first (and so far only) implementation for this system of metadata management. This has the responsibility of doing the idea justice and showing just what’s possible when it comes to user file management. -4. (The name.) I think it’s fine for an app or client, but it doesn’t really make sense for a system or standard. I suppose this will evolve with time. +4. (The name.) I think it’s fine for an app or client, but it doesn’t really make sense for a system or standard. I suppose this will evolve with time... + +## Feature Roadmap + +The [Feature Roadmap](updates/roadmap.md) lists all of the planned core features for TagStudio to be considered "feature complete" along with estimated release milestones. The development and testing of these features takes priority over all other requested or submitted features unless they are later added to this roadmap. This helps ensure that TagStudio eventually sees a full release and becomes more usable by more people more quickly. ## Current Features +### Libraries + - Create libraries/vaults centered around a system directory. Libraries contain a series of entries: the representations of your files combined with metadata fields. Each entry represents a file in your library’s directory, and is linked to its location. +- Address moved, deleted, or otherwise "unlinked" files by using the "Fix Unlinked Entries" option in the Tools menu. + +### Metadata + Tagging + - Add metadata to your library entries, including: - - Name, Author, Artist (Single-Line Text Fields) - - Description, Notes (Multiline Text Fields) - - Tags, Meta Tags, Content Tags (Tag Boxes) -- Create rich tags composed of a name, a list of aliases, and a list of “subtags” - being tags in which these tags inherit values from. -- Search for entries based on tags, ~~metadata~~ (TBA), or filenames/filetypes (using `filename: `) -- Special search conditions for entries that are: `untagged`/`no tags` and `empty`/`no fields`. + - Name, Author, Artist (Single-Line Text Fields) + - Description, Notes (Multiline Text Fields) + - Tags, Meta Tags, Content Tags (Tag Boxes) +- Create rich tags composed of a name, a list of aliases, and a list of “parent tags” - being tags in which these tags inherit values from. +- Copy and paste tags and fields across file entries +- Generate tags from your existing folder structure with the "Folders to Tags" macro (NOTE: these tags do NOT sync with folders after they are created) -## Important Updates +### Search -### [Database Migration](updates/db_migration.md) +- Search for entries based on tags, ~~metadata~~ (TBA), or filenames/filetypes (using `filename: `). +- Special search conditions for entries that are: `untagged` and `empty`. -The "Database Migration", "DB Migration", or "SQLite Migration" is an upcoming update to TagStudio which will replace the current JSON [library](library/index.md) with a SQL-based one, and will additionally include some fundamental changes to how some features such as [tags](library/tag.md) will work. +### File Entries + +- All\* file types are supported in TagStudio libraries - just not all have dedicated thumbnail support. +- Preview most image file types, animated GIFs, videos, plain text documents, audio files\*\*, Blender projects, and more! +- Open files or file locations by right-clicking on thumbnails and previews and selecting the respective context menu options. You can also click on the preview panel image to open the file, and click the file path label to open its location. +- Delete files from both your library and drive by right-clicking the thumbnail(s) and selecting the "Move to Trash"/"Move to Recycle Bin" option. + +> _\* Weird files with no extension or files such as ".\_DS_Store" currently have limited support._ +> +> _\*\* Audio playback coming in v9.5_ diff --git a/docs/library/tag_overrides.md b/docs/library/tag_overrides.md index ee9f6cec..b1f798bd 100644 --- a/docs/library/tag_overrides.md +++ b/docs/library/tag_overrides.md @@ -5,7 +5,7 @@ tags: # Tag Overrides -Tag overrides are the ability to add or remove [parent tags](tag.md#subtags) from a [tag](tag.md) on a per- [entry](entry.md) basis. Relies on the [Database Migration](../updates/db_migration.md) update being complete. +Tag overrides are the ability to add or remove [parent tags](tag.md#subtags) from a [tag](tag.md) on a per- [entry](entry.md) basis. ## Examples diff --git a/docs/updates/db_migration.md b/docs/updates/db_migration.md deleted file mode 100644 index 4e2a7abb..00000000 --- a/docs/updates/db_migration.md +++ /dev/null @@ -1,43 +0,0 @@ -# Database Migration - -The database migration is an upcoming refactor to TagStudio's library data storage system. The database will be migrated from a JSON-based one to a SQLite-based one. Part of this migration will include a reworked schema, which will allow for several new features and changes to how [tags](../library/tag.md) and [fields](../library/field.md) operate. - -## Schema - -![Database Schema](../assets/db_schema.png){ width="600" } - -### `alias` Table - -_Description TBA_ - -### `entry` Table - -_Description TBA_ - -### `entry_attribute` Table - -_Description TBA_ - -### `entry_page` Table - -_Description TBA_ - -### `location` Table - -_Description TBA_ - -### `tag` Table - -_Description TBA_ - -### `tag_relation` Table - -_Description TBA_ - -## Resulting New Features and Changes - -- Multiple Directory Support -- [Tag Categories](../library/tag_categories.md) (Replaces [Tag Fields](../library/field.md#tag_box)) -- [Tag Overrides](../library/tag_overrides.md) -- User-Defined [Fields](../library/field.md) -- Tag Icons diff --git a/docs/updates/planned_features.md b/docs/updates/planned_features.md deleted file mode 100644 index cf6da56d..00000000 --- a/docs/updates/planned_features.md +++ /dev/null @@ -1,59 +0,0 @@ -# Planned Features - -The following lists outline the planned major and minor features for TagStudio, in no particular order. - -# Major Features - -- [ ] [SQL Database Migration](../updates/db_migration.md) -- [ ] Multiple Directory Support -- [ ] [Tags Categories](../library/tag_categories.md) -- [ ] [Entry Groups](../library/entry_groups.md) -- [ ] [Tag Overrides](../library/tag_overrides.md) -- [ ] Tagging Panel - - [ ] Top Tags - - [ ] Recent Tags - - [ ] Tag Search - - [ ] Pinned Tags -- [ ] Configurable Default Fields (May be part of [Macros](../utilities/macro.md)) -- [ ] Deep File Extension Control -- [ ] Settings Menu -- [ ] Custom User Colors -- [ ] Search Engine Rework - - [ ] Boolean Search - - [ ] Tag Objects In Search - - [ ] Search For Fields - - [ ] Sortable Search Results -- [ ] Automatic Entry Relinking - - [ ] Detect Renames - - [ ] Detect Moves -- [ ] Thumbnail Caching -- [ ] User-Defined Fields -- [ ] Exportable Library/Tag Data - - [ ] Exportable Human-Readable Library - - [ ] Exportable/Importable Human-Readable “Tag Packs” - - [ ] Exportable/Importable Color Palettes -- [ ] Configurable Thumbnail Labels - - [ ] Toggle Extension Label - - [ ] Toggle File Size Label -- [ ] Configurable Thumbnail Tag Badges - - [ ] Customize tags that appear instead of just “Archive” and “Favorite” -- [ ] OCR Search - -## Minor Features - -- [ ] Deleting Tags -- [ ] Merging Tags -- [ ] Tag Icons -- [ ] Tag/Field Copy + Paste -- [ ] Collage UI -- [ ] Resizable Thumbnail Grid -- [ ] Draggable Files Outside The Program -- [ ] File Property Caching -- [ ] 3D Previews -- [ ] Audio Waveform Previews - - [ ] Toggle Between Waveform And Album Artwork -- [ ] PDF Previews -- [ ] SVG Previews -- [ ] Full Video Player -- [ ] Duration Properties For Video + Audio Files -- [ ] Optional Starter Tag Packs diff --git a/docs/updates/roadmap.md b/docs/updates/roadmap.md new file mode 100644 index 00000000..e204bc3b --- /dev/null +++ b/docs/updates/roadmap.md @@ -0,0 +1,280 @@ +# Feature Roadmap + +This checklist details the current and remaining features required at a minimum for TagStudio to be considered “Feature Complete”. This list is _not_ a definitive list for additional feature requests and PRs as they come in, but rather an outline of my personal core feature set intended for TagStudio. + +## Priorities + +Features are broken up into the following priority levels, with nested priorities referencing their relative priority for the overall feature (i.e. A [LOW] priority feature can have a [HIGH] priority element but it otherwise still a [LOW] priority item overall): + +- [HIGH] - Core feature +- [MEDIUM] - Important but not necessary +- [LOW] - Just nice to have + +## Core Feature List + +- [ ] Tags [HIGH] + - [x] ID-based, not string based [HIGH] + - [x] Tag name [HIGH] + - [x] Tag alias list, aka alternate names [HIGH] + - [x] Tag shorthand (specific short alias for displaying) [HIGH] + - [x] Parent/Inheritance subtags [HIGH] + - [ ] Composition/HAS subtags [HIGH] + - [ ] Deleting Tags [HIGH] [#148](https://github.com/TagStudioDev/TagStudio/issues/148) + - [ ] Merging Tags [HIGH] [#12](https://github.com/TagStudioDev/TagStudio/issues/12) + - [ ] Tag Icons [HIGH] [#195](https://github.com/TagStudioDev/TagStudio/issues/195) + - [ ] Small Icons [HIGH] + - [ ] Large Icons for Profiles [MEDIUM] + - [ ] Built-in Icon Packs (i.e. Boxicons) [HIGH] + - [ ] User Defined Icons [HIGH] + - [ ] Multiple Languages for Tag Strings [MEDIUM] + - [ ] User-defined tag colors [HIGH] [#264](https://github.com/TagStudioDev/TagStudio/issues/264) + - [ ] ID based, not string or hex [HIGH] + - [ ] Color name [HIGH] + - [ ] Color value (hex) [HIGH] + - [ ] Existing colors are now a set of base colors [HIGH] + - [ ] Editable [MEDIUM] + - [ ] Non-removable [HIGH] + - [ ] [Tag Categories](../library/tag_categories.md) [HIGH] + - [ ] Property available for tags that allow the tag and any inheriting from it to be displayed separately in the preview panel under a title [HIGH] + - [ ] Title is tag name [HIGH] + - [ ] Title has tag color [MEDIUM] + - [ ] Tag marked as category does not display as a tag itself [HIGH] + - [ ] [Tag Overrides](../library/tag_overrides.md) [MEDIUM] + - [ ] Per-file overrides of subtags [HIGH] +- [ ] Tag Packs [MEDIUM] [#3](https://github.com/TagStudioDev/TagStudio/issues/3) + - [ ] Human-readable (i.e. JSON) files containing tag data [HIGH] + - [ ] Importable [HIGH] + - [ ] Exportable [HIGH] + - [ ] Conflict resolution [HIGH] + - [ ] Color Packs [MEDIUM] + - [ ] Human-readable (i.e. JSON) files containing tag data [HIGH] + - [ ] Importable [HIGH] + - [ ] Exportable [HIGH] +- [ ] Exportable Library Data [HIGH] [#47](https://github.com/TagStudioDev/TagStudio/issues/47) + - [ ] Standard notation format (i.e. JSON) contacting all library data [HIGH] +- [ ] [Macros](../utilities/macro.md) [HIGH] + - [ ] Sharable Macros [MEDIUM] + - [ ] Standard notation format (i.e. JSON) contacting macro instructions [HIGH] + - [ ] Exportable [HIGH] + - [ ] Importable [HIGH] + - [ ] Triggers [HIGH] + - [ ] On new file [HIGH] + - [ ] On library refresh [HIGH] + - [...] + - [ ] Actions [HIGH] + - [ ] Add tag(s) [HIGH] + - [ ] Add field(s) [HIGH] + - [ ] Set field content [HIGH] + - [ ] [...] +- [ ] Settings Menu [HIGH] + - [ ] Application Settings [HIGH] + - [ ] Stored in system user folder/designated folder [HIGH] + - [ ] Library Settings [HIGH] + - [ ] Stored in `.TagStudio` folder [HIGH] +- [ ] Multiple Root Directories per Library [HIGH] [#295](https://github.com/TagStudioDev/TagStudio/issues/295) +- [ ] [Entry groups](../library/entry_groups.md) [HIGH] + - [ ] Groups for files/entries where the same entry can be in multiple groups [HIGH] + - [ ] Ability to number entries within group [HIGH] + - [ ] Ability to set sorting method for group [HIGH] + - [ ] Ability to set custom thumbnail for group [HIGH] + - [ ] Group is treated as entry with tags and metadata [HIGH] + - [ ] Nested groups [MEDIUM] +- [ ] Fields [HIGH] + - [x] Text Boxes [HIGH] + - [x] Text Lines [HIGH] + - [ ] Dates [HIGH] [#213](https://github.com/TagStudioDev/TagStudio/issues/213) + - [ ] GPS Location [LOW] + - [ ] Custom field names [HIGH] [#18](https://github.com/TagStudioDev/TagStudio/issues/18) +- [ ] Search engine [HIGH] [#325](https://github.com/TagStudioDev/TagStudio/issues/325) + - [ ] Boolean operators [HIGH] [#225](https://github.com/TagStudioDev/TagStudio/issues/225), [#314](https://github.com/TagStudioDev/TagStudio/issues/314) + - [ ] Tag objects + autocomplete [HIGH] [#476 (Autocomplete)](https://github.com/TagStudioDev/TagStudio/issues/476) + - [ ] Filename search [HIGH] + - [ ] Filetype search [HIGH] + - [ ] Search by extension (e.g. ".jpg", ".png") [HIGH] + - [ ] Optional consolidation of extension synonyms (i.e. ".jpg" can equal ".jpeg") [LOW] + - [ ] Search by media type (e.g. "image", "video", "document") [MEDIUM] + - [ ] Field content search [HIGH] [#272](https://github.com/TagStudioDev/TagStudio/issues/272) + - [ ] HAS operator for composition tags [HIGH] + - [ ] OCR search [LOW] + - [ ] Fuzzy Search [LOW] [#400](https://github.com/TagStudioDev/TagStudio/issues/400) + - [ ] Sortable results [HIGH] [#68](https://github.com/TagStudioDev/TagStudio/issues/68) + - [ ] Sort by relevance [HIGH] + - [ ] Sort by date created [HIGH] + - [ ] Sort by date modified [HIGH] + - [ ] Sort by date taken (photos) [MEDIUM] + - [ ] Sort by file size [HIGH] + - [ ] Sort by file dimension (images/video) [LOW] +- [ ] Automatic Entry Relinking [HIGH] [#36](https://github.com/TagStudioDev/TagStudio/issues/36) + - [ ] Detect Renames [HIGH] + - [ ] Detect Moves [HIGH] + - [ ] Detect Deletions [HIGH] +- [ ] Image Collages [LOW] [#91](https://github.com/TagStudioDev/TagStudio/issues/91) + - [ ] UI [HIGH] +- [ ] Tagging Panel [HIGH] + - [ ] Top Tags [HIGH] + - [ ] Recent Tags [HIGH] + - [ ] Tag Search [HIGH] + - [ ] Pinned Tags [HIGH] +- [ ] Configurable Thumbnails [MEDIUM] + - [ ] Custom thumbnail override [HIGH] + - [ ] Toggle File Extension Label [MEDIUM] + - [ ] Toggle Duration Label [MEDIUM] + - [ ] Custom Tag Badges [LOW] +- [ ] Thumbnails [HIGH] + - [ ] File Duration Label [HIGH] + - [ ] 3D Model Previews [LOW] + - [ ] STL Previews [HIGH] [#351](https://github.com/TagStudioDev/TagStudio/issues/351) +- [x] Drag and Drop [HIGH] + - [x] Drag files _to_ other programs [HIGH] + - [x] Drag files _to_ file explorer windows [MEDIUM] + - [x] Drag files _from_ file explorer windows [MEDIUM] + - [x] Drag files _from_ other programs [LOW] +- [ ] File Preview Panel [HIGH] + - [ ] Video Playback [HIGH] + - [x] Play/Pause [HIGH] + - [x] Loop [HIGH] + - [x] Toggle Autoplay [MEDIUM] + - [ ] Volume Control [HIGH] + - [x] Toggle Mute [HIGH] + - [ ] Timeline scrubber [HIGH] + - [ ] Fullscreen [MEDIUM] + - [ ] Audio Playback [HIGH] [#450](https://github.com/TagStudioDev/TagStudio/issues/450) + - [ ] Play/Pause [HIGH] + - [ ] Loop [HIGH] + - [ ] Toggle Autoplay [MEDIUM] + - [ ] Volume Control [HIGH] + - [ ] Toggle Mute [HIGH] + - [x] Timeline scrubber [HIGH] + - [ ] Fullscreen [MEDIUM] +- [ ] Optimizations [HIGH] + - [ ] Thumbnail caching [HIGH] [#104](https://github.com/TagStudioDev/TagStudio/issues/104) + - [ ] File property indexes [HIGH] + +## Version Milestones + +These version milestones are rough estimations for when the previous core features will be added. For a more definitive idea for when features are coming, please reference the current GitHub [milestones](https://github.com/TagStudioDev/TagStudio/milestones). + +### 9.5 (Alpha) + +- [x] SQL backend [HIGH] +- [ ] Translations _(Any applicable)_ [MEDIUM] +- [ ] Multiple Root Directories per Library [HIGH] +- [ ] Tags [HIGH] + - [ ] Deleting Tags [HIGH] + - [ ] Merging Tags [HIGH] + - [ ] User-defined tag colors [HIGH] + - [ ] ID based, not string or hex [HIGH] + - [ ] Color name [HIGH] + - [ ] Color value (hex) [HIGH] + - [ ] Existing colors are now a set of base colors [HIGH] + - [ ] Editable [MEDIUM] + - [ ] Non-removable [HIGH] + - [ ] [Tag Categories](../library/tag_categories.md) [HIGH] + - [ ] Property available for tags that allow the tag and any inheriting from it to be displayed separately in the preview panel under a title [HIGH] +- [ ] Search engine [HIGH] + - [ ] Boolean operators [HIGH] + - [ ] Tag objects + autocomplete [HIGH] + - [x] Filename search [HIGH] + - [x] Filetype search [HIGH] + - [x] Search by extension (e.g. ".jpg", ".png") [HIGH] + - [ ] Optional consolidation of extension synonyms (i.e. ".jpg" can equal ".jpeg") [LOW] + - [x] Search by media type (e.g. "image", "video", "document") [MEDIUM] + - [ ] Field content search [HIGH] + - [ ] Sortable results [HIGH] + - [ ] Sort by relevance [HIGH] + - [ ] Sort by date created [HIGH] + - [ ] Sort by date modified [HIGH] + - [ ] Sort by date taken (photos) [MEDIUM] + - [ ] Sort by file size [HIGH] + - [ ] Sort by file dimension (images/video) [LOW] +- [ ] Settings Menu [HIGH] + - [ ] Application Settings [HIGH] + - [ ] Stored in system user folder/designated folder [HIGH] + - [ ] Library Settings [HIGH] + - [ ] Stored in `.TagStudio` folder [HIGH] +- [ ] Optimizations [HIGH] + - [ ] Thumbnail caching [HIGH] + +### 9.6 (Alpha) + +- [ ] Tags [HIGH] + - [ ] Composition/HAS subtags [HIGH] + - [ ] Tag Icons [HIGH] + - [ ] Small Icons [HIGH] + - [ ] Large Icons for Profiles [MEDIUM] + - [ ] Built-in Icon Packs (i.e. Boxicons) [HIGH] + - [ ] User Defined Icons [HIGH] + - [ ] Multiple Languages for Tag Strings [MEDIUM] + - [ ] Title is tag name [HIGH] + - [ ] Title has tag color [MEDIUM] + - [ ] Tag marked as category does not display as a tag itself [HIGH] + - [ ] [Tag Overrides](../library/tag_overrides.md) [MEDIUM] + - [ ] Per-file overrides of subtags [HIGH] +- [ ] Fields [HIGH] + - [ ] Dates [HIGH] + - [ ] Custom field names [HIGH] + +### 9.7 (Alpha) + +- [ ] Configurable Thumbnails [MEDIUM] + - [ ] Toggle File Extension Label [MEDIUM] + - [ ] Toggle Duration Label [MEDIUM] + - [ ] Custom Tag Badges [LOW] +- [ ] Thumbnails [HIGH] + - [ ] File Duration Label [HIGH] +- [ ] [Entry groups](../library/entry_groups.md) [HIGH] + - [ ] Groups for files/entries where the same entry can be in multiple groups [HIGH] + - [ ] Ability to number entries within group [HIGH] + - [ ] Ability to set sorting method for group [HIGH] + - [ ] Ability to set custom thumbnail for group [HIGH] + - [ ] Group is treated as entry with tags and metadata [HIGH] + - [ ] Nested groups [MEDIUM] +- [ ] Tagging Panel [HIGH] + - [ ] Top Tags [HIGH] + - [ ] Recent Tags [HIGH] + - [ ] Tag Search [HIGH] + - [ ] Pinned Tags [HIGH] + +### 9.8 (Possible Beta) + +- [ ] Automatic Entry Relinking [HIGH] + - [ ] Detect Renames [HIGH] + - [ ] Detect Moves [HIGH] + - [ ] Detect Deletions [HIGH] +- [ ] [Macros](../utilities/macro.md) [HIGH] + - [ ] Sharable Macros [MEDIUM] + - [ ] Standard notation format (i.e. JSON) contacting macro instructions [HIGH] + - [ ] Exportable [HIGH] + - [ ] Importable [HIGH] + - [ ] Triggers [HIGH] + - [ ] On new file [HIGH] + - [ ] On library refresh [HIGH] + - [...] + - [ ] Actions [HIGH] + - [ ] Add tag(s) [HIGH] + - [ ] Add field(s) [HIGH] + - [ ] Set field content [HIGH] + - [ ] [...] + +### 9.9 (Possible Beta) + +- [ ] Tag Packs [MEDIUM] + - [ ] Human-readable (i.e. JSON) files containing tag data [HIGH] + - [ ] Importable [HIGH] + - [ ] Exportable [HIGH] + - [ ] Conflict resolution [HIGH] + - [ ] Color Packs [MEDIUM] + - [ ] Human-readable (i.e. JSON) files containing tag data [HIGH] + - [ ] Importable [HIGH] + - [ ] Exportable [HIGH] +- [ ] Exportable Library Data [HIGH] + - [ ] Standard notation format (i.e. JSON) contacting all library data [HIGH] + +### 10.0 (Possible Beta/Full Release) + +- [ ] All remaining [HIGH] and optional [MEDIUM] features + +### Post 10.0 + +- [ ] Core Library/API +- [ ] Plugin Support diff --git a/mkdocs.yml b/mkdocs.yml index f34800d1..b3c3c8ad 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -47,7 +47,7 @@ extra: # - utilities/macro.md # - Updates: # - updates/changelog.md -# - updates/planned_features.md +# - updates/roadmap.md # - updates/db_migration.md theme: diff --git a/pyproject.toml b/pyproject.toml index 023ee6f1..05e1a51b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,6 +4,7 @@ line-length = 100 [tool.ruff.lint.per-file-ignores] "tagstudio/tests/**" = ["D", "E402"] +"tagstudio/src/qt/helpers/vendored/**" = ["B", "E", "N", "UP", "SIM115"] [tool.ruff.lint.pydocstyle] convention = "google" @@ -38,6 +39,7 @@ disable_error_code = ["func-returns-value", "import-untyped"] explicit_package_bases = true warn_unused_ignores = true check_untyped_defs = true +mypy_path = ["tagstudio"] [[tool.mypy.overrides]] module = "tests.*" diff --git a/requirements-dev.txt b/requirements-dev.txt index b6b2c6e6..c80848cc 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,4 @@ -ruff==0.6.4 +ruff==0.8.1 pre-commit==3.7.0 pytest==8.2.0 Pyinstaller==6.6.0 diff --git a/requirements.txt b/requirements.txt index 18be43fa..9eb29039 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,14 +1,19 @@ +chardet==5.2.0 +ffmpeg-python==0.2.0 humanfriendly==10.0 -opencv_python>=4.8.0.74,<=4.9.0.80 +mutagen==1.47.0 +numpy==2.1.0 +opencv_python==4.10.0.84 +pillow-heif==0.16.0 +pillow-jxl-plugin==1.3.0 Pillow==10.3.0 -PySide6==6.7.1 -PySide6_Addons==6.7.1 -PySide6_Essentials==6.7.1 +pydub==0.25.1 +PySide6_Addons==6.8.0.1 +PySide6_Essentials==6.8.0.1 +PySide6==6.8.0.1 +rawpy==0.22.0 +SQLAlchemy==2.0.34 +structlog==24.4.0 typing_extensions>=3.10.0.0,<=4.11.0 ujson>=5.8.0,<=5.9.0 -numpy==1.26.4 -rawpy==0.21.0 -pillow-heif==0.16.0 -chardet==5.2.0 -structlog==24.4.0 -SQLAlchemy==2.0.34 +vtf2img==0.1.0 \ No newline at end of file diff --git a/tagstudio/resources/qt/images/broken_link_icon.png b/tagstudio/resources/qt/images/broken_link_icon.png new file mode 100644 index 00000000..d4310970 Binary files /dev/null and b/tagstudio/resources/qt/images/broken_link_icon.png differ diff --git a/tagstudio/resources/qt/images/file_icons/adobe_illustrator.png b/tagstudio/resources/qt/images/file_icons/adobe_illustrator.png new file mode 100644 index 00000000..141ae620 Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/adobe_illustrator.png differ diff --git a/tagstudio/resources/qt/images/file_icons/adobe_photoshop.png b/tagstudio/resources/qt/images/file_icons/adobe_photoshop.png new file mode 100644 index 00000000..44a2c167 Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/adobe_photoshop.png differ diff --git a/tagstudio/resources/qt/images/file_icons/affinity_photo.png b/tagstudio/resources/qt/images/file_icons/affinity_photo.png new file mode 100644 index 00000000..f4305fb8 Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/affinity_photo.png differ diff --git a/tagstudio/resources/qt/images/file_icons/audio.png b/tagstudio/resources/qt/images/file_icons/audio.png new file mode 100644 index 00000000..9019de01 Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/audio.png differ diff --git a/tagstudio/resources/qt/images/file_icons/document.png b/tagstudio/resources/qt/images/file_icons/document.png new file mode 100644 index 00000000..a3dacb01 Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/document.png differ diff --git a/tagstudio/resources/qt/images/file_icons/file_generic.png b/tagstudio/resources/qt/images/file_icons/file_generic.png new file mode 100644 index 00000000..13685e3a Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/file_generic.png differ diff --git a/tagstudio/resources/qt/images/file_icons/font.png b/tagstudio/resources/qt/images/file_icons/font.png new file mode 100644 index 00000000..174750db Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/font.png differ diff --git a/tagstudio/resources/qt/images/file_icons/image.png b/tagstudio/resources/qt/images/file_icons/image.png new file mode 100644 index 00000000..94264aec Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/image.png differ diff --git a/tagstudio/resources/qt/images/file_icons/image_vector.png b/tagstudio/resources/qt/images/file_icons/image_vector.png new file mode 100644 index 00000000..f0e38a3d Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/image_vector.png differ diff --git a/tagstudio/resources/qt/images/file_icons/material.png b/tagstudio/resources/qt/images/file_icons/material.png new file mode 100644 index 00000000..0c0c10af Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/material.png differ diff --git a/tagstudio/resources/qt/images/file_icons/model.png b/tagstudio/resources/qt/images/file_icons/model.png new file mode 100644 index 00000000..631db6e9 Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/model.png differ diff --git a/tagstudio/resources/qt/images/file_icons/presentation.png b/tagstudio/resources/qt/images/file_icons/presentation.png new file mode 100644 index 00000000..86a3b37c Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/presentation.png differ diff --git a/tagstudio/resources/qt/images/file_icons/program.png b/tagstudio/resources/qt/images/file_icons/program.png new file mode 100644 index 00000000..f7d64c1a Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/program.png differ diff --git a/tagstudio/resources/qt/images/file_icons/spreadsheet.png b/tagstudio/resources/qt/images/file_icons/spreadsheet.png new file mode 100644 index 00000000..fb1dbeac Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/spreadsheet.png differ diff --git a/tagstudio/resources/qt/images/file_icons/text.png b/tagstudio/resources/qt/images/file_icons/text.png new file mode 100644 index 00000000..79d7d91b Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/text.png differ diff --git a/tagstudio/resources/qt/images/file_icons/video.png b/tagstudio/resources/qt/images/file_icons/video.png new file mode 100644 index 00000000..5dae57a6 Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/video.png differ diff --git a/tagstudio/resources/qt/images/thumb_border_512.png b/tagstudio/resources/qt/images/thumb_border_512.png deleted file mode 100644 index 605717e3..00000000 Binary files a/tagstudio/resources/qt/images/thumb_border_512.png and /dev/null differ diff --git a/tagstudio/resources/qt/images/thumb_broken_512.png b/tagstudio/resources/qt/images/thumb_broken_512.png deleted file mode 100644 index 5022f2eb..00000000 Binary files a/tagstudio/resources/qt/images/thumb_broken_512.png and /dev/null differ diff --git a/tagstudio/resources/qt/images/thumb_file_default_512.png b/tagstudio/resources/qt/images/thumb_file_default_512.png deleted file mode 100644 index 28dfbd43..00000000 Binary files a/tagstudio/resources/qt/images/thumb_file_default_512.png and /dev/null differ diff --git a/tagstudio/resources/qt/images/thumb_loading.png b/tagstudio/resources/qt/images/thumb_loading.png new file mode 100644 index 00000000..174a1879 Binary files /dev/null and b/tagstudio/resources/qt/images/thumb_loading.png differ diff --git a/tagstudio/resources/qt/images/thumb_loading_512.png b/tagstudio/resources/qt/images/thumb_loading_512.png deleted file mode 100644 index 05008af5..00000000 Binary files a/tagstudio/resources/qt/images/thumb_loading_512.png and /dev/null differ diff --git a/tagstudio/resources/qt/images/thumb_loading_dark_512.png b/tagstudio/resources/qt/images/thumb_loading_dark_512.png deleted file mode 100644 index 7dcd99db..00000000 Binary files a/tagstudio/resources/qt/images/thumb_loading_dark_512.png and /dev/null differ diff --git a/tagstudio/resources/qt/images/thumb_mask_128.png b/tagstudio/resources/qt/images/thumb_mask_128.png deleted file mode 100644 index 52a0a135..00000000 Binary files a/tagstudio/resources/qt/images/thumb_mask_128.png and /dev/null differ diff --git a/tagstudio/resources/qt/images/thumb_mask_512.png b/tagstudio/resources/qt/images/thumb_mask_512.png deleted file mode 100644 index ce641abc..00000000 Binary files a/tagstudio/resources/qt/images/thumb_mask_512.png and /dev/null differ diff --git a/tagstudio/resources/qt/images/thumb_mask_hl_512.png b/tagstudio/resources/qt/images/thumb_mask_hl_512.png deleted file mode 100644 index 36c896b8..00000000 Binary files a/tagstudio/resources/qt/images/thumb_mask_hl_512.png and /dev/null differ diff --git a/tagstudio/resources/translations/da.json b/tagstudio/resources/translations/da.json new file mode 100644 index 00000000..fc59c8d6 --- /dev/null +++ b/tagstudio/resources/translations/da.json @@ -0,0 +1,6 @@ +{ + "home.base_title": "TagStudio Alfa", + "home.include_all_tags": "Og (inkluderer alle tags)", + "home.include_any_tag": "Og (inkluderer envher tag)", + "home.main_window": "Hovedvindue" +} diff --git a/tagstudio/resources/translations/de.json b/tagstudio/resources/translations/de.json new file mode 100644 index 00000000..ed4c48a5 --- /dev/null +++ b/tagstudio/resources/translations/de.json @@ -0,0 +1,125 @@ +{ + "add_field.add": "Feld hinzufügen", + "build_tags.add_parent_tags": "Übergeordnete Tags hinzufügen", + "build_tags.parent_tags": "Übergeordnete Tags", + "delete_unlinked.confirm": "Sind Sie sicher, dass Sie die folgenden %{len(self.lib.missing_files)}-Einträge löschen wollen?", + "delete_unlinked.delete_entries": "Einträge am Löschen", + "delete_unlinked.delete_unlinked": "Unverknüpfte Einträge löschen", + "delete_unlinked.deleting_number_entries": "Löschen von %{x[0]+1}/{len(self.lib.missing_files)} Unverknüpften Einträgen", + "dialog.open_create_library": "Bibliothek öffnen/erstellen", + "dialog.refresh_directories": "Verzeichnisse werden aktualisiert", + "dialog.save_library": "Bibliothek speichern", + "dialog.scan_directories": "Überprüfe Verzeichnisse auf neue Dateien...\nBereite vor...", + "dialog.scan_directories.new_files": "Verzeichnisse nach neuen Dateien durchsuchen...\n%{x + 1} Datei%{„s“ if x + 1 != 1 else „“} Gesucht, %{len(self.lib.files_not_in_library)} Neue Dateien gefunden", + "file_extension.add_extension": "Erweiterung hinzufügen", + "file_opener.command_not_found": "Konnte %{command_name} nicht im System PATH finden", + "file_opener.not_found": "Datei nicht gefunden:", + "file_opener.open_file": "Datei öffnen:}", + "fix_dupes.advice_label": "Nach der Spiegelung können Sie DupeGuru verwenden, um die unerwünschten Dateien zu löschen. Verwenden Sie anschliessend die Funktion „Unverknüpfte Einträge reparieren“ im Menü „Extras“ von TagStudio, um die unverknüpften Einträge zu löschen.", + "fix_dupes.fix_dupes": "Doppelte Dateien korrigieren", + "fix_dupes.load_file": "DupeGuru-Datei laden", + "fix_dupes.mirror_description": "Spiegeln die Eingabedaten für jeden doppelten Abgleichsatz, indem Sie alle Daten kombinieren, ohne Felder zu entfernen oder zu duplizieren. Bei diesem Vorgang werden keine Dateien oder Daten gelöscht.", + "fix_dupes.mirror_entries": "Spiegelnde Einträge", + "fix_dupes.name_filter": "DupeGuru-Dateien (*.dupeguru)", + "fix_dupes.no_file_match": "Übereinstimmungen mit doppelten Dateien: N/A", + "fix_dupes.no_file_selected": "Keine DupeGuru-Datei ausgewählt", + "fix_dupes.number_file_match": "Übereinstimmungen mit doppelten Dateien: %{count}", + "fix_dupes.open_result_files": "DupeGuru Ergebnisdatei öffnen", + "fix_unlinked.delete_unlinked": "Nicht verknüpfte Einträge löschen", + "fix_unlinked.description": "Jeder Bibliothekseintrag ist mit einer Datei in einem Ihrer Verzeichnisse verknüpft. Wenn eine Datei, die mit einem Eintrag verknüpft ist, ausserhalb von TagStudio verschoben oder gelöscht wird, gilt sie als nicht verknüpft. Nicht verknüpfte Einträge können durch Durchsuchen Ihrer Verzeichnisse automatisch neu verknüpft, vom Benutzer manuell neu verknüpft oder auf Wunsch gelöscht werden.", + "fix_unlinked.duplicate_description": "Doppelte Einträge sind definiert als mehrere Einträge, die auf dieselbe Datei auf der Festplatte verweisen. Durch das Zusammenführen dieser Einträge werden die Tags und Metadaten aller Duplikate zu einem einzigen konsolidierten Eintrag zusammengefasst. Diese sind nicht zu verwechseln mit „doppelten Dateien“, die Duplikate Ihrer Dateien selbst außerhalb von TagStudio sind.", + "fix_unlinked.fix_unlinked": "Unverknüpfte Einträge reparieren", + "fix_unlinked.manual_relink": "Manuelle Neuverknüpfung", + "fix_unlinked.merge_dupes": "Doppelte Einträge zusammenführen", + "fix_unlinked.refresh_dupes": "Doppelte Einträge aktualisieren", + "fix_unlinked.scan_library.label": "Bibliothek nach nicht verknüpften Einträgen durchsuchen...", + "fix_unlinked.scan_library.title": "Bibliothek wird scannen", + "fix_unlinked.search_and_relink": "Suche && Neuverbindung", + "generic.add": "hinzufügen", + "generic.aliases": "Aliase", + "generic.apply": "anwenden", + "generic.cancel": "Abbrechen", + "generic.close_all": "Alle schliessen", + "generic.color": "Farbe", + "generic.delete": "Löschen", + "generic.done": "Fertig", + "generic.exclude": "ausschliessen", + "generic.file_extension": "Dateierweiterungen", + "generic.include": "einschliessen", + "generic.mirror": "Spiegel", + "generic.name": "Name", + "generic.open_all": "Alle öffnen", + "generic.open_file": "Datei öffnen", + "generic.open_file_explorer": "Datei im Explorer öffnen", + "generic.refresh_all": "Alle aktualisieren", + "generic.remove_field": "Feld entfernen", + "generic.search_tags": "Tags suchen", + "generic.shorthand": "Kürzel", + "home.base_title": "TagStudio Alpha", + "home.include_all_tags": "Und (enthält alle Tags)", + "home.include_any_tag": "Oder (enthält alle Tags)", + "home.main_window": "Hauptfenster", + "home.search": "Suchen", + "home.search_entries": "Nach Einträgen suchen", + "home.thumbnail_size": "Grösse des Vorschaubildes", + "library.Artist": "Künstler", + "library.anthology": "Anthologie", + "library.archived": "Archivierungsdatum", + "library.author": "Autor", + "library.book": "Buch", + "library.collation": "Zusammenstellung", + "library.comic": "Comicheft", + "library.comments": "Kommentare", + "library.composer": "Komponist", + "library.content_tags": "Inhalt Tags", + "library.date": "Datum", + "library.date_created": "Erstellungsdatum", + "library.date_modified": "Datum geändert", + "library.date_published": "Datum des Publizierten", + "library.date_released": "Veröffentlichungsdatum", + "library.date_taken": "Aufnahmedatum", + "library.date_uploaded": "Hochladedatum", + "library.description": "Beschreibung", + "library.favorite": "Favoriten", + "library.guest_artist": "Gastkünstler", + "library.magazine": "Zeitschrift", + "library.manga": "Manga", + "library.meta_tags": "Meta Tags", + "library.notes": "Notizen", + "library.publisher": "Herausgeber", + "library.series": "Serie", + "library.source": "Quelle", + "library.tags": "Tags", + "library.title": "Titel", + "library.url": "URL", + "library.volume": "Band", + "menu.edit": "Bearbeiten", + "menu.file": "Datei", + "menu.help": "Hilfe", + "menu.macros": "Makros", + "menu.tools": "Werkzeuge", + "menu.window": "Fenster", + "merge.merge_dupe_entries": "Zusammenführen von doppelten Einträgen", + "merge.window_title": "Zusammenführen von doppelten Einträgen", + "open_library.library_creation_return_code": "Bibliothekserstellung Rückgabecode:", + "open_library.no_tagstudio_library_found": "Keine vorhandene TagStudio-Bibliothek unter '%{Pfad}' gefunden. Eine wird erstellt.", + "open_library.title": "Bibliothek", + "preview.dimensions": "Abmessungen", + "preview.recent": "Aktuelle Bibliotheken", + "progression.running_macros.new_entries": "Ausführen von Makros bei neuen Einträgen", + "progression.running_macros.one_new_entry": "Ausführen konfigurierter Makros für 1/%{len(new_ids)} neue Einträge", + "progression.running_macros.several_new_entry": "Ausführen konfigurierter Makros für %{x + 1}/%{len(new_ids)} neue Einträge", + "relink_unlinked.title": "Einträge werden neuverknüpft", + "splash.open_library": "Die Bibliothek wird geöffnet", + "status.backup_success": "Bibliotheks-Backup gespeichert unter:", + "status.enumerate_query": "Abfrage:%{Abfrage}, Rahmen: %{i}, Länge: %{len(f)}", + "status.number_results_found": "%{len(all_items)} Ergebnisse gefunden für „%{query}“ (%{format_timespan(end_time - start_time)})", + "status.results_found": "Ergebnisse", + "status.save_success": "Bibliothek gespeichert und geschlossen!", + "status.search_library_query": "Suche in der Bibliothek nach", + "tag.add": "Hinzufüge Tag", + "tag.library": "Bibliothek Tags", + "tag.new": "Neuer Tag", + "tooltip.open_library": "Ctrl+O", + "tooltip.save_library": "Ctrl+S" +} diff --git a/tagstudio/resources/translations/en.json b/tagstudio/resources/translations/en.json new file mode 100644 index 00000000..0f9b61e0 --- /dev/null +++ b/tagstudio/resources/translations/en.json @@ -0,0 +1,144 @@ +{ + "home.base_title": "TagStudio Alpha", + "home.main_window": "Main Window", + "home.include_all_tags": "And (Includes All Tags)", + "home.include_any_tag": "Or (Includes Any Tag)", + "home.thumbnail_size": "Thumbnail Size", + "home.search_entries": "Search Entries", + "home.search": "Search", + "menu.file": "File", + "menu.edit": "Edit", + "menu.tools": "Tools", + "menu.macros": "Macros", + "menu.window": "Window", + "menu.help": "Help", + "tag.new": "New Tag", + "tag.add": "Add Tag", + "tag.library": "Library Tags", + "merge.window_title": "Merging Duplicate Entries", + "merge.merge_dupe_entries": "Merging Duplicate Entries", + "preview.dimensions": "Dimensions", + "preview.recent": "Recent Libraries", + "library.title": "Title", + "library.author": "Author", + "library.Artist": "Artist", + "library.url": "URL", + "library.description": "Description", + "library.notes": "Notes", + "library.tags": "Tags", + "library.content_tags": "Content Tags", + "library.meta_tags": "Meta Tags", + "library.collation": "Collation", + "library.date": "Date", + "library.date_created": "Date Created", + "library.date_modified": "Date Modified", + "library.date_taken": "Date Taken", + "library.date_published": "Date Published", + "library.archived": "Date Archived", + "library.favorite": "Favorite", + "library.book": "Book", + "library.comic": "Comic", + "library.series": "Series", + "library.manga": "Manga", + "library.source": "Source", + "library.date_uploaded": "Date Uploaded", + "library.date_released": "Date Released", + "library.volume": "Volume", + "library.anthology": "Anthology", + "library.magazine": "Magazine", + "library.publisher": "Publisher", + "library.guest_artist": "Guest Artist", + "library.composer": "Composer", + "library.comments": "Comments", + "open_library.no_tagstudio_library_found": "No existing TagStudio library found at '%{path}'. Creating one.", + "open_library.library_creation_return_code": "Library Creation Return Code:", + "open_library.title": "Library", + "dialog.open_create_library": "Open/Create Library", + "splash.open_library": "Opening Library", + "status.save_success": "Library Saved and Closed!", + "status.backup_success": "Library Backup Saved at:", + "status.search_library_query": "Searching Library for", + "status.enumerate_query": "Query:%{query}, Frame: %{i}, Length: %{len(f)}", + "status.number_results_found": "%{len(all_items)} Results Found for \"%{query}\" (%{format_timespan(end_time - start_time)})", + "status.results_found": "Results", + "dialog.save_library": "Save Library", + "dialog.refresh_directories": "Refreshing Directories", + "dialog.scan_directories": "Scanning Directories for New Files...\nPreparing...", + "dialog.scan_directories.new_files": "Scanning Directories for New Files...\n%{x + 1} File%{\"s\" if x + 1 != 1 else \"\"} Searched, %{len(self.lib.files_not_in_library)} New Files Found", + "tooltip.open_library": "Ctrl+O", + "tooltip.save_library": "Ctrl+S", + "progression.running_macros.new_entries": "Running Macros on New Entries", + "progression.running_macros.one_new_entry": "Running Configured Macros on 1/%{len(new_ids)} New Entries", + "progression.running_macros.several_new_entry": "Running Configured Macros on %{x + 1}/%{len(new_ids)} New Entries", + "file_opener.open_file": "Opening file:}", + "file_opener.not_found": "File not found:", + "file_opener.command_not_found": "Could not find %{command_name} on system PATH", + "add_field.add": "Add Field", + "generic.remove_field": "Remove Field", + "generic.file_extension": "File Extensions", + "generic.open_file": "Open file", + "generic.open_file_explorer": "Open file in explorer", + "generic.cancel": "Cancel", + "generic.add": "Add", + "generic.name": "Name", + "generic.shorthand": "Shorthand", + "generic.aliases": "Aliases", + "generic.color": "Color", + "generic.delete": "Delete", + "generic.exclude": "Exclude", + "generic.include": "Include", + "generic.done": "Done", + "generic.open_all": "Open All", + "generic.close_all": "Close All", + "generic.refresh_all": "Refresh All", + "generic.apply": "Apply", + "generic.mirror": "Mirror", + "generic.search_tags": "Search Tags", + "build_tags.parent_tags": "Parent Tags", + "build_tags.add_parent_tags": "Add Parent Tags", + "delete_unlinked.delete_unlinked": "Delete Unlinked Entries", + "delete_unlinked.confirm": "Are you sure you want to delete the following %{len(self.lib.missing_files)} entries?", + "delete_unlinked.delete_entries": "Deleting Entries", + "delete_unlinked.deleting_number_entries": "Deleting %{x[0]+1}/{len(self.lib.missing_files)} Unlinked Entries", + "file_extension.add_extension": "Add Extension", + "file_extension.list_mode": "List Mode:", + "fix_dupes.fix_dupes": "Fix Duplicate Files", + "fix_dupes.no_file_selected": "No DupeGuru File Selected", + "fix_dupes.load_file": "Load DupeGuru File", + "fix_dupes.mirror_entries": "Mirror Entries", + "fix_dupes.mirror_description": "Mirror the Entry data across each duplicate match set, combining all data while not removing or duplicating fields. This operation will not delete any files or data.", + "fix_dupes.advice_label": "After mirroring, you're free to use DupeGuru to delete the unwanted files. Afterwards, use TagStudio's \"Fix Unlinked Entries\" feature in the Tools menu in order to delete the unlinked Entries.", + "fix_dupes.open_result_files": "Open DupeGuru Results File", + "fix_dupes.name_filter": "DupeGuru Files (*.dupeguru)", + "fix_dupes.no_file_match": "Duplicate File Matches: N/A", + "fix_dupes.number_file_match": "Duplicate File Matches: %{count}", + "fix_unlinked.fix_unlinked": "Fix Unlinked Entries", + "fix_unlinked.description": "Each library entry is linked to a file in one of your directories. If a file linked to an entry is moved or deleted outside of TagStudio, it is then considered unlinked. Unlinked entries may be automatically relinked via searching your directories, manually relinked by the user, or deleted if desired.", + "fix_unlinked.duplicate_description": "Duplicate entries are defined as multiple entries which point to the same file on disk. Merging these will combine the tags and metadata from all duplicates into a single consolidated entry. These are not to be confused with \"duplicate files\", which are duplicates of your files themselves outside of TagStudio.", + "fix_unlinked.search_and_relink": "Search && Relink", + "fix_unlinked.refresh_dupes": "Refresh Duplicate Entries", + "fix_unlinked.merge_dupes": "Merge Duplicate Entries", + "fix_unlinked.manual_relink": "Manual Relink", + "fix_unlinked.delete_unlinked": "Delete Unlinked Entries", + "fix_unlinked.scan_library.title": "Scanning Library", + "fix_unlinked.scan_library.label": "Scanning Library for Unlinked Entries...", + "folders_to_tags.folders_to_tags": "Converting folders to Tags", + "folders_to_tags.title": "Create Tags From Folders", + "folders_to_tags.description": "Creates tags based on your folder structure and applies them to your entries.\n The structure below shows all the tags that will be created and what entries they will be applied to.", + "mirror_entities.are_you_sure": "Are you sure you want to mirror the following %{len(self.lib.dupe_files)} Entries?", + "mirror_entities.title": "Mirroring Entries", + "mirror_entities.label": "Mirroring 1/%{count} Entries...", + "relink_unlinked.title": "Relinking Entries", + "relink_unlinked.attempt_relink": "Attempting to Relink %{x[0]+1}/%{len(self.lib.missing_files)} Entries, %{self.fixed} Successfully Relinked", + "landing.open_button": "Open/Create Library %{open_shortcut_text}", + "preview_panel.missing_location": "Location is missing", + "preview_panel.update_widgets": "[ENTRY PANEL] UPDATE WIDGETS (%{self.driver.selected})", + "preview_panel.no_items_selected": "No Items Selected", + "preview_panel.confirm_remove": "Are you sure you want to remove this \"%{self.lib.get_field_attr(field, \"name\")}\" field?", + "preview_panel.mixed_data": "Mixed Data", + "preview_panel.edit_name": "Edit", + "preview_panel.unknown_field_type": "Unknown Field Type", + "tag.search_for_tag": "Search for Tag", + "tag.add_search": "Add to Search", + "text_line_edit.unknown_event_type": "unknown event type: %{event}" +} diff --git a/tagstudio/resources/translations/es.json b/tagstudio/resources/translations/es.json new file mode 100644 index 00000000..f0b4a52e --- /dev/null +++ b/tagstudio/resources/translations/es.json @@ -0,0 +1,144 @@ +{ + "add_field.add": "Añadir campo", + "build_tags.add_parent_tags": "Añadir etiquetas principales", + "build_tags.parent_tags": "Etiquetas principales", + "delete_unlinked.confirm": "¿Está seguro de que desea eliminar las siguientes %{len(self.lib.missing_files)} entradas?", + "delete_unlinked.delete_entries": "Eliminando entradas", + "delete_unlinked.delete_unlinked": "Eliminar entradas no vinculadas", + "delete_unlinked.deleting_number_entries": "Eliminando %{x[0]+1}/{len(self.lib.missing_files)} entradas no vinculadas", + "dialog.open_create_library": "Abrir/Crear biblioteca", + "dialog.refresh_directories": "Refrescar directorios", + "dialog.save_library": "Guardar la biblioteca", + "dialog.scan_directories": "Buscar archivos nuevos en los directorios...\nPreparando...", + "dialog.scan_directories.new_files": "Buscando archivos nuevos en los directorios...\n%{x + 1} Archivo%{\"s\" if x + 1 != 1 else \"\"} Buscado, %{len(self.lib.files_not_in_library)} Archivos nuevos encontrados", + "file_extension.add_extension": "Añadir extensión", + "file_extension.list_mode": "Modo de lista:", + "file_opener.command_not_found": "No se pudo encontrar %{command_name} en el PATH del sistema", + "file_opener.not_found": "Archivo no encontrado:", + "file_opener.open_file": "Abriendo archivo :}", + "fix_dupes.advice_label": "Después de la duplicación, puede utilizar DupeGuru para eliminar los archivos no deseados. Luego, utilice la función \"Reparar entradas no vinculadas\" de TagStudio en el menú Herramientas para eliminar las entradas no vinculadas.", + "fix_dupes.fix_dupes": "Reparar archivos duplicados", + "fix_dupes.load_file": "Cargar archivo DupeGuru", + "fix_dupes.mirror_description": "Reflejar los datos de entrada en cada conjunto de coincidencias duplicadas, combinando todos los datos sin eliminar ni duplicar campos. Esta operación no eliminará ningún archivos ni dato.", + "fix_dupes.mirror_entries": "Reflejar entradas", + "fix_dupes.name_filter": "Archivos DupeGuru (*.dupeguru)", + "fix_dupes.no_file_match": "Coincidencias de archivos duplicados: N/D", + "fix_dupes.no_file_selected": "No se ha seleccionado ningún archivo DupeGuru", + "fix_dupes.number_file_match": "Coincidencias de archivos duplicados: %{count}", + "fix_dupes.open_result_files": "Abrir el archivo de resultados de DupeGuru", + "fix_unlinked.delete_unlinked": "Borrar entradas no vinculadas", + "fix_unlinked.description": "Cada entrada de la biblioteca está vinculada a un archivo en uno de sus directorios. Si un archivo vinculado a una entrada se mueve o se elimina fuera de TagStudio, se considerará desvinculado. Las entradas no vinculadas se pueden volver a vincular automáticamente mediante una búsqueda en sus directorios, el usuario puede volver a vincularlas manualmente o eliminarlas si así lo desea.", + "fix_unlinked.duplicate_description": "Las entradas duplicadas se definen como múltiples entradas que apuntan al mismo archivo en el disco. Al fusionarlas, se combinarán las etiquetas y los metadatos de todos los duplicados en una única entrada consolidada. No deben confundirse con los \"archivos duplicados\", que son duplicados de sus archivos fuera de TagStudio.", + "fix_unlinked.fix_unlinked": "Corregir entradas no vinculadas", + "fix_unlinked.manual_relink": "Reenlace manual", + "fix_unlinked.merge_dupes": "Fusionar entradas duplicadas", + "fix_unlinked.refresh_dupes": "Recargar entradas duplicadas", + "fix_unlinked.scan_library.label": "Buscando entradas no enlazadas en la biblioteca...", + "fix_unlinked.scan_library.title": "Escaneando la biblioteca", + "fix_unlinked.search_and_relink": "Buscar y volver a vincular", + "folders_to_tags.description": "Crea etiquetas basadas en su estructura de carpetas y las aplica a sus entradas.\n La siguiente estructura muestra todas las etiquetas que se crearán y a qué entradas se aplicarán.", + "folders_to_tags.folders_to_tags": "Convertir carpetas en etiquetas", + "folders_to_tags.title": "Crear etiquetas a partir de carpetas", + "generic.add": "Añadir", + "generic.aliases": "Alias", + "generic.apply": "Aplicar", + "generic.cancel": "Cancelar", + "generic.close_all": "Cerrar todo", + "generic.color": "Color", + "generic.delete": "Borrar", + "generic.done": "Donar", + "generic.exclude": "Excluir", + "generic.file_extension": "Extensiones del archivo", + "generic.include": "Incluir", + "generic.mirror": "Espejo", + "generic.name": "Nombre", + "generic.open_all": "Abrir todo", + "generic.open_file": "Abrir archivo", + "generic.open_file_explorer": "Abrir archivo en el explorador", + "generic.refresh_all": "Recargar todo", + "generic.remove_field": "Eliminar campo", + "generic.search_tags": "Buscar etiquetas", + "generic.shorthand": "Taquigrafía", + "home.base_title": "TagStudio Alfa", + "home.include_all_tags": "Y (Incluye todas las etiquetas)", + "home.include_any_tag": "O (Incluye cualquier etiqueta)", + "home.main_window": "Ventana principal", + "home.search": "Buscar", + "home.search_entries": "Buscar entradas", + "home.thumbnail_size": "Tamaño de la miniatura", + "landing.open_button": "Abrir/Crear biblioteca %{open_shortcut_text}", + "library.Artist": "Artista", + "library.anthology": "Antología", + "library.archived": "Fecha de archivo", + "library.author": "Autor", + "library.book": "Libro", + "library.collation": "Recopilación", + "library.comic": "Tebeo", + "library.comments": "Comentarios", + "library.composer": "Compositor", + "library.content_tags": "Etiquetas del contenido", + "library.date": "Fecha", + "library.date_created": "Fecha de creación", + "library.date_modified": "Fecha de modificación", + "library.date_published": "Fecha de publicación", + "library.date_released": "Fecha de lanzamiento", + "library.date_taken": "Fecha de realización", + "library.date_uploaded": "Fecha de carga", + "library.description": "Descripción", + "library.favorite": "Favorito", + "library.guest_artist": "Artista invitado", + "library.magazine": "Revista", + "library.manga": "Manga", + "library.meta_tags": "Etiquetas de metadatos", + "library.notes": "Notas", + "library.publisher": "Editor", + "library.series": "Series", + "library.source": "Fuente", + "library.tags": "Etiquetas", + "library.title": "Título", + "library.url": "URL", + "library.volume": "Volumen", + "menu.edit": "Editar", + "menu.file": "Archivo", + "menu.help": "Ayuda", + "menu.macros": "Macro", + "menu.tools": "Herramientas", + "menu.window": "Ventana", + "merge.merge_dupe_entries": "Fusionando entradas duplicadas", + "merge.window_title": "Fusionando entradas duplicadas", + "mirror_entities.are_you_sure": "¿Estás seguro de que quieres reflejar las siguientes %{len(self.lib.dupe_files)} entradas?", + "mirror_entities.label": "Reflejando 1/%{count} Entradas...", + "mirror_entities.title": "Entradas reflejadas", + "open_library.library_creation_return_code": "Código de retorno de creación de biblioteca:", + "open_library.no_tagstudio_library_found": "No se encontró ninguna biblioteca TagStudio existente en '%{path}'. Se está creando una.", + "open_library.title": "Biblioteca", + "preview.dimensions": "Dimensiones", + "preview.recent": "Bibliotecas recientes", + "preview_panel.confirm_remove": "¿Está seguro de que desea eliminar este campo \"%{self.lib.get_field_attr(field, \"name\")}\"?", + "preview_panel.edit_name": "Editar", + "preview_panel.missing_location": "Falta la ubicación", + "preview_panel.mixed_data": "Datos variados", + "preview_panel.no_items_selected": "No hay elementos seleccionados", + "preview_panel.unknown_field_type": "Tipo de campo desconocido", + "preview_panel.update_widgets": "[PANEL DE ENTRADA] ACTUALIZAR WIDGETS (%{self.driver.selected})", + "progression.running_macros.new_entries": "Ejecución de macros en entradas nuevas", + "progression.running_macros.one_new_entry": "Ejecución de macros configurados en 1/%{len(new_ids)} entradas nuevas", + "progression.running_macros.several_new_entry": "Ejecución de macros configurados en %{x + 1}/%{len(new_ids)} entradas nuevas", + "relink_unlinked.attempt_relink": "Intentando volver a vincular %{x[0]+1}/%{len(self.lib.missing_files)} Entradas, %{self.fixed} Reenlazado correctamente", + "relink_unlinked.title": "Volver a vincular las entradas", + "splash.open_library": "Abriendo la biblioteca", + "status.backup_success": "Copia de seguridad de la biblioteca guardada en:", + "status.enumerate_query": "Consulta:%{query}, Cuadro: %{i}, Longitud: %{len(f)}", + "status.number_results_found": "%{len(all_items)} Resultados encontrados para \"%{query}\" (%{format_timespan(end_time - start_time)})", + "status.results_found": "Resultados", + "status.save_success": "¡Biblioteca guardada y cerrada!", + "status.search_library_query": "Buscando en la biblioteca", + "tag.add": "Añadir etiqueta", + "tag.add_search": "Añadir a la búsqueda", + "tag.library": "Etiquetas de la biblioteca", + "tag.new": "Nueva etiqueta", + "tag.search_for_tag": "Buscar por etiqueta", + "text_line_edit.unknown_event_type": "tipo de suceso desconocido: %{event}", + "tooltip.open_library": "Ctrl+O", + "tooltip.save_library": "Ctrl+S" +} diff --git a/tagstudio/resources/translations/fil.json b/tagstudio/resources/translations/fil.json new file mode 100644 index 00000000..4f129b95 --- /dev/null +++ b/tagstudio/resources/translations/fil.json @@ -0,0 +1,110 @@ +{ + "add_field.add": "Magdagdag ng Field", + "build_tags.add_parent_tags": "Magdagdag ng Mga Parent Tag", + "build_tags.parent_tags": "Mga Parent Tag", + "delete_unlinked.confirm": "Sigurado ka ba gusto mong burahin ang (mga) sumusunod na %{len(self.lib.missing_files)} entry?", + "delete_unlinked.delete_entries": "Binubura ang Mga Entry", + "delete_unlinked.delete_unlinked": "Burahin ang Mga Hindi Naka-link na Entry", + "delete_unlinked.deleting_number_entries": "Binubura ang %{x[0]+1}/{len(self.lib.missing_files)} (mga) Naka-unlink na Entry", + "dialog.open_create_library": "Magbukas/Gumawa ng Library", + "dialog.refresh_directories": "Nire-refresh ang Mga Direktoryo", + "dialog.save_library": "I-save ang Library", + "dialog.scan_directories": "Sina-scan ang Mga Direktoryo para sa Mga Bagong File...\nNaghahanda...", + "dialog.scan_directories.new_files": "Sina-scan ang Mga Direktoryo para sa Mga Bagong File...\n%{x + 1} %{\"mga\" if x + 1 != 1 else \"\"} File na nahanap, %{len(self.lib.files_not_in_library)} Mga Bagong File na Nahanap", + "file_extension.add_extension": "Magdagdag ng Extension", + "file_extension.list_mode": "Mode ng Pag-lista:", + "file_opener.command_not_found": "Hindi mahanap ang %{command_name} sa system PATH", + "file_opener.not_found": "Hindi nahanap ang file:", + "file_opener.open_file": "Binubuksan ang file:}", + "fix_dupes.fix_dupes": "Ayusin ang Mga Duplicate na File", + "fix_dupes.load_file": "Mag-load ng DupeGuru File", + "fix_dupes.mirror_description": "Mirror the Entry data across each duplicate match set, combining all data while not removing or duplicating fields. This operation will not delete any files or data.", + "fix_dupes.mirror_entries": "Mga Entry ng Salamin", + "fix_dupes.no_file_selected": "Walang DupeGuru na File na Napili", + "generic.add": "Magdagdag", + "generic.aliases": "Mga Alyas", + "generic.apply": "I-apply", + "generic.cancel": "Kanselahin", + "generic.close_all": "Isara Lahat", + "generic.color": "Kulay", + "generic.delete": "Burahin", + "generic.done": "Tapos na", + "generic.exclude": "Huwag isama", + "generic.file_extension": "Mga File Extension", + "generic.include": "Isama", + "generic.mirror": "Salamin", + "generic.name": "Pangalan", + "generic.open_all": "Buksan Lahat", + "generic.open_file": "Buksan ang file", + "generic.open_file_explorer": "Buksan ang file sa explorer", + "generic.refresh_all": "I-refresh Lahat", + "generic.remove_field": "Tanggalin ang Field", + "generic.search_tags": "Maghanap ng Mga Tag", + "generic.shorthand": "Shorthand", + "home.base_title": "TagStudio Alpha", + "home.include_all_tags": "And (sinasama ang Lahat ng Mga Tag)", + "home.include_any_tag": "Or (Sinasama ang Anumang Tag)", + "home.main_window": "Main na Window", + "home.search": "Maghanap", + "home.search_entries": "Mga Entry sa Paghahanap", + "home.thumbnail_size": "Laki ng Thumbnail", + "library.Artist": "Artista", + "library.anthology": "Antolohiya", + "library.archived": "Petsa na Na-archve", + "library.author": "Awtor", + "library.book": "Aklat", + "library.collation": "Collation", + "library.comic": "Komik", + "library.comments": "Mga Komento", + "library.composer": "Kompositor", + "library.content_tags": "Mga Tag ng Content", + "library.date": "Petsa", + "library.date_created": "Petsa na Ginawa", + "library.date_modified": "Binago Noong", + "library.date_published": "Petsa na Inilabas", + "library.date_released": "Petsa na na-release", + "library.date_taken": "Kinuha Noong", + "library.date_uploaded": "Petsa na Na-upload", + "library.description": "Paglalarawan", + "library.favorite": "Paborito", + "library.guest_artist": "Bisitang Artista", + "library.magazine": "Magasin", + "library.manga": "Manga", + "library.meta_tags": "Mga Meta Tag", + "library.notes": "Mga tala", + "library.publisher": "Tagapaglathala", + "library.series": "Serye", + "library.source": "Pinagmulan", + "library.tags": "Mga Tag", + "library.title": "Paksa", + "library.url": "URL", + "library.volume": "Volume", + "menu.edit": "I-edit", + "menu.file": "File", + "menu.help": "Tulong", + "menu.macros": "Mga macro", + "menu.tools": "Mga tool", + "menu.window": "Window", + "merge.merge_dupe_entries": "Sinasama ang mga Duplicate na Entry", + "merge.window_title": "Sinasama ang mga Duplicate na Entry", + "open_library.library_creation_return_code": "Return Code ng Paggawa ng Library:", + "open_library.no_tagstudio_library_found": "Walang umiiral na TagStudio library na nahanap sa '%{path}'. Gumagawa ng isa.", + "open_library.title": "Library", + "preview.dimensions": "Laki", + "preview.recent": "Mga Kamakailang Library", + "progression.running_macros.new_entries": "Tumatakbo ng Mga Macro sa Mga Bagong Entry", + "progression.running_macros.one_new_entry": "Tinatakbo ang Mga Naka-configure na Macro sa 1/%{len(new_ids)} Mga Bagong Entry", + "progression.running_macros.several_new_entry": "Tinatakbo ang Mga Naka-configure na Macro sa %{x + 1}/%{len(new_ids)} Mga Bagong Entry", + "splash.open_library": "Binubuksan ang Library", + "status.backup_success": "Na-save ang Library Backup sa:", + "status.enumerate_query": "Query:%{query}, Frame: %{i}, Haba: %{len(f)}", + "status.number_results_found": "%{len(all_items)} Nakahanap ng Mga Resulta para sa \"%{query}\" (%{format_timespan(end_time - start_time)})", + "status.results_found": "Mga Resulta", + "status.save_success": "Sinave at Sinara ang Library!", + "status.search_library_query": "Hinahanap ang library para sa", + "tag.add": "Magdagdag ng Tag", + "tag.library": "Mga Tag ng Library", + "tag.new": "Bagong Tag", + "tooltip.open_library": "Ctrl+O", + "tooltip.save_library": "Ctrl+S" +} diff --git a/tagstudio/resources/translations/fr.json b/tagstudio/resources/translations/fr.json new file mode 100644 index 00000000..09b68742 --- /dev/null +++ b/tagstudio/resources/translations/fr.json @@ -0,0 +1,144 @@ +{ + "add_field.add": "Ajouter un Champ", + "build_tags.add_parent_tags": "Ajouter des Labels Parents", + "build_tags.parent_tags": "Labels Parent", + "delete_unlinked.confirm": "Êtes-vous sûr de vouloir supprimer les %{len(self.lib.missing_files)} entrées suivantes  ?", + "delete_unlinked.delete_entries": "Suppression des Entrées", + "delete_unlinked.delete_unlinked": "Supprimer les Entrées non Liées", + "delete_unlinked.deleting_number_entries": "Suppression des Entrées non Liées %{x[0]+1}/{len(self.lib.missing_files)}", + "dialog.open_create_library": "Ouvrir/Créer une Bibliothèque", + "dialog.refresh_directories": "Rafraîchissement des Dossiers", + "dialog.save_library": "Sauvegarder la Bibliothèque", + "dialog.scan_directories": "Recherche de Nouveaux Fichiers dans les Dossiers...\nPréparation...", + "dialog.scan_directories.new_files": "Recherche de Nouveaux Fichiers dans les Dossiers...\n%{x + 1} Fichiers%{\"s\" if x + 1 != 1 else \"\"} Recherchés, %{len(self.lib.files_not_in_library)} Nouveaux Fichiers Trouvés", + "file_extension.add_extension": "Ajouter une Extension", + "file_extension.list_mode": "Mode Liste :", + "file_opener.command_not_found": "Impossible de trouver %{command_name} dans le CHEMIN système", + "file_opener.not_found": "Fichier non trouvé:", + "file_opener.open_file": "Ouverture du Fichier:}", + "fix_dupes.advice_label": "Après réplication, vous êtes libre d'utiliser DupeGuru pour supprimer des fichiers non désirés. Ensuite, utilisez la fonctionnalité \"Réparation des Entrées non Liées\" de TagStudio dans le menu Outils pour supprimer les Entrées non liées.", + "fix_dupes.fix_dupes": "Réparer les Fichiers en Double", + "fix_dupes.load_file": "Charger un Fichier DupeGuru", + "fix_dupes.mirror_description": "Repliquer les données d'entrée dans chaque jeu de correspondances en double, en combinant toutes les données sans supprimer ni dupliquer de champs. Cette opération ne supprime aucun fichier ni aucune donnée.", + "fix_dupes.mirror_entries": "Répliquer les Entrées", + "fix_dupes.name_filter": "Fichiers DupeGuru (*.dupeguru)", + "fix_dupes.no_file_match": "Dupliquer les Correspondances de Fichier : N/A", + "fix_dupes.no_file_selected": "Aucun Fichier DupeGuru Sélectionné", + "fix_dupes.number_file_match": "Dupliquer les Correspondances de Fichier : %{count}", + "fix_dupes.open_result_files": "Ouvrire les Fichiers de Résultats de DupeGuru", + "fix_unlinked.delete_unlinked": "Supprimer les Entrées non Liées", + "fix_unlinked.description": "Chaque entrée dans la bibliothèque est liée à un fichier dans l'un de vos dossiers. Si un fichier lié à une entrée est déplacé ou supprimé en dehors de TagStudio, il est alors considéré non lié. Les entrées non liées peuvent être automatiquement reliées via la recherche dans vos dossiers, reliées manuellement par l'utilisateur, ou supprimées si désiré.", + "fix_unlinked.duplicate_description": "Les entrées dupliquées sont définies comme des entrées multiple qui pointent vers le même fichier sur le disque. Les fusionner va combiner les labels et metadatas de tous les duplicatas vers une seule entrée consolidée. Elles ne doivent pas être confondues avec les \"fichiers en doublon\", qui sont des doublons de vos fichiers en dehors de TagStudio.", + "fix_unlinked.fix_unlinked": "Réparation des Entrées non Liées", + "fix_unlinked.manual_relink": "Reliage Manuel", + "fix_unlinked.merge_dupes": "Fusionner les Entrées en Doublon", + "fix_unlinked.refresh_dupes": "Rafraichir les Entrées en Doublon", + "fix_unlinked.scan_library.label": "Balayage de la Bibliothèque pour trouver des Entrées non Liées...", + "fix_unlinked.scan_library.title": "Balayage de la Bibliothèque", + "fix_unlinked.search_and_relink": "Rechercher && Relier", + "folders_to_tags.description": "Créé des labels basés sur votre arborescence de dossier et les applique à vos entrées.\nLa structure ci-dessous affiche tous les labels qui seront créés et à quelles entrées ils seront appliqués.", + "folders_to_tags.folders_to_tags": "Conversion des dossiers en Labels", + "folders_to_tags.title": "Créer un Label à partir d'un Dossier", + "generic.add": "Ajouter", + "generic.aliases": "Alias", + "generic.apply": "Appliquer", + "generic.cancel": "Annuler", + "generic.close_all": "Tout Fermer", + "generic.color": "Couleur", + "generic.delete": "Supprimer", + "generic.done": "Terminé", + "generic.exclude": "Exclure", + "generic.file_extension": "Extensions de Fichiers", + "generic.include": "Inclure", + "generic.mirror": "Refléter", + "generic.name": "Nom", + "generic.open_all": "Tout Ouvrir", + "generic.open_file": "Ouvrir un Fichier", + "generic.open_file_explorer": "Ouvrir un Fichier dans l'Explorateur", + "generic.refresh_all": "Tout Rafraîchir", + "generic.remove_field": "Supprimer un Champ", + "generic.search_tags": "Recherche de Labels", + "generic.shorthand": "Abrégé", + "home.base_title": "TagStudio Alpha", + "home.include_all_tags": "Et (Inclus tous les Labels)", + "home.include_any_tag": "Ou (Inclus N'importe quel Label)", + "home.main_window": "Fenêtre Principale", + "home.search": "Rechercher", + "home.search_entries": "Recherche", + "home.thumbnail_size": "Taille de la miniature", + "landing.open_button": "Ouvrir/Créer une Bibliothèque %{open_shortcut_text}", + "library.Artist": "Artiste", + "library.anthology": "Anthologie", + "library.archived": "Date d'Archivage", + "library.author": "Auteur", + "library.book": "Livre", + "library.collation": "Collage", + "library.comic": "Bande Dessinée", + "library.comments": "Commentaires", + "library.composer": "Compositeur", + "library.content_tags": "Labels de Contenu", + "library.date": "Date", + "library.date_created": "Date de Création", + "library.date_modified": "Date de Modification", + "library.date_published": "Date de Publication", + "library.date_released": "Date de Sortie", + "library.date_taken": "Date de Capture", + "library.date_uploaded": "Date de Mise en Ligne", + "library.description": "Description", + "library.favorite": "Favori", + "library.guest_artist": "Artiste Invité", + "library.magazine": "Magazine", + "library.manga": "Manga", + "library.meta_tags": "Label Meta", + "library.notes": "Notes", + "library.publisher": "Éditeur", + "library.series": "Séries", + "library.source": "Source", + "library.tags": "Labels", + "library.title": "Titre", + "library.url": "URL", + "library.volume": "Volume", + "menu.edit": "Édition", + "menu.file": "Fichier", + "menu.help": "Aide", + "menu.macros": "Macros", + "menu.tools": "Outils", + "menu.window": "Fenêtre", + "merge.merge_dupe_entries": "Fusionner les duplicatas", + "merge.window_title": "Fusionner les duplicatas", + "mirror_entities.are_you_sure": "Êtes-vous sûr de vouloir répliquer les %{len(self.lib.dupe_files)} Entrées suivantes ?", + "mirror_entities.label": "Réplication de 1/%{count} Entrées...", + "mirror_entities.title": "Réplication des Entrées", + "open_library.library_creation_return_code": "Code de Retour de la Création de bibliothèque:", + "open_library.no_tagstudio_library_found": "Aucune bibliothèque TagStudio trouvé au chemin '%{path}'. Création d'une bibliothèque.", + "open_library.title": "Bibliothèque", + "preview.dimensions": "Dimensions", + "preview.recent": "Bibliothèques Récentes", + "preview_panel.confirm_remove": "Êtes-vous sûr de vouloir supprimer le champ \"%{self.lib.get_field_attr(field, \"name\")}\" ?", + "preview_panel.edit_name": "Éditer", + "preview_panel.missing_location": "Emplacement Manquant", + "preview_panel.mixed_data": "Données Mélangées", + "preview_panel.no_items_selected": "Pas d'Objet Selectionné", + "preview_panel.unknown_field_type": "Type de Champ Inconnu", + "preview_panel.update_widgets": "[PANEL D'ENTRÉE] MISE À JOUR DES WIDGETS (%{self.driver.selected})", + "progression.running_macros.new_entries": "Exécution des Macros sur les Nouvelles Entrées", + "progression.running_macros.one_new_entry": "Éxecution des Macros Configurées sur 1/%{len(new_ids)} Nouvelles Entrées", + "progression.running_macros.several_new_entry": "Éxectution des Macros Configurées sur %{x + 1}/%{len(new_ids)} Nouvelles Entrées", + "relink_unlinked.attempt_relink": "Tentative de Reliage de %{x[0]+1}/%{len(self.lib.missing_files)} Entrées, %{self.fixed} ont été Reliées avec Succès", + "relink_unlinked.title": "Reliage des Entrées", + "splash.open_library": "Ouverture de la Bilbliothèque", + "status.backup_success": "Bibliothèque sauvegardée au chemin  :", + "status.enumerate_query": "Requête:%{query}, Image: %{i}, Longueur: %{len(f)}", + "status.number_results_found": "%{len(all_items)} Résultats Trouvés pour \"%{query}\" (%{format_timespan(end_time - start_time)})", + "status.results_found": "Résultats", + "status.save_success": "Bibliothèque Sauvegardée et Fermée !", + "status.search_library_query": "Recherche dans la Bibliothèque pour", + "tag.add": "Ajouter un Label", + "tag.add_search": "Ajouter à la Recherche", + "tag.library": "Labels de la Bibliothèque", + "tag.new": "Nouveau Label", + "tag.search_for_tag": "Recherche de Label", + "text_line_edit.unknown_event_type": "Type d'évenement inconnu : %{event}", + "tooltip.open_library": "Ctrl+O", + "tooltip.save_library": "Ctrl+S" +} diff --git a/tagstudio/resources/translations/hu.json b/tagstudio/resources/translations/hu.json new file mode 100644 index 00000000..892ab95d --- /dev/null +++ b/tagstudio/resources/translations/hu.json @@ -0,0 +1,144 @@ +{ + "add_field.add": "Új mező", + "build_tags.add_parent_tags": "Új szülőcímke", + "build_tags.parent_tags": "Szülőcímkék", + "delete_unlinked.confirm": "Biztosan törölni akarja az alábbi %{len(self.lib.missing_files)} elemet?", + "delete_unlinked.delete_entries": "Elemek törlése", + "delete_unlinked.delete_unlinked": "Kapcsolat nélküli elemek törlése", + "delete_unlinked.deleting_number_entries": "{len(self.lib.missing_files)}/%{x[0]+1}. kapcsolat nélküli elem törlése", + "dialog.open_create_library": "Könyvtár megnyitása/létrehozása", + "dialog.refresh_directories": "Mappák frissítése", + "dialog.save_library": "Könyvtár mentése", + "dialog.scan_directories": "Új fájlok keresése a mappákban…\nElőkészítés…", + "dialog.scan_directories.new_files": "Új fájlok keresése a mappákban…\n%{x + 1} fájl megvizsgálva; ebből %{len(self.lib.files_not_in_library)} új", + "file_extension.add_extension": "Kiterjesztés hozzáadása", + "file_extension.list_mode": "Listázott elemek módja:", + "file_opener.command_not_found": "A(z) „%{command_name}”-parancs nem szerepel a rendszer PATH-változójában.", + "file_opener.not_found": "Az alábbi fájl nem található:", + "file_opener.open_file": "Fájl megnyitása:", + "fix_dupes.advice_label": "A tükrözés befejezése után a DupeGuruval kitörölheti a nem kívánt fájlokat. Ezt követően, a TagStudio „Kapcsolat nélküli elemek javítása” funkciójával eltávolíthatja az árván maradt elemeket.", + "fix_dupes.fix_dupes": "Egyező fájlok egyesítése", + "fix_dupes.load_file": "DupeGuru fájl betöltése", + "fix_dupes.mirror_description": "Az összes adat átmásolása minden összetartozó fájl között, ezzel kiegészítve a hiányzó címkéket eltávolítás és duplikálás nélkül. Ez a folyamat nem fog adatokat vagy fájlokat törölni.", + "fix_dupes.mirror_entries": "Elemek tükrözése", + "fix_dupes.name_filter": "DupeGuru-fájlok (*.dupeguru)", + "fix_dupes.no_file_match": "Nincsenek egyező fájlok", + "fix_dupes.no_file_selected": "Nincs kiválasztott DupeGuru-fájl.", + "fix_dupes.number_file_match": "%{count} egyező fájl", + "fix_dupes.open_result_files": "DupeGuru-fájl megnyitása", + "fix_unlinked.delete_unlinked": "Kapcsolat nélküli elemek törlése", + "fix_unlinked.description": "A könyvtár minden eleme egy fájllal van összekapcsolva a számítógépen. Ha egy kapcsolt fájl a TagSudión kívül kerül áthelyezésre vagy törésre, akkor ez a kapcsolat megszakad. Ezeket a kapcsolat nélküli elemeket a program megpróbálhatja automatikusan megkeresni, de Ön is kézileg újra összekapcsolhatja vagy törölheti őket.", + "fix_unlinked.duplicate_description": "Ha több elem ugyanazzal a fájllal van összekapcsolva, akkor egyezőnek számítanak. Ha egyesíti őket, akkor egy olyan elem lesz létrehozva, ami az eredeti elemek összes adatát tartalmazza. Ezeket nem szabad összetéveszteni az „egyező fájlokkal”, amelyek a TagStudión kívüli azonos tartalmú fájlok.", + "fix_unlinked.fix_unlinked": "Kapcsolat nélküli elemek javítása", + "fix_unlinked.manual_relink": "Újra összekapcsolás kézileg", + "fix_unlinked.merge_dupes": "Egyező elemek egyesítése", + "fix_unlinked.refresh_dupes": "Egyező elemek frissítése", + "fix_unlinked.scan_library.label": "Kapcsolat nélküli elemek keresése a könyvtárban…", + "fix_unlinked.scan_library.title": "Könyvtár vizsgálata", + "fix_unlinked.search_and_relink": "Keresés és újra összekapcsolás", + "folders_to_tags.description": "Címkék automatikus létrehozása a létező mappastruktúra alapján.\nAz alábbi mappafán megtekintheti a létrehozandó címkéket, és hogy mely elemekre lesznek alkalmazva.", + "folders_to_tags.folders_to_tags": "Mappák címkékké alakítása", + "folders_to_tags.title": "Címkék létrehozása mappák alapján", + "generic.add": "Hozzáadás", + "generic.aliases": "Áljelek", + "generic.apply": "Alkalmaz", + "generic.cancel": "Mégse", + "generic.close_all": "Összes bezárása", + "generic.color": "Szín", + "generic.delete": "Törlés", + "generic.done": "Kész", + "generic.exclude": "Elrejtés", + "generic.file_extension": "Kiterjesztések", + "generic.include": "Mutatás", + "generic.mirror": "Tükrözés", + "generic.name": "Név", + "generic.open_all": "Összes megnyitása", + "generic.open_file": "Fájl megnyitása", + "generic.open_file_explorer": "Fájl megnyitása Intézőben", + "generic.refresh_all": "Összes frissítése", + "generic.remove_field": "Mező eltávolítása", + "generic.search_tags": "Címkék keresése", + "generic.shorthand": "Rövidítés", + "home.base_title": "TagStudio Alfa", + "home.include_all_tags": "És (az összes keresett címkét tartalmazza)", + "home.include_any_tag": "Vagy (valamelyik keresett címkét tartalmazza)", + "home.main_window": "Fő ablak", + "home.search": "Keresés", + "home.search_entries": "Tételek keresése", + "home.thumbnail_size": "Indexkép mérete", + "landing.open_button": "Könyvtár megnyitása/létrehozása %{open_shortcut_text}", + "library.Artist": "Előadó", + "library.anthology": "Gyűjtemény", + "library.archived": "Archiválás dátuma", + "library.author": "Szerző", + "library.book": "Könyv", + "library.collation": "Rendezés", + "library.comic": "Képregény", + "library.comments": "Megjegyzés", + "library.composer": "Zeneszerző", + "library.content_tags": "Tartalomcímkék", + "library.date": "Dátum", + "library.date_created": "Létrehozás dátuma", + "library.date_modified": "Módosítás dátuma", + "library.date_published": "Közzététel dátuma", + "library.date_released": "Kiadás dátuma", + "library.date_taken": "Készítés dátuma", + "library.date_uploaded": "Feltöltés dátuma", + "library.description": "Leírás", + "library.favorite": "Kedvenc", + "library.guest_artist": "Társelőadó", + "library.magazine": "Magazin", + "library.manga": "Manga", + "library.meta_tags": "Metacímkék", + "library.notes": "Jegyzetek", + "library.publisher": "Kiadó", + "library.series": "Sorozat", + "library.source": "Forrás", + "library.tags": "Címkék", + "library.title": "Cím", + "library.url": "Hivatkozás", + "library.volume": "Kötet", + "menu.edit": "Szerkesztés", + "menu.file": "Fájl", + "menu.help": "Súgó", + "menu.macros": "Makrók", + "menu.tools": "Eszközök", + "menu.window": "Ablak", + "merge.merge_dupe_entries": "Egyező elemek egyesítése", + "merge.window_title": "Egyező elemek egyesítése", + "mirror_entities.are_you_sure": "Biztosan tükrözni akarja az alábbi adatokat %{len(self.lib.dupe_files)} különböző elemre?", + "mirror_entities.label": "%{count}/1 elem tükrözése folyamatban…", + "mirror_entities.title": "Elemek tükrözése", + "open_library.library_creation_return_code": "A könyvtárlétrehozási folyamat visszatérési kódja:", + "open_library.no_tagstudio_library_found": "A program nem talált létező TagStudio-könyvtárat az alábbi elérési úton: „%{path}”. Ezen a helyen egy új könyvtár lesz létrehozva.", + "open_library.title": "Könyvtár", + "preview.dimensions": "Méret", + "preview.recent": "Legutóbbi könytárak", + "preview_panel.confirm_remove": "Biztosan el akarja távolítani a(z) „%{self.lib.get_field_attr(field, \"name\")}”-mezőt?", + "preview_panel.edit_name": "Szerkesztés", + "preview_panel.missing_location": "Hiányzó hely", + "preview_panel.mixed_data": "Kevert adatok", + "preview_panel.no_items_selected": "Nincs kijelölt elem", + "preview_panel.unknown_field_type": "Ismeretlen mezőtípus", + "preview_panel.update_widgets": "[ENTRY PANEL] UPDATE WIDGETS (%{self.driver.selected})", + "progression.running_macros.new_entries": "Makrók futtatása az új elemeken", + "progression.running_macros.one_new_entry": "Korábban beállított makrók futtatása %{len(new_ids)}/1 új elemen", + "progression.running_macros.several_new_entry": "Korábban beállított makrók futtatása %{len(new_ids)}/%{x + 1} új elemen", + "relink_unlinked.attempt_relink": "%{len(self.lib.missing_files)}/%{x[0]+1} elem újra összekapcsolásának megkísérlése; %{self.fixed} elem sikeresen újra összekapcsolva", + "relink_unlinked.title": "Elemek újra összekapcsolása", + "splash.open_library": "Könyvtár megnyitása", + "status.backup_success": "A biztonsági mentés létrehozása megtörtént az alábbi elérési úton:", + "status.enumerate_query": "Keresési kifejezés: %{query}; %{i}. képokcka; Hossz: %{len(f)}", + "status.number_results_found": "%{len(all_items)} találat az alábbi kifejezésre: „%{query}” (%{format_timespan(end_time - start_time)})", + "status.results_found": "találat", + "status.save_success": "A könyvtár mentése és bezárása sikeresen megtörtént.", + "status.search_library_query": "Az alábbi kifejezés keresése a könyvtárban:", + "tag.add": "Címke hozzáadása", + "tag.add_search": "Keresési kifejezés kiegészítése", + "tag.library": "Könyvtárcímkék", + "tag.new": "Új címke", + "tag.search_for_tag": "Címke keresése", + "text_line_edit.unknown_event_type": "Ismeretlen eseménytípus: %{event}", + "tooltip.open_library": "Ctrl+O", + "tooltip.save_library": "Ctrl+S" +} diff --git a/tagstudio/resources/translations/it.json b/tagstudio/resources/translations/it.json new file mode 100644 index 00000000..94642e48 --- /dev/null +++ b/tagstudio/resources/translations/it.json @@ -0,0 +1,19 @@ +{ + "generic.add": "Aggiungi", + "generic.cancel": "Annulla", + "generic.color": "Colore", + "generic.delete": "Elimina", + "generic.name": "Nome", + "home.base_title": "TagStudio Alfa", + "home.main_window": "Finestra principale", + "home.search": "Cerca", + "library.Artist": "Artista", + "library.book": "Libro", + "library.description": "Descrizione", + "library.title": "Titolo", + "menu.file": "File", + "menu.window": "Finestra", + "preview.recent": "Librerias Recenti", + "tag.add": "Aggiungi Tag", + "tag.new": "Nuovo Tag" +} diff --git a/tagstudio/resources/translations/nb_NO.json b/tagstudio/resources/translations/nb_NO.json new file mode 100644 index 00000000..1d7b93ac --- /dev/null +++ b/tagstudio/resources/translations/nb_NO.json @@ -0,0 +1,102 @@ +{ + "add_field.add": "Legg til felt", + "delete_unlinked.confirm": "Slett følgende %{len(self.lib.missing_files)} oppføringer?", + "delete_unlinked.delete_entries": "Sletting av oppføringer", + "delete_unlinked.deleting_number_entries": "Sletter %{x[0]+1}/{len(self.lib.missing_files)} ulenkede oppføringer", + "dialog.open_create_library": "Åpne/opprett bibliotek", + "dialog.save_library": "Lagre bibliotek", + "file_extension.add_extension": "Legg til utvidelse", + "file_extension.list_mode": "Listemodus:", + "file_opener.not_found": "Fant ikke filen:", + "file_opener.open_file": "Åpner fil :}", + "fix_dupes.fix_dupes": "Fiks duplikatfiler", + "fix_dupes.load_file": "Last inn DupeGuru-fil", + "fix_dupes.mirror_entries": "Speil oppføringer", + "fix_dupes.name_filter": "DupeGuru-filer (*.dupeguru)", + "fix_dupes.no_file_selected": "Ingen DupeGuru-fil valgt", + "fix_dupes.open_result_files": "Åpne DupeGuru-resultatfil", + "fix_unlinked.fix_unlinked": "Fiks ulenkede oppføringer", + "fix_unlinked.scan_library.label": "Skanner bibliotek for ulenkede oppføringer …", + "fix_unlinked.scan_library.title": "Skanning av bibliotek", + "folders_to_tags.folders_to_tags": "Konverterer mapper til etiketter", + "folders_to_tags.title": "Opprett etiketter fra mapper", + "generic.add": "Legg til", + "generic.apply": "Bruk", + "generic.cancel": "Avbryt", + "generic.close_all": "Lukk alle", + "generic.color": "Farge", + "generic.delete": "Slett", + "generic.done": "Ferdig", + "generic.exclude": "Utelat", + "generic.file_extension": "Filutvidelse", + "generic.include": "Inkluder", + "generic.mirror": "Speil", + "generic.name": "Navn", + "generic.open_all": "Åpne alle", + "generic.open_file": "Åpne fil", + "generic.open_file_explorer": "Åpne fil i utforsker", + "generic.refresh_all": "Gjenoppfrisk alle", + "generic.remove_field": "Fjern felt", + "generic.search_tags": "Søk etter etiketter", + "home.main_window": "Hovedvindu", + "home.search": "Søk", + "home.search_entries": "Søk etter oppføringer", + "home.thumbnail_size": "Miniatyrbildestørrelse", + "landing.open_button": "Åpne/opprett bibliotek %{open_shortcut_text}", + "library.Artist": "Artist", + "library.archived": "Arkiveringsdato", + "library.author": "Forfatter", + "library.book": "Bok", + "library.comic": "Tegneserie", + "library.comments": "Kommentarer", + "library.content_tags": "Innholdsetiketter", + "library.date": "Dato", + "library.date_created": "Dato opprettet", + "library.date_modified": "Endringsdato", + "library.date_published": "Publiseringsdato", + "library.date_released": "Slippdato", + "library.date_taken": "Dato knipset", + "library.date_uploaded": "Opplastingsdato", + "library.description": "Beskrivelse", + "library.favorite": "Favoritt", + "library.guest_artist": "Gjesteartist", + "library.magazine": "Magasin", + "library.manga": "Manga", + "library.meta_tags": "Metaetiketter", + "library.notes": "Notater", + "library.publisher": "Utgiver", + "library.series": "Serie", + "library.source": "Kilde", + "library.tags": "Etiketter", + "library.url": "Nettadresse", + "menu.edit": "Rediger", + "menu.file": "Fil", + "menu.help": "Hjelp", + "menu.macros": "Makroer", + "menu.tools": "Verktøy", + "menu.window": "Vindu", + "merge.merge_dupe_entries": "Fletter duplikatoppføringer …", + "merge.window_title": "Fletter duplikatoppføringer …", + "open_library.title": "Bibliotek", + "preview.dimensions": "Dimensjoner", + "preview.recent": "Nylige bibliotek", + "preview_panel.confirm_remove": "Fjern dette «%{self.lib.get_field_attr(field, \"name\")}»-feltet?", + "preview_panel.edit_name": "Rediger", + "preview_panel.missing_location": "Posisjon mangler", + "preview_panel.mixed_data": "Blandet data", + "preview_panel.no_items_selected": "Ingen elementer valgt", + "preview_panel.unknown_field_type": "Ukjent felttype", + "splash.open_library": "Åpner bibliotek …", + "status.backup_success": "Kopi av bibliotek lagret i:", + "status.results_found": "Resultat", + "status.save_success": "Bibliotek lagret og lukket.", + "status.search_library_query": "Søker i biblioteket etter", + "tag.add": "Legg til etikett", + "tag.add_search": "Legg til søk", + "tag.library": "Biblioteksetiketter", + "tag.new": "Ny etikett", + "tag.search_for_tag": "Søk etter etikett", + "text_line_edit.unknown_event_type": "ukjent begivenhetstype: %{event}", + "tooltip.open_library": "Ctrl+O", + "tooltip.save_library": "Ctrl+S" +} diff --git a/tagstudio/resources/translations/pt.json b/tagstudio/resources/translations/pt.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/tagstudio/resources/translations/pt.json @@ -0,0 +1 @@ +{} diff --git a/tagstudio/resources/translations/pt_BR.json b/tagstudio/resources/translations/pt_BR.json new file mode 100644 index 00000000..82482bc9 --- /dev/null +++ b/tagstudio/resources/translations/pt_BR.json @@ -0,0 +1,144 @@ +{ + "add_field.add": "Adicionar Campo", + "build_tags.add_parent_tags": "Adicionar Rótulo Pai", + "build_tags.parent_tags": "Rótulos Pai", + "delete_unlinked.confirm": "Tem certeza que deseja deletar as seguintes %{len(self.lib.missing_files)} entradas?", + "delete_unlinked.delete_entries": "Deletando Entradas", + "delete_unlinked.delete_unlinked": "Deletar Entradas Não Linkada", + "delete_unlinked.deleting_number_entries": "Deletando %{x[0]+1}/{len(self.lib.missing_files)} Entradas Não Linkadas", + "dialog.open_create_library": "Abrir/Criar Biblioteca", + "dialog.refresh_directories": "Atualizando Diretórios", + "dialog.save_library": "Salvar Biblioteca", + "dialog.scan_directories": "Escaneando Diretórios por Novos Arquivos...\nPreparando...", + "dialog.scan_directories.new_files": "Escaneando Diretórios por Novos Arquivos...\n%{x + 1} Arquivo%{\"s\" if x + 1 != 1 else \"\"} Pesquisado%{\"s\" if x + 1 != 1 else \"\"}, %{len(self.lib.files_not_in_library)} Novo(s) Arquivo(s) Encontrado(s)", + "file_extension.add_extension": "Adicionar Extensão", + "file_extension.list_mode": "Modo de Lista:", + "file_opener.command_not_found": "Não foi possível encontrar %{command_name} na PATH do sistema", + "file_opener.not_found": "Arquivo não encontrado:", + "file_opener.open_file": "Abrindo Arquivo:", + "fix_dupes.advice_label": "Após espelhagem, você estará livre para usar DupeGuru para deletar arquivos indesejados. Após, use a função \"Consertar Entradas Não Linkadas\" do TagStudio no menu de Ferramentas para deletar entradas não linkadas.", + "fix_dupes.fix_dupes": "Corrigir Arquivos Duplicados", + "fix_dupes.load_file": "Carregar Aquivo DupeGuru", + "fix_dupes.mirror_description": "Espelhe os ados de entrada em cada conjunto de correspondência duplicado, combinando todos os dados sem remover ou duplicar campos. Esta operação não excluirá nenhum arquivo ou dado.", + "fix_dupes.mirror_entries": "Entradas Espelhadas", + "fix_dupes.name_filter": "Arquivos DupeGuru (*.dupeguru)", + "fix_dupes.no_file_match": "Correspondências de Arquivos Duplicados: N/A", + "fix_dupes.no_file_selected": "Nenhum Arquivo DupeGuru Selecionado", + "fix_dupes.number_file_match": "Correspondências de Arquivos Duplicados: %{count}", + "fix_dupes.open_result_files": "Abrir Arquivo de Resultados do DupeGuru", + "fix_unlinked.delete_unlinked": "Escluir Entradas Não Linkadas", + "fix_unlinked.description": "Cada entrada na biblioteca está linkada a um arquivo em um dos seus diretórios. Se um arquivo linkado a uma entrada for movido ou deletado fora do TagStudio, ele é então considerado não linkado. Entradas não linkadas podem ser automaticamente re-linkadas por buscas nos seus diretórios, manualmente re-linkadas pelo usuário, ou deletada se for desejada.", + "fix_unlinked.duplicate_description": "Entradas duplicadas são definidas como multiplas entradas que levam ao mesmo arquivo no disco. Mergir essas entradas irá combinar as tags e metadados de todas as duplicatas em uma única entrada consolidada. Não confundir com \"Arquivos Duplicados\" que são duplicatas dos seus arquivos fora do TagStudio.", + "fix_unlinked.fix_unlinked": "Corrigir Entradas Não Linkadas", + "fix_unlinked.manual_relink": "Relink Manual", + "fix_unlinked.merge_dupes": "Mesclar Entradas Duplicadas", + "fix_unlinked.refresh_dupes": "Atualizar Entradas Duplicadas", + "fix_unlinked.scan_library.label": "Escaneando Bibliotecada para Entradas Não Linkadas...", + "fix_unlinked.scan_library.title": "Escaneando Biblioteca", + "fix_unlinked.search_and_relink": "Buscar && Relinkar", + "folders_to_tags.description": "Cria rótulos baseado na sua estrutura de arquivos e aplica elas nas suas entradas\nA estrutura abaixo mostra todos os rótulos que irão ser criados e a quais entradas eles serão aplicados.", + "folders_to_tags.folders_to_tags": "Convertendo pastas para Rótulos", + "folders_to_tags.title": "Criar rótulos a partir das pastas", + "generic.add": "Adicionar", + "generic.aliases": "Alias", + "generic.apply": "Aplicar", + "generic.cancel": "Cancelar", + "generic.close_all": "Fechar Tudo", + "generic.color": "Cor", + "generic.delete": "Deletar", + "generic.done": "Completo", + "generic.exclude": "Excluir", + "generic.file_extension": "Extensões de Arquivo", + "generic.include": "Incluir", + "generic.mirror": "Espelho", + "generic.name": "Nome", + "generic.open_all": "Abrir Tudo", + "generic.open_file": "Abrir arquivo", + "generic.open_file_explorer": "Abrir no explorador de arquivos", + "generic.refresh_all": "Atualizar_Tudo", + "generic.remove_field": "Remover Campo", + "generic.search_tags": "Buscar Rótulos", + "generic.shorthand": "Taquigrafia", + "home.base_title": "TagStudio Alfa", + "home.include_all_tags": "E (Inclui todos os rótulos)", + "home.include_any_tag": "Ou (Inclui qualquer rótulo)", + "home.main_window": "Janela Principal", + "home.search": "Buscar", + "home.search_entries": "Buscar Entradas", + "home.thumbnail_size": "Tamanho de miniatura", + "landing.open_button": "Abrir/Criar Bliblioteca %{open_shortcut_text}", + "library.Artist": "Artista", + "library.anthology": "Coletânea", + "library.archived": "Data de Arquivação", + "library.author": "Autor", + "library.book": "Livro", + "library.collation": "Colagem", + "library.comic": "Quadrinho", + "library.comments": "Comentários", + "library.composer": "Compositor", + "library.content_tags": "Rótulos de conteúdo", + "library.date": "Data", + "library.date_created": "Data de Criação", + "library.date_modified": "Dada de Modificação", + "library.date_published": "Data de Publicação", + "library.date_released": "Data de Lançamento", + "library.date_taken": "Data de Criação", + "library.date_uploaded": "Data de Envio", + "library.description": "Descrição", + "library.favorite": "Favorito", + "library.guest_artist": "Artista Convidado", + "library.magazine": "Revista", + "library.manga": "Manga", + "library.meta_tags": "Meta Rótulos", + "library.notes": "Notas", + "library.publisher": "Editora", + "library.series": "Séries", + "library.source": "Fonte", + "library.tags": "Rótulos", + "library.title": "Título", + "library.url": "URL", + "library.volume": "Volume", + "menu.edit": "Editar", + "menu.file": "Arquivo", + "menu.help": "Ajuda", + "menu.macros": "Macros", + "menu.tools": "Ferramentas", + "menu.window": "Janela", + "merge.merge_dupe_entries": "Mesclando Entradas Duplicadas", + "merge.window_title": "Mesclando Entradas Duplicadas", + "mirror_entities.are_you_sure": "Tem certeza que você deseja espelhar os seguintes %{len(self.lib.dupe_files)} entradas?", + "mirror_entities.label": "Espelhando 1/%{count} Entradas...", + "mirror_entities.title": "Espelhando Entradas", + "open_library.library_creation_return_code": "Código de Retorno da Criação da Biblioteca:", + "open_library.no_tagstudio_library_found": "Nenhuma biblioteca do TagStudio existente foi encontrada em '%{path}'. Criando uma.", + "open_library.title": "Biblioteca", + "preview.dimensions": "Dimensões", + "preview.recent": "Bibliotecas recentes", + "preview_panel.confirm_remove": "Você tem certeza de que quer remover o campo \"%{self.lib.get_field_attr(field, \"name\")}\"?", + "preview_panel.edit_name": "Editar", + "preview_panel.missing_location": "Localização Ausente", + "preview_panel.mixed_data": "Dados Mistos", + "preview_panel.no_items_selected": "Nenhum Item Selecionado", + "preview_panel.unknown_field_type": "Tipo de Campo Desconhecido", + "preview_panel.update_widgets": "[ENTRY PANEL] ATULIZAR WIDGETS (%{self.driver.selected})", + "progression.running_macros.new_entries": "Executando Macros nas Novas Entradas", + "progression.running_macros.one_new_entry": "Executando Macros Configurados em 1/%{len(new_ids)} Novas Entradas", + "progression.running_macros.several_new_entry": "Executando Macros Configurados em %{x + 1}/%{len(new_ids)} Novas Entradas", + "relink_unlinked.attempt_relink": "Tentando Relinkar %{x[0]+1}/%{len(self.lib.missing_files)} Entradas, %{self.fixed} Relinkadas com Sucesso", + "relink_unlinked.title": "Relinkando Entradas", + "splash.open_library": "Abrindo Biblioteca", + "status.backup_success": "Backup da Biblioteca Salvo em:", + "status.enumerate_query": "Busca:%{query}, Quadro: %{i}, Tamanho: %{len(f)}", + "status.number_results_found": "%{len(all_items)} Resultados Encontrados para \"%{query}\" (%{format_timespan(end_time - start_time)})", + "status.results_found": "Resultados", + "status.save_success": "Biblioteca Salva e Fechada!", + "status.search_library_query": "Procurando na Biblioteca por", + "tag.add": "Adicionar Rótulo", + "tag.add_search": "Adicionar a Pesquisa", + "tag.library": "Rótulos da biblioteca", + "tag.new": "Novo Rótulo", + "tag.search_for_tag": "Procurar por Rótulo", + "text_line_edit.unknown_event_type": "tipo de evento desconhecido: %{event}", + "tooltip.open_library": "Ctrl+O", + "tooltip.save_library": "Ctrl+S" +} diff --git a/tagstudio/resources/translations/ru.json b/tagstudio/resources/translations/ru.json new file mode 100644 index 00000000..25dc7c39 --- /dev/null +++ b/tagstudio/resources/translations/ru.json @@ -0,0 +1,144 @@ +{ + "add_field.add": "Добавить Категорию", + "build_tags.add_parent_tags": "Добавить Теги-родители", + "build_tags.parent_tags": "Теги-родители", + "delete_unlinked.confirm": "Вы уверены в том, что желаете удалить %{len(self.lib.missing_files)} записи?", + "delete_unlinked.delete_entries": "Удаление записей", + "delete_unlinked.delete_unlinked": "Удалить откреплённые записи", + "delete_unlinked.deleting_number_entries": "Удалено %{x[0]+1}/{len(self.lib.missing_files)} откреплённых записей", + "dialog.open_create_library": "Открыть/Создать Библиотеку", + "dialog.refresh_directories": "Обновление Каталога", + "dialog.save_library": "Сохранить Библиотеку", + "dialog.scan_directories": "Сканирование каталога на наличие новых файлов...\nПодготовка...", + "dialog.scan_directories.new_files": "Сканирование на наличие новых файлов...\n%{x + 1} File%{\"s\" if x + 1 != 1 else \"\"} Searched, %{len(self.lib.files_not_in_library)} Найдены новые файлы", + "file_extension.add_extension": "Добавить расширение", + "file_extension.list_mode": "Список режимов:", + "file_opener.command_not_found": "Не смог найти %{command_name} в системе PATH", + "file_opener.not_found": "Файл не найден:", + "file_opener.open_file": "Открытие файла:}", + "fix_dupes.advice_label": "После отзеркаливания, вы можете использовать DupeGuru, чтобы удалить ненужные файлы. После этого, используйте функцию \"Исправить откреплённые записи\" внутри панели \"Инструменты\" TagStudio, чтобы удалить откреплённые записи.", + "fix_dupes.fix_dupes": "Исправить дубликаты", + "fix_dupes.load_file": "Загрузить файл DupeGuru", + "fix_dupes.mirror_description": "Отзеркалить данные записи внутри каждого указанного набора дубликатов, объединяя все данные внутри без удаления или дублирования категорий. Эта операция не удалит какие-либо файлы или данные.", + "fix_dupes.mirror_entries": "Отзеркалить записи", + "fix_dupes.name_filter": "Файлы DupeGuru (*.dupeguru)", + "fix_dupes.no_file_match": "Совпадение файлов дубликатов: N/A", + "fix_dupes.no_file_selected": "Файл DupeGuru не выбран", + "fix_dupes.number_file_match": "Совпадения файлов дубликатов: %{count}", + "fix_dupes.open_result_files": "Открыть файл результатов DupeGuru", + "fix_unlinked.delete_unlinked": "Удалить откреплённые записи", + "fix_unlinked.description": "Каждая запись в библиотеке привязана к файлу, находящегося внутри той или иной папки. Если файл, к которому была привязана запись, был удалён или перемещён без использования TagStudio, то запись становиться \"откреплённой\". Откреплённые записи могут быть прикреплены обратно через автоматический рескан, вручную прикреплены обратно пользователем, либо же удалены если в них нет надобности.", + "fix_unlinked.duplicate_description": "Записи-дубликаты это несколько записей, которые одновременно привязаны к одному файлу. Объединение таких дубликатов соединит все теги и мета данные из этих записей в одну. Записи-дубликаты не стоит путать с несколькими копиями самого файла, которые могут существовать вне TagStudio.", + "fix_unlinked.fix_unlinked": "Исправить откреплённые записи", + "fix_unlinked.manual_relink": "Ручная привязка", + "fix_unlinked.merge_dupes": "Объединить записи дубликаты", + "fix_unlinked.refresh_dupes": "Обновить записи дубликаты", + "fix_unlinked.scan_library.label": "Сканирование Библиотеки на наличие откреплённых записей...", + "fix_unlinked.scan_library.title": "Сканирование Библиотеки", + "fix_unlinked.search_and_relink": "Поиск и Привязка", + "folders_to_tags.description": "Создаёт теги для записей согласно имеющейся иерархии папок.\nВнизу указаны все теги, которые будут созданы, а также записи к которым они будут применены.", + "folders_to_tags.folders_to_tags": "Конвертировать папки в теги", + "folders_to_tags.title": "Создать теги из папок", + "generic.add": "Добавить", + "generic.aliases": "Псевдонимы", + "generic.apply": "Применить", + "generic.cancel": "Отмена", + "generic.close_all": "Закрыть Всё", + "generic.color": "Цвет", + "generic.delete": "Удалить", + "generic.done": "Завершено", + "generic.exclude": "Исключить", + "generic.file_extension": "Расширения Файлов", + "generic.include": "Включить", + "generic.mirror": "Отзеркалить", + "generic.name": "Имя", + "generic.open_all": "Открыть Всё", + "generic.open_file": "Открыть файл", + "generic.open_file_explorer": "Открыть файл в проводнике", + "generic.refresh_all": "Обновить Всё", + "generic.remove_field": "Удалить Категорию", + "generic.search_tags": "Поиск тегов", + "generic.shorthand": "Сокращённое название", + "home.base_title": "TagStudio Альфа Версия", + "home.include_all_tags": "И (Файл содержит все теги)", + "home.include_any_tag": "Или (Файл содержит любой из тегов)", + "home.main_window": "Основное Окно", + "home.search": "Поиск", + "home.search_entries": "Поисковые запросы", + "home.thumbnail_size": "Размер иконок", + "landing.open_button": "Открыть/Создать Библиотеку %{open_shortcut_text}", + "library.Artist": "Художник", + "library.anthology": "Антология", + "library.archived": "Дата архивации", + "library.author": "Автор", + "library.book": "Книги", + "library.collation": "Сопоставление", + "library.comic": "Комиксы", + "library.comments": "Комментарии", + "library.composer": "Композитор", + "library.content_tags": "Теги содержимого", + "library.date": "Дата", + "library.date_created": "Дата создания", + "library.date_modified": "Дата изменения", + "library.date_published": "Дата публикации", + "library.date_released": "Дата выпуска", + "library.date_taken": "Дата съёмки", + "library.date_uploaded": "Дата загрузки", + "library.description": "Описание", + "library.favorite": "Избранное", + "library.guest_artist": "Соавтор", + "library.magazine": "Журнал", + "library.manga": "Манга", + "library.meta_tags": "Мета Теги", + "library.notes": "Заметки", + "library.publisher": "Издатель", + "library.series": "Серии", + "library.source": "Источники", + "library.tags": "Теги", + "library.title": "Название", + "library.url": "URL", + "library.volume": "Том", + "menu.edit": "Редактировать", + "menu.file": "Файл", + "menu.help": "Помощь", + "menu.macros": "Макросы", + "menu.tools": "Инструменты", + "menu.window": "Окно", + "merge.merge_dupe_entries": "Объединить повторяющиеся записи", + "merge.window_title": "Объединить повторяющиеся записи", + "mirror_entities.are_you_sure": "Вы уверенны, что хотите отзеркалить следующие %{len(self.lib.dupe_files)} записи?", + "mirror_entities.label": "Отзеркаливание 1/%{count} записей...", + "mirror_entities.title": "Отзеркаливание записей", + "open_library.library_creation_return_code": "Возвратный код создания библиотеки:", + "open_library.no_tagstudio_library_found": "Существующая библиотека TagStudio не найдена внутри '%{path}'. Создаём новую.", + "open_library.title": "Библиотека", + "preview.dimensions": "Соотношения сторон", + "preview.recent": "Недавние библиотеки", + "preview_panel.confirm_remove": "Вы уверенны, что хотите удалить эту категорию \"%{self.lib.get_field_attr(field, \"name\")}\"?", + "preview_panel.edit_name": "Редактировать", + "preview_panel.missing_location": "Путь не найден", + "preview_panel.mixed_data": "Смешанные данные", + "preview_panel.no_items_selected": "Ничего не выбрано", + "preview_panel.unknown_field_type": "Неизвестный тип категории", + "preview_panel.update_widgets": "[ENTRY PANEL] ОБНОВИТЬ ВИДЖЕТЫ (%{self.driver.selected})", + "progression.running_macros.new_entries": "Использование макросов на новых файлах", + "progression.running_macros.one_new_entry": "Запуск сконфигурированных макросов в 1/%{len(new_ids)} Новые файлы", + "progression.running_macros.several_new_entry": "Запуск сконфигурированных макросов в %{x + 1}/%{len(new_ids)} Новые файлы", + "relink_unlinked.attempt_relink": "Пытаемся вновь привязать %{x[0]+1}/%{len(self.lib.missing_files)} записей, %{self.fixed} Успешно привязано", + "relink_unlinked.title": "Привязка записей", + "splash.open_library": "Открытие Библиотеки", + "status.backup_success": "Резервная копия библиотеки сохранена по адресу:", + "status.enumerate_query": "Query:%{query}, Frame: %{i}, Length: %{len(f)}", + "status.number_results_found": "%{len(all_items)} Было найдено \"%{query}\" (%{format_timespan(end_time - start_time)})", + "status.results_found": "Результаты", + "status.save_success": "Библиотека сохранена и закрыта!", + "status.search_library_query": "Поиск по библиотеке", + "tag.add": "Добавить тег", + "tag.add_search": "Добавить к поисковому запросу", + "tag.library": "Библиотека тегов", + "tag.new": "Новый тег", + "tag.search_for_tag": "Поиск тега", + "text_line_edit.unknown_event_type": "unknown event type: %{event}", + "tooltip.open_library": "Ctrl+O", + "tooltip.save_library": "Ctrl+S" +} diff --git a/tagstudio/resources/translations/sv.json b/tagstudio/resources/translations/sv.json new file mode 100644 index 00000000..3f4457a6 --- /dev/null +++ b/tagstudio/resources/translations/sv.json @@ -0,0 +1,114 @@ +{ + "add_field.add": "Lägg till fält", + "build_tags.add_parent_tags": "Lägg till förälderetikett", + "build_tags.parent_tags": "Förälderetiketter", + "delete_unlinked.delete_entries": "Tar bort poster", + "dialog.open_create_library": "Öppna/skapa bibliotek", + "dialog.refresh_directories": "Uppdaterar kataloger", + "dialog.save_library": "Spara bibliotek", + "dialog.scan_directories": "Skannar kataloger efter nya filer...\nFörbereder...", + "file_extension.add_extension": "Lägg till tillägg", + "file_opener.not_found": "Kunde inte hitta filen:", + "file_opener.open_file": "Öppnar fil:}", + "fix_dupes.fix_dupes": "Fixa dubbla filer", + "fix_dupes.mirror_entries": "Spegla poster", + "fix_unlinked.delete_unlinked": "Ta bort olänkade poster", + "fix_unlinked.description": "Varje post i biblioteket är länkad till en fil i en av dina kataloger. Om en fil länkad till en post är flyttad eller borttagen utanför TagStudio blir den olänkad. Olänkade poster kan automatiskt bli omlänkade genom att söka genom dina kataloger, manuellt omlänkade av användaren eller tas bort om så önskas.", + "fix_unlinked.duplicate_description": "Dubbla poster är definierade som flera poster som pekar på samma fil på datorn. Genom att slå ihop dessa poster kommer deras etiketter och metadata från dubbletterna att kombineras till en post. Dessa ska inte förväxlas med \"dubbla filer\", som är dubbletter av dina filer utanför TagStudio.", + "fix_unlinked.fix_unlinked": "Fixa olänkade poster", + "fix_unlinked.manual_relink": "Länka om manuellt", + "fix_unlinked.merge_dupes": "Slå ihop dubbla poster", + "fix_unlinked.refresh_dupes": "Uppdatera dubbla poster", + "fix_unlinked.scan_library.label": "Skannar bibliotek efter olänkade poster...", + "fix_unlinked.scan_library.title": "Skannar bibliotek", + "fix_unlinked.search_and_relink": "Sök && Länka om", + "folders_to_tags.description": "Skapar etiketter baserat på din mappstruktur och tillämpar dem till dina poster.\nStrukturen nedan visar vilka etiketter som kommer skapas och vilka filer de kommer tillämpas på.", + "folders_to_tags.folders_to_tags": "Konverterar mappar till etiketter", + "folders_to_tags.title": "Skapa etiketter från mappar", + "generic.add": "Lägg till", + "generic.aliases": "Alias", + "generic.apply": "Tillämpa", + "generic.cancel": "Avbryt", + "generic.close_all": "Stäng alla", + "generic.color": "Färg", + "generic.delete": "Ta bort", + "generic.done": "Klar", + "generic.exclude": "Exkludera", + "generic.file_extension": "Filnamnstillägg", + "generic.include": "Inkludera", + "generic.mirror": "Spegla", + "generic.name": "Namn", + "generic.open_all": "Öppna alla", + "generic.open_file": "Öppna fil", + "generic.open_file_explorer": "Öppna fil i utforskaren", + "generic.refresh_all": "Uppdatera alla", + "generic.remove_field": "Ta bort fält", + "generic.search_tags": "Sök etikett", + "generic.shorthand": "Förkortning", + "home.base_title": "TagStudio Alfa", + "home.include_all_tags": "Och (Inkluderar alla etiketter)", + "home.include_any_tag": "Eller (Inkluderar alla etiketter)", + "home.main_window": "Huvudfönster", + "home.search": "Sök", + "home.search_entries": "Sök poster", + "home.thumbnail_size": "Miniatyrbildsstorlek", + "library.Artist": "Artist", + "library.anthology": "Antologi", + "library.archived": "Arkiveringsdatum", + "library.author": "Författare", + "library.book": "Bok", + "library.collation": "Kollation", + "library.comic": "Serietidning", + "library.comments": "Kommentarer", + "library.composer": "Kompositör", + "library.content_tags": "Innehållsetiketter", + "library.date": "Datum", + "library.date_created": "Skapad den", + "library.date_modified": "Senast ändrad", + "library.date_published": "Publiceringsdatum", + "library.date_released": "Utgivningsdatum", + "library.date_uploaded": "Uppladdningsdatum", + "library.description": "Beskrivning", + "library.favorite": "Favorit", + "library.guest_artist": "Gästartist", + "library.magazine": "Magasin", + "library.manga": "Manga", + "library.meta_tags": "Meta-etiketter", + "library.notes": "Anteckningar", + "library.publisher": "Utgivare", + "library.series": "Serie", + "library.source": "Källa", + "library.tags": "Etiketter", + "library.title": "Titel", + "library.url": "URL", + "library.volume": "Volym", + "menu.edit": "Redigera", + "menu.file": "Fil", + "menu.help": "Hjälp", + "menu.macros": "Makron", + "menu.tools": "Verktyg", + "menu.window": "Fönster", + "merge.merge_dupe_entries": "Slår ihop dubbla poster", + "merge.window_title": "Slår ihop dubbla poster", + "mirror_entities.title": "Speglar poster", + "open_library.title": "Bibliotek", + "preview.dimensions": "Dimensioner", + "preview.recent": "Senaste biblioteken", + "preview_panel.edit_name": "Redigera", + "preview_panel.missing_location": "Platsen saknas", + "preview_panel.no_items_selected": "Inga valda objekt", + "progression.running_macros.new_entries": "Kör makros på nya poster", + "relink_unlinked.title": "Länkar om poster", + "splash.open_library": "Öppnar bibliotek", + "status.backup_success": "Bibliotekets säkerhetskopia sparad i:", + "status.results_found": "Resultat", + "status.save_success": "Bibliotek sparat och stängt!", + "status.search_library_query": "Söker i biblioteket efter", + "tag.add": "Lägg till etikett", + "tag.add_search": "Lägg till i Sök", + "tag.library": "Biblioteksetiketter", + "tag.new": "Ny etikett", + "tag.search_for_tag": "Sök efter etikett", + "tooltip.open_library": "Ctrl+O", + "tooltip.save_library": "Ctrl+S" +} diff --git a/tagstudio/resources/translations/ta.json b/tagstudio/resources/translations/ta.json new file mode 100644 index 00000000..e1be874d --- /dev/null +++ b/tagstudio/resources/translations/ta.json @@ -0,0 +1,144 @@ +{ + "add_field.add": "புலத்தைச் சேர்க்க", + "build_tags.add_parent_tags": "பெற்றோர் குறிச்சொற்களைச் சேர்க்க", + "build_tags.parent_tags": "பெற்றோர் குறிச்சொற்கள்", + "delete_unlinked.confirm": "பின்வரும் உள்ளீடுகளை நிச்சயமாக நீக்க விரும்புகிறீர்களா %{len(self.lib.missing_files)}?", + "delete_unlinked.delete_entries": "உள்ளீடுகள் நீக்கப்படுகிறது", + "delete_unlinked.delete_unlinked": "இணைக்கப்படாத உள்ளீடுகளை நீக்கு", + "delete_unlinked.deleting_number_entries": "%{x[0]+1}/{len(self.lib.missing_files)} இணைக்கப்படாத உள்ளீடுகள் நீக்கப்படுகிறது", + "dialog.open_create_library": "நூலகத்தைத் திற/உருவாக்கு", + "dialog.refresh_directories": "கோப்பகங்கள் புதுப்பிக்கப்படுகின்றன", + "dialog.save_library": "நூலகத்தைச் சேமி", + "dialog.scan_directories": "புதிய கோப்புகளுக்கான அடைவுகள் சோதனை செய்யப்படுகின்றது...\nதயாராகிறது...", + "dialog.scan_directories.new_files": "புதிய கோப்புகளுக்கான அடைவுகள் சோதனை செய்யப்படுகின்றது...\n%{x + 1} கோப்பு%{\"s\" if x + 1 != 1 else \"\"} தேடப்பட்டது, %{len(self.lib.files_not_in_library)} புதிய கோப்புகள் கிடைத்தன", + "file_extension.add_extension": "நீட்டிப்பைச் சேர்க்க", + "file_extension.list_mode": "பட்டியல் முறை:", + "file_opener.command_not_found": "%{command_name} ஐ system PATH இல் கண்டுபிடிக்க முடியவில்லை", + "file_opener.not_found": "கோப்பு கிடைக்கவில்லை:", + "file_opener.open_file": "கோப்பைத் திறக்கிறது:}", + "fix_dupes.advice_label": "படிமம் முடிந்தவுடன், தேவையற்ற கோப்புகளை நீக்க DupeGuru ஐ பயன்படுத்தலாம். அதற்குப் பிறகு, இணைக்காத நுழைவுகளை நீக்க 'டாக் ஸ்டுடியோ' வின் 'இணைக்கப்படாத உள்ளீடுகளைச் சரிசெய்' அம்சத்தைக் கருவிகள் பட்டியில் பயன்படுத்தவும்.", + "fix_dupes.fix_dupes": "நகல் கோப்புகளைச் சரிசெய்", + "fix_dupes.load_file": "DupeGuru கோப்பை ஏற்றவும்", + "fix_dupes.mirror_description": "ஒவ்வொரு மறுநுழைவு பொருத்தத் தொகுப்பிலும் நுழைவு தரவுகளைப் பிரதிபலிக்கவும், அனைத்து தரவுகளையும் இணைக்கவும், எந்தத் தகவல்களையும் நீக்காமலும் மறு செய்யாமலும். இந்தச் செயலில் எந்தக் கோப்புகள் அல்லது தரவுகளும் நீக்கப்பட மாட்டாது.", + "fix_dupes.mirror_entries": "படிம நுழைவுகள்", + "fix_dupes.name_filter": "DupeGuru கோப்புகள் (*.dupeguru)", + "fix_dupes.no_file_match": "நகல் கோப்பு பொருத்தங்கள்: ஒன்றும் இல்லை", + "fix_dupes.no_file_selected": "DupeGuru கோப்பு எதுவும் தேர்ந்தெடுக்கப்படவில்லை", + "fix_dupes.number_file_match": "நகல் கோப்பு பொருத்தங்கள்: %{count}", + "fix_dupes.open_result_files": "DupeGuru முடிவுகள் கோப்பைத் திறக்க", + "fix_unlinked.delete_unlinked": "இணைக்கப்படாத உள்ளீடுகளை நீக்கு", + "fix_unlinked.description": "ஒவ்வொரு புத்தககல்லரி நுழைவும் உங்கள் அடைவுகளில் உள்ள ஒரு கோப்புடன் இணைக்கப்பட்டுள்ளது. டாக் ஸ்டுடியோ-வைத் தவிர கோப்புகள் நகர்த்தப்பட்டால் அல்லது நீக்கப்பட்டால், அவை இணைக்கப்படாதவையாகக் கருதப்படும். இணைக்கப்படாத நுழைவுகளை உங்கள் அடைவுகளில் தேடுவதன் மூலம் தானாகவே மீண்டும் இணைக்கலாம், பயனர் கைமுறையாக இணைக்கலாம் அல்லது விருப்பப்படி நீக்கலாம்.", + "fix_unlinked.duplicate_description": "மறுநுழைவுகள் என்பது, ஒரே கோப்பை குறிக்கும் பல நுழைவுகளை குறிக்கும். இவற்றை இணைப்பதால், அனைத்து மறுநுழைவுகளின் குறிச்சொற்களும் மெட்டாடேட்டாவும் ஒரே ஒட்டுமொத்த நுழைவாகச் சேர்க்கப்படும். இவற்றை 'மறுகோப்புகள்' என்பதுடன் குழப்பக் கூடாது, ஏனெனில் அவை டாக் ஸ்டுடியோவுக்கு வெளியேயுள்ள கோப்புகளின் நகல்களாகும்.", + "fix_unlinked.fix_unlinked": "இணைக்கப்படாத உள்ளீடுகளைச் சரிசெய்யவும்", + "fix_unlinked.manual_relink": "கைமுறை மறு இணைப்பு", + "fix_unlinked.merge_dupes": "நகல் உள்ளீடுகளை ஒன்றிணை", + "fix_unlinked.refresh_dupes": "நகல் உள்ளீடுகளைப் புதுப்பி", + "fix_unlinked.scan_library.label": "இணைக்கப்படாத நுழைவுகளை புத்தககல்லரியில் சோதனை செய்யப்படுகிறது...", + "fix_unlinked.scan_library.title": "புத்தககல்லரி சோதனை செய்யப்படுகிறது", + "fix_unlinked.search_and_relink": "தேடல் && மீண்டும் இணை", + "folders_to_tags.description": "உங்கள் அடைவு கட்டமைப்பின் அடிப்படையில் குறிச்சொற்களை உருவாக்கி, அவற்றை உங்கள் நுழைவுகளில் பயன்படுத்துகிறது.\nகீழே காணப்படும் கட்டமைப்பானது உருவாக்கப்படும் அனைத்து குறிச்சொற்களையும், அவை எந்த நுழைவுகளில் பயன்படுத்தப்படும் என்பதையும் காட்டுகிறது.", + "folders_to_tags.folders_to_tags": "கோப்புறைகளை குறிச்சொற்களாக மாற்றப்படுகிறது", + "folders_to_tags.title": "கோப்புறைகளிலிருந்து குறிச்சொற்களை உருவாக்கு", + "generic.add": "சேர்", + "generic.aliases": "மாற்றுப்பெயர்கள்", + "generic.apply": "விண்ணப்பிக்க", + "generic.cancel": "ரத்து செய்", + "generic.close_all": "அனைத்தையும் மூடு", + "generic.color": "நிறம்", + "generic.delete": "நீக்கு", + "generic.done": "முடிந்தது", + "generic.exclude": "தவிர்", + "generic.file_extension": "கோப்பு நீட்டிப்புகள்", + "generic.include": "உள்ளடக்கு", + "generic.mirror": "படிமம்", + "generic.name": "பெயர்", + "generic.open_all": "அனைத்தையும் திற", + "generic.open_file": "கோப்பைத் திறக்கவும்", + "generic.open_file_explorer": "Explorer இல் கோப்பைத் திறக்கவும்", + "generic.refresh_all": "அனைத்தையும் புதுப்பி", + "generic.remove_field": "புலத்தை அகற்று", + "generic.search_tags": "குறிச்சொற்களைத் தேடு", + "generic.shorthand": "சுருக்கெழுத்து", + "home.base_title": "TagStudio ஆல்பா", + "home.include_all_tags": "மற்றும் (அனைத்து குறிச்சொற்களையும் உள்ளடக்கியது)", + "home.include_any_tag": "அல்லது (எந்தக் குறிச்சொல்லையும் உள்ளடக்கியது)", + "home.main_window": "பிரதான சாளரம்", + "home.search": "தேடு", + "home.search_entries": "தேடல் உள்ளீடுகள்", + "home.thumbnail_size": "சின்னப்பட அளவு", + "landing.open_button": "நூலகத்தைத் திற/உருவாக்கு %{open_shortcut_text}", + "library.Artist": "கலைஞர்", + "library.anthology": "தொகுப்பியல்", + "library.archived": "காப்பகப்படுத்தப்பட்ட தேதி", + "library.author": "ஆக்கியோன்", + "library.book": "புத்தகம்", + "library.collation": "தொகுத்தல்", + "library.comic": "நகைச்சுவை", + "library.comments": "கருத்துகள்", + "library.composer": "இசையமைப்பாளர்", + "library.content_tags": "உள்ளடக்கக் குறிச்சொற்கள்", + "library.date": "தேதி", + "library.date_created": "உருவாக்கப்பட்ட தேதி", + "library.date_modified": "மாற்றப்பட்ட தேதி", + "library.date_published": "வெளியிடப்பட்ட தேதி", + "library.date_released": "வெளியான தேதி", + "library.date_taken": "எடுக்கப்பட்ட தேதி", + "library.date_uploaded": "பதிவேற்றிய தேதி", + "library.description": "விளக்கம்", + "library.favorite": "பிடித்தது", + "library.guest_artist": "விருந்தினர் கலைஞர்", + "library.magazine": "இதழ்", + "library.manga": "மங்கா", + "library.meta_tags": "மெட்டா குறிச்சொற்கள்", + "library.notes": "குறிப்புகள்", + "library.publisher": "பதிப்பாளர்", + "library.series": "தொடர்", + "library.source": "ஆதாரம்", + "library.tags": "குறிச்சொற்கள்", + "library.title": "தலைப்பு", + "library.url": "இணைய முகவரி", + "library.volume": "தொகுப்பு", + "menu.edit": "திருத்து", + "menu.file": "கோப்பு", + "menu.help": "உதவி", + "menu.macros": "செயல்முறை", + "menu.tools": "கருவிகள்", + "menu.window": "சாளரம்", + "merge.merge_dupe_entries": "மறுநுழைவுகளை ஒன்றுசேர்த்தல்", + "merge.window_title": "மறுநுழைவுகளை ஒன்றுசேர்த்தல்", + "mirror_entities.are_you_sure": "பின்வரும் உள்ளீடுகளைப் பிரதிபலிக்க விரும்புகிறீர்களா %{len(self.lib.dupe_files)}?", + "mirror_entities.label": "1/%{count} உள்ளீடுகளைப் பிரதிபலிக்கப்படுகின்றது...", + "mirror_entities.title": "உள்ளீடுகள் பிரதிபழிக்கப்படுகின்றது", + "open_library.library_creation_return_code": "நூலக உருவாக்கம் திரும்பக் குறியீடு:", + "open_library.no_tagstudio_library_found": "'%{path}' இல் ஏற்கனவே உள்ள டாக் ஸ்டுடியோ புத்தககல்லரி காணப்படவில்லை. ஒன்று உருவாக்கப்படுகிறது.", + "open_library.title": "நூலகம்", + "preview.dimensions": "பரிமாணங்கள்", + "preview.recent": "சமீபத்திய நூலகங்கள்", + "preview_panel.confirm_remove": "இந்த \"%{self.lib.get_field_attr(field, \"name\")}\" புலத்தை நிச்சயமாக அகற்ற விரும்புகிறீர்களா?", + "preview_panel.edit_name": "திருத்து", + "preview_panel.missing_location": "இடம் காணவில்லை", + "preview_panel.mixed_data": "கலப்பு தரவு", + "preview_panel.no_items_selected": "உருப்படிகள் எதுவும் தேர்ந்தெடுக்கப்படவில்லை", + "preview_panel.unknown_field_type": "அறியப்படாத புல வகை", + "preview_panel.update_widgets": "[நுழைவு குழு] விட்ஜெட்டுகளை புதுப்பிக்கவும் (%{self.driver.selected})", + "progression.running_macros.new_entries": "புதிய நுழைவுகளில் செயல்முறைகளை இயக்கப்படுகின்றது", + "progression.running_macros.one_new_entry": "1/ இல் கட்டமைக்கப்பட்ட செயல்முறைகளை இயக்கப்படுகிறது %{len(new_ids)} புதிய பதிவுகள்", + "progression.running_macros.several_new_entry": "%{x + 1} இல் கட்டமைக்கப்பட்ட செயல்முறைகளை இயக்கப்படுகிறது / %{len(new_ids)} புதிய பதிவுகள்", + "relink_unlinked.attempt_relink": "%{x[0]+1}/%{len(self.lib.missing_files)} உள்ளீடுகளை மீண்டும் இணைக்க முயற்சிக்கிறது, %{self.fixed} மீண்டும் இணைக்கப்பட்டது", + "relink_unlinked.title": "உள்ளீடுகள் மீண்டும் இணைக்கப்படுகின்றது", + "splash.open_library": "நூலகம் திறக்கப்படுகின்றது", + "status.backup_success": "நூலக காப்புப் பிரதி சேமிக்கப்பட்டது:", + "status.enumerate_query": "வினவு:%{query}, சட்டகம்: %{i}, நீளம்: %{len(f)}", + "status.number_results_found": "\"%{query}\" இல் %{len(all_items)} முடிவுகள் கிடைத்தன (%{format_timespan(end_time - start_time)})", + "status.results_found": "முடிவுகள்", + "status.save_success": "நூலகம் சேமிக்கப்பட்டு மூடப்பட்டது!", + "status.search_library_query": "நூலகத்தைத் தேடுகிறது", + "tag.add": "குறிச்சொல் சேர்க்க", + "tag.add_search": "தேடலில் சேர்", + "tag.library": "நூலக குறிச்சொற்கள்", + "tag.new": "புதிய குறிச்சொல்", + "tag.search_for_tag": "குறிச்சொல்லைத் தேடு", + "text_line_edit.unknown_event_type": "அறியப்படாத நிகழ்வு வகை: %{event}", + "tooltip.open_library": "Ctrl+O", + "tooltip.save_library": "Ctrl+S" +} diff --git a/tagstudio/resources/translations/tok.json b/tagstudio/resources/translations/tok.json new file mode 100644 index 00000000..3832c5a5 --- /dev/null +++ b/tagstudio/resources/translations/tok.json @@ -0,0 +1,144 @@ +{ + "add_field.add": "pana e sona", + "build_tags.add_parent_tags": "o pana e poki mama", + "build_tags.parent_tags": "poki mama", + "delete_unlinked.confirm": "mi weka e ijo %{len(self.lib.missing_files)}. ni li pona anu seme?", + "delete_unlinked.delete_entries": "mi weka e ijo", + "delete_unlinked.delete_unlinked": "o weka e ijo pi ijo lon ala", + "delete_unlinked.deleting_number_entries": "mi weka e ijo %{x[0]+1}/{len(self.lib.missing_files)} pi ijo lon ala", + "dialog.open_create_library": "open/sin e tomo", + "dialog.refresh_directories": "mi kama jo e sin lon tomo", + "dialog.save_library": "awen e tomo", + "dialog.scan_directories": "mi alasa e ijo sin lon tomo...\nmi kama pona...", + "dialog.scan_directories.new_files": "mi alasa e ijo sin lon tomo...\nmi lukin e ijo %{x + 1}. ijo %{len(self.lib.files_not_in_library)} li sin", + "file_extension.add_extension": "o pana e nimi anpa", + "file_extension.list_mode": "nasin kulupu:", + "file_opener.command_not_found": "mi ken ala alasa e toki lawa '%{command_name}' lon PATH", + "file_opener.not_found": "mi ken ala alasa e ijo:", + "file_opener.open_file": "mi open e ijo:}", + "fix_dupes.advice_label": "jasima li pini la, sina ken kepeken ilo DupeGuru. ilo DupeGuru li ken weka e ijo ike. ni li pini la, o kepeken e nasin \"o pona e ijo pi ijo lon ala\" lon ilo TagStudio. ni li weka e ijo pi ijo lon ala.", + "fix_dupes.fix_dupes": "pona e ijo sama", + "fix_dupes.load_file": "o kama sona e ijo DupeGuru", + "fix_dupes.mirror_description": "o jasima e sona kama lon kulupu sama ale. sona ale li kama wan li weka ala li kama mute ala. ni li weka ala e sona e ijo.", + "fix_dupes.mirror_entries": "o jasima e ijo", + "fix_dupes.name_filter": "ijo DupeGuru (*.dupeguru)", + "fix_dupes.no_file_match": "ijo sama: ala", + "fix_dupes.no_file_selected": "sina o anu e ijo DupeGuru", + "fix_dupes.number_file_match": "ijo sama: %{count}", + "fix_dupes.open_result_files": "o open e sona pini tan ilo DupeGuru", + "fix_unlinked.delete_unlinked": "o weka e ijo pi ijo lon ala", + "fix_unlinked.description": "ijo ale li jo e ijo lon. ona li tawa anu weka, ona li jo ala e ijo lon. ijo pi ijo lon li ken alasa e tomo li ken kama jo e ijo lon. ante la sina ken pana ijo lon tawa ijo. ante la sina ken weka e ijo.", + "fix_unlinked.duplicate_description": "ken la, ijo mute li jo e ijo lon sama. ni li \"ijo sama\". sina wan e ona la, ijo sama li kama wan li jo e sona ale tan ijo sama ale.", + "fix_unlinked.fix_unlinked": "o pona e ijo pi ijo lon ala", + "fix_unlinked.manual_relink": "sina o pana e ijo lon tawa ijo", + "fix_unlinked.merge_dupes": "o kama wan e ijo sama", + "fix_unlinked.refresh_dupes": "o kama jo e sona tan ijo sama", + "fix_unlinked.scan_library.label": "mi o alasa e ijo pi ijo lon ala...", + "fix_unlinked.scan_library.title": "mi o lukin e tomo", + "fix_unlinked.search_and_relink": "o alasa o pana e ijo lon tawa ijo", + "folders_to_tags.description": "ni li pali e poki tan poki tona li pana e poki sin tawa ijo lon tomo.\n ilo ni li pali e anpa.", + "folders_to_tags.folders_to_tags": "mi o pali e poki tan poki tomo", + "folders_to_tags.title": "o pali e poki tan poki tomo", + "generic.add": "o pana", + "generic.aliases": "nimi ante", + "generic.apply": "o pana", + "generic.cancel": "o ala", + "generic.close_all": "weka e ale", + "generic.color": "kule", + "generic.delete": "o weka", + "generic.done": "pona", + "generic.exclude": "o kepeken ala", + "generic.file_extension": "nimi lon nimi ijo anpa", + "generic.include": "o kepeken", + "generic.mirror": "jasima", + "generic.name": "nimi", + "generic.open_all": "open e ale", + "generic.open_file": "open e ijo", + "generic.open_file_explorer": "open e ijo lon ilo alasa", + "generic.refresh_all": "o kama jo sin tan ale", + "generic.remove_field": "weka e sona", + "generic.search_tags": "o alasa e poki", + "generic.shorthand": "nimi lili", + "home.base_title": "ilo TagStudio pi pini ala", + "home.include_all_tags": "AND (kepeken poki ale)", + "home.include_any_tag": "OR (kepeken poki wan. poki ale li ken)", + "home.main_window": "lipu suli", + "home.search": "alasa", + "home.search_entries": "ijo alasa", + "home.thumbnail_size": "suli sitelen", + "landing.open_button": "open/sin e tomo %{open_shortcut_text}", + "library.Artist": "jan pali sitelen", + "library.anthology": "kulupu toki", + "library.archived": "tenpo pi kama awen", + "library.author": "jan pali toki", + "library.book": "lipu toki", + "library.collation": "poki ijo", + "library.comic": "lipu sitelen", + "library.comments": "toki isipin", + "library.composer": "jan pali pi kalama musi", + "library.content_tags": "poki pi jo ijo", + "library.date": "tenpo", + "library.date_created": "tenpo pi kama sin", + "library.date_modified": "tenpo pi kama ante", + "library.date_published": "tenpo pana", + "library.date_released": "tenpo pi kama open", + "library.date_taken": "tenpo lanpan", + "library.date_uploaded": "tenpo pi kama lon lipu", + "library.description": "toki ni", + "library.favorite": "pona mute", + "library.guest_artist": "jan pali namako", + "library.magazine": "lipu sitelen toki", + "library.manga": "kulupu pi lipu sitelen", + "library.meta_tags": "poki pi sona ijo", + "library.notes": "toki sina", + "library.publisher": "jan esun", + "library.series": "kulupu", + "library.source": "tan", + "library.tags": "poki", + "library.title": "nimi", + "library.url": "nimi linluwi", + "library.volume": "nanpa", + "menu.edit": "ante", + "menu.file": "ijo", + "menu.help": "mi jo e toki seme", + "menu.macros": "ilo pali", + "menu.tools": "ilo", + "menu.window": "lipu", + "merge.merge_dupe_entries": "ijo sama li kama wan", + "merge.window_title": "ijo sama li kama wan", + "mirror_entities.are_you_sure": "mi jasima e ijo %{len(self.lib.dupe_files)}. ni li pona anu seme?", + "mirror_entities.label": "mi jasima e ijo 1/%{count}...", + "mirror_entities.title": "mi jasima e ijo", + "open_library.library_creation_return_code": "tomo li open li toki e ni:", + "open_library.no_tagstudio_library_found": "tomo lon '%{path}' li lon ala. mi pali e tomo.", + "open_library.title": "tomo", + "preview.dimensions": "suli", + "preview.recent": "tomo pi tenpo poka", + "preview_panel.confirm_remove": "sina weka e sona poki \"%{self.lib.get_field_attr(field, \"name\")}\". ni li pona anu seme?", + "preview_panel.edit_name": "ante", + "preview_panel.missing_location": "ma li lon ala", + "preview_panel.mixed_data": "sona ante", + "preview_panel.no_items_selected": "ijo ala li anu", + "preview_panel.unknown_field_type": "mi sona ala e kule pi sona poki", + "preview_panel.update_widgets": "[LIPU OPEN] O SIN E LIPU LILI (%{self.driver.selected})", + "progression.running_macros.new_entries": "mi pali lon ijo sin", + "progression.running_macros.one_new_entry": "mi pali lon ijo sin 1/%{len(new_ids)}", + "progression.running_macros.several_new_entry": "mi pali lon ijo sin %{x + 1}/%{len(new_ids)}", + "relink_unlinked.attempt_relink": "mi o pana e ijo lon tawa ijo %{x[0]+1}/%{len(self.lib.missing_files)}. mi pana e ijo lon tawa ijo %{self.fixed}", + "relink_unlinked.title": "mi pana e ijo lon tawa ijo", + "splash.open_library": "mi open e tomo", + "status.backup_success": "tomo sama li lon:", + "status.enumerate_query": "seme: %{query}. sitelen: %{i}. nanpa: %{len(f)}", + "status.number_results_found": "%{len(all_items)} mi jo e ijo \"%{query}\" (%{format_timespan(end_time - start_time)})", + "status.results_found": "jo", + "status.save_success": "tomo li awen li weka!", + "status.search_library_query": "mi alasa e", + "tag.add": "o pana e poki", + "tag.add_search": "pana tawa alasa", + "tag.library": "poki tomo", + "tag.new": "poki sin", + "tag.search_for_tag": "o alasa e poki", + "text_line_edit.unknown_event_type": "mi sona ala e ni: %{event}", + "tooltip.open_library": "Ctrl+O", + "tooltip.save_library": "Ctrl+S" +} diff --git a/tagstudio/resources/translations/tr.json b/tagstudio/resources/translations/tr.json new file mode 100644 index 00000000..92c149b7 --- /dev/null +++ b/tagstudio/resources/translations/tr.json @@ -0,0 +1,144 @@ +{ + "add_field.add": "Alan Ekle", + "build_tags.add_parent_tags": "Üst Etiketler Ekle", + "build_tags.parent_tags": "Üst Etiketler", + "delete_unlinked.confirm": "%{len(self.lib.missing_files) tane kayıtları silmek istediğinden emin misin?", + "delete_unlinked.delete_entries": "Kayıtlar Siliniyor", + "delete_unlinked.delete_unlinked": "Kopuk Kayıtları Sil", + "delete_unlinked.deleting_number_entries": "%{x[0]+1}/{len(self.lib.missing_files)} Kopuk Kayıt Siliniyor", + "dialog.open_create_library": "Kütüphane Aç/Oluştur", + "dialog.refresh_directories": "Dizinler Yenileniyor", + "dialog.save_library": "Kütüphaneyi Kaydet", + "dialog.scan_directories": "Yeni Dosyalar için Dizinler Taranıyor...\nHazırlanıyor...", + "dialog.scan_directories.new_files": "Yeni Dosyalar için Dizinler Taranıyor...\n%{x + 1} File%{\"s\" if x + 1 != 1 else \"\"} Arandı, %{len(self.lib.files_not_in_library)} Yeni Dosya Bulundu", + "file_extension.add_extension": "Dosya Uzantısı Ekle", + "file_extension.list_mode": "Listeleme Modu:", + "file_opener.command_not_found": "Sistem PATH'inde %{command_name} komutu bulunamadı", + "file_opener.not_found": "Dosya bulunamadı:", + "file_opener.open_file": "Dosya açılıyor:}", + "fix_dupes.advice_label": "Yansıtma işleminden sonra, DupeGuru'yu kullanarak istenmeyen dosyaları silebilirsin. İşlem sonrasında, kopuk kayıtları silmek için TagStudio'nun Araçlar menüsünden \"Kopuk Kayıtları Düzelt\" özelliğini kullanabilirsin.", + "fix_dupes.fix_dupes": "Yinelenen Dosyaları Düzelt", + "fix_dupes.load_file": "DupeGuru Dosyasını Yükle", + "fix_dupes.mirror_description": "Kayıt verilerini bulunan her bir yinelemeye yansıtır, alanları kopyalamadan veya silmeden tüm verileri birleştirir. Bu operasyon herhangi bir dosya veya veri silmeyecek.", + "fix_dupes.mirror_entries": "Kayıtları Yansıt", + "fix_dupes.name_filter": "DupeGuru Dosyaları (*.dupeguru)", + "fix_dupes.no_file_match": "Bulunan Yinelenen Dosyalar: Yok", + "fix_dupes.no_file_selected": "Seçili DupeGuru Dosyası Yok", + "fix_dupes.number_file_match": "Bulunan Yinelenen Dosyalar: %{count}", + "fix_dupes.open_result_files": "DupeGuru Sonuçlar Dosyasını Aç", + "fix_unlinked.delete_unlinked": "Kopuk Kayıtları Sil", + "fix_unlinked.description": "Kütüphanenizdeki her bir kayıt, dizinlerinizden bir tane dosya ile eşleştirilmektedir. Eğer bir kayıta bağlı dosya TagStudio dışında taşınır veya silinirse, o dosya artık kopuk olarak sayılır. Kopuk kayıtlar dizinlerinizde arama yapılırken otomatik olarak tekrar eşleştirilebilir, manuel olarak sizin tarafınızdan eşleştirilebilir veya isteğiniz üzere silinebilir.", + "fix_unlinked.duplicate_description": "Yinelenen kayıtlar, diskinizde aynı dosyaya işaret eden birden fazla kayıt olarak tanımlanmaktadır. Bu kayıtları birleştirdiğinizde, yinelenen tüm kayıtların içerisindeki etiketler ve metadata bilgisi tek bir tane kayıt üzerinde birleştirilecektir. Bu, \"yinelenen dosyalar\" ile karıştırılmamalıdır. Yinelenen dosyalar, TagStudio'nun dışında birden fazla kere bulunan dosyalarınızdır.", + "fix_unlinked.fix_unlinked": "Kopuk Kayıtları Düzelt", + "fix_unlinked.manual_relink": "Manuel Yeniden Eşleştirme", + "fix_unlinked.merge_dupes": "Yinelenen Kayıtları Birleştir", + "fix_unlinked.refresh_dupes": "Yinelenen Kayıtları Yenile", + "fix_unlinked.scan_library.label": "Kütüphane Kopuk Kayıtlar için Taranıyor...", + "fix_unlinked.scan_library.title": "Kütüphane Taranıyor", + "fix_unlinked.search_and_relink": "Ara && Yeniden Eşleştir", + "folders_to_tags.description": "Klasörlerinin yapısına bakarak etiketler oluşturur ve bu etiketleri kayıtlarına uygular.\nAşağıdaki yapı, oluşturulacak tüm etiketleri ve o etiketlerin hangi kayıtlarına uygulanacağını göstermektedir.", + "folders_to_tags.folders_to_tags": "Klasörler Etiketlere Dönüştürülüyor", + "folders_to_tags.title": "Klasörlerden Etiketler Oluştur", + "generic.add": "Ekle", + "generic.aliases": "Takma Adlar", + "generic.apply": "Uygula", + "generic.cancel": "İptal", + "generic.close_all": "Tümünü Kapat", + "generic.color": "Renk", + "generic.delete": "Sil", + "generic.done": "Tamamlandı", + "generic.exclude": "Hariç Tut", + "generic.file_extension": "Dosya Uzantıları", + "generic.include": "Dahil Et", + "generic.mirror": "Yansıt", + "generic.name": "İsim", + "generic.open_all": "Tümünü Aç", + "generic.open_file": "Dosya aç", + "generic.open_file_explorer": "Dosyayı Dosya Gezgininde Aç", + "generic.refresh_all": "Tümünü Yenile", + "generic.remove_field": "Alan Kaldır", + "generic.search_tags": "Etiketleri Araştır", + "generic.shorthand": "Kısaltma", + "home.base_title": "TagStudio Alpha", + "home.include_all_tags": "Ve (Tüm Etiketleri Dikkate Alır)", + "home.include_any_tag": "Veya (Herhangi Bir Etiketi Dikkate Alır)", + "home.main_window": "Ana Pencere", + "home.search": "Ara", + "home.search_entries": "library", + "home.thumbnail_size": "Küçük Resim Boyutu", + "landing.open_button": "Kütüphane Aç/Oluştur %{open_shortcut_text}", + "library.Artist": "Sanatçı", + "library.anthology": "Antoloji", + "library.archived": "Arşivlenme Tarihi", + "library.author": "Sahibi", + "library.book": "Kitap", + "library.collation": "Sıralama", + "library.comic": "Dergi", + "library.comments": "Yorumlar", + "library.composer": "Besteci", + "library.content_tags": "İçerik Etiketleri", + "library.date": "Tarih", + "library.date_created": "Oluşturulma Tarihi", + "library.date_modified": "Değiştirilme Tarihi", + "library.date_published": "Yayınlanma Tarihi", + "library.date_released": "Çıkış Tarihi", + "library.date_taken": "Alınma Tarihi", + "library.date_uploaded": "Yüklenme Tarihi", + "library.description": "Açıklama", + "library.favorite": "Favori", + "library.guest_artist": "Konuk Sanatçı", + "library.magazine": "Magazin", + "library.manga": "Manga", + "library.meta_tags": "Meta Etiketler", + "library.notes": "Notlar", + "library.publisher": "Yayımcı", + "library.series": "Dizi", + "library.source": "Kaynak", + "library.tags": "Etiketler", + "library.title": "Başlık", + "library.url": "URL", + "library.volume": "Ses Seviyesi", + "menu.edit": "Düzenle", + "menu.file": "Dosya", + "menu.help": "Yardım", + "menu.macros": "Makrolar", + "menu.tools": "Araçlar", + "menu.window": "Pencere", + "merge.merge_dupe_entries": "Yinelenen Kayıtlar Birleştiriliyor", + "merge.window_title": "Yinelenen Kayıtlar Birleştiriliyor", + "mirror_entities.are_you_sure": "%{len(self.lib.dupe_files)} kaydı yansıtmak istediğinden emin misin?", + "mirror_entities.label": "1/%{count} Kayıt Yansıtılıyor...", + "mirror_entities.title": "Kayıtlar Yansıtılıyor", + "open_library.library_creation_return_code": "Kütüphane Oluşturmaktan Dönen Kod:", + "open_library.no_tagstudio_library_found": "'%{path}' konumunda herhangi bir TagStudio kütüphanesi bulunamadı. Yeni bir tane oluşturuluyor.", + "open_library.title": "Kütüphane", + "preview.dimensions": "Ölçüler", + "preview.recent": "Son Kütüphaneler", + "preview_panel.confirm_remove": "Bu \"%{self.lib.get_field_attr(field, \"name\")}\" alanını silmek istediğinden emin misin?", + "preview_panel.edit_name": "Düzenle", + "preview_panel.missing_location": "Lokasyon bulunamadı", + "preview_panel.mixed_data": "Karışık Veri", + "preview_panel.no_items_selected": "Hiçbir Öğe Seçilmedi", + "preview_panel.unknown_field_type": "Bilinmeyen Alan Türü", + "preview_panel.update_widgets": "[KAYIT PANELİ] WIDGET'LARI GÜNCELLE (%{self.driver.selected})", + "progression.running_macros.new_entries": "Yeni Kayıtlar Üzerinde Makrolar Çalıştırılıyor", + "progression.running_macros.one_new_entry": "1/%{len(new_ids)} Tane Yeni Kayıtlarda Yapılandırılmış Makrolar Çalıştırılıyor", + "progression.running_macros.several_new_entry": "%{x + 1}/%{len(new_ids)} Tane Yeni Kayıt Üzerinde Yapılandırılmış Makrolar Çalıştırılıyor", + "relink_unlinked.attempt_relink": "%{x[0]+1}/%{len(self.lib.missing_files)} Kayıt Yeniden Eşleştirilmeye Çalışılıyor, %{self.fixed} Başarıyla Yeniden Eşleştirildi", + "relink_unlinked.title": "Kayıtlar Yeniden Eşleştiriliyor", + "splash.open_library": "Kütüphane Açılıyor", + "status.backup_success": "Kütüphane Yedeklemesi Şuraya Kaydedildi:", + "status.enumerate_query": "Sorgu:%{query}, Kare: %{i}, Uzunluk: %{len(f)}", + "status.number_results_found": "\"%{query}\" (%{format_timespan(end_time - start_time)}) Sorgusu için %{len(all_items)} Sonuç Bulundu", + "status.results_found": "Sonuçlar", + "status.save_success": "Kütüphane Kaydedildi ve Çıkış Yapıldı!", + "status.search_library_query": "Kütüphane Aranıyor", + "tag.add": "Etiket Ekle", + "tag.add_search": "Aramaya Ekle", + "tag.library": "Kütüphane Etiketleri", + "tag.new": "Yeni Etiket", + "tag.search_for_tag": "Etiket Ara", + "text_line_edit.unknown_event_type": "bilinmeyen event türü: %{event}", + "tooltip.open_library": "Ctrl+O", + "tooltip.save_library": "Ctrl+S" +} diff --git a/tagstudio/resources/translations/yue_Hant.json b/tagstudio/resources/translations/yue_Hant.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/tagstudio/resources/translations/yue_Hant.json @@ -0,0 +1 @@ +{} diff --git a/tagstudio/src/core/constants.py b/tagstudio/src/core/constants.py index 64ff6214..335df553 100644 --- a/tagstudio/src/core/constants.py +++ b/tagstudio/src/core/constants.py @@ -1,134 +1,17 @@ -from enum import Enum - -VERSION: str = "9.3.2" # Major.Minor.Patch -VERSION_BRANCH: str = "" # Usually "" or "Pre-Release" +VERSION: str = "9.5.0" # Major.Minor.Patch +VERSION_BRANCH: str = "EXPERIMENTAL" # Usually "" or "Pre-Release" # The folder & file names where TagStudio keeps its data relative to a library. TS_FOLDER_NAME: str = ".TagStudio" BACKUP_FOLDER_NAME: str = "backups" COLLAGE_FOLDER_NAME: str = "collages" -LIBRARY_FILENAME: str = "ts_library.json" -# TODO: Turn this whitelist into a user-configurable blacklist. -IMAGE_TYPES: list[str] = [ - ".png", - ".jpg", - ".jpeg", - ".jpg_large", - ".jpeg_large", - ".jfif", - ".gif", - ".tif", - ".tiff", - ".heic", - ".heif", - ".webp", - ".bmp", - ".svg", - ".avif", - ".apng", - ".jp2", - ".j2k", - ".jpg2", -] -RAW_IMAGE_TYPES: list[str] = [ - ".raw", - ".dng", - ".rw2", - ".nef", - ".arw", - ".crw", - ".cr2", - ".cr3", -] -VIDEO_TYPES: list[str] = [ - ".mp4", - ".webm", - ".mov", - ".hevc", - ".mkv", - ".avi", - ".wmv", - ".flv", - ".gifv", - ".m4p", - ".m4v", - ".3gp", -] -AUDIO_TYPES: list[str] = [ - ".mp3", - ".mp4", - ".mpeg4", - ".m4a", - ".aac", - ".wav", - ".flac", - ".alac", - ".wma", - ".ogg", - ".aiff", -] -DOC_TYPES: list[str] = [ - ".txt", - ".rtf", - ".md", - ".doc", - ".docx", - ".pdf", - ".tex", - ".odt", - ".pages", -] -PLAINTEXT_TYPES: list[str] = [ - ".txt", - ".md", - ".css", - ".html", - ".xml", - ".json", - ".js", - ".ts", - ".ini", - ".htm", - ".csv", - ".php", - ".sh", - ".bat", -] -SPREADSHEET_TYPES: list[str] = [".csv", ".xls", ".xlsx", ".numbers", ".ods"] -PRESENTATION_TYPES: list[str] = [".ppt", ".pptx", ".key", ".odp"] -ARCHIVE_TYPES: list[str] = [ - ".zip", - ".rar", - ".tar", - ".tar", - ".gz", - ".tgz", - ".7z", - ".s7z", -] -PROGRAM_TYPES: list[str] = [".exe", ".app"] -SHORTCUT_TYPES: list[str] = [".lnk", ".desktop", ".url"] - -ALL_FILE_TYPES: list[str] = ( - IMAGE_TYPES - + VIDEO_TYPES - + AUDIO_TYPES - + DOC_TYPES - + SPREADSHEET_TYPES - + PRESENTATION_TYPES - + ARCHIVE_TYPES - + PROGRAM_TYPES - + SHORTCUT_TYPES +FONT_SAMPLE_TEXT: str = ( + """ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!?@$%(){}[]""" ) - +FONT_SAMPLE_SIZES: list[int] = [10, 15, 20] TAG_FAVORITE = 1 TAG_ARCHIVED = 0 - - -class LibraryPrefs(Enum): - IS_EXCLUDE_LIST = True - EXTENSION_LIST: list[str] = [".json", ".xmp", ".aae"] - PAGE_SIZE: int = 500 - DB_VERSION: int = 1 +RESERVED_TAG_START = 0 +RESERVED_TAG_END = 999 diff --git a/tagstudio/src/core/driver.py b/tagstudio/src/core/driver.py new file mode 100644 index 00000000..1561fbc9 --- /dev/null +++ b/tagstudio/src/core/driver.py @@ -0,0 +1,40 @@ +from pathlib import Path + +import structlog +from PySide6.QtCore import QSettings +from src.core.constants import TS_FOLDER_NAME +from src.core.enums import SettingItems +from src.core.library.alchemy.library import LibraryStatus + +logger = structlog.get_logger(__name__) + + +class DriverMixin: + settings: QSettings + + def evaluate_path(self, open_path: str | None) -> LibraryStatus: + """Check if the path of library is valid.""" + library_path: Path | None = None + if open_path: + library_path = Path(open_path) + if not library_path.exists(): + logger.error("Path does not exist.", open_path=open_path) + return LibraryStatus(success=False, message="Path does not exist.") + elif self.settings.value( + SettingItems.START_LOAD_LAST, defaultValue=True, type=bool + ) and self.settings.value(SettingItems.LAST_LIBRARY): + library_path = Path(str(self.settings.value(SettingItems.LAST_LIBRARY))) + if not (library_path / TS_FOLDER_NAME).exists(): + logger.error( + "TagStudio folder does not exist.", + library_path=library_path, + ts_folder=TS_FOLDER_NAME, + ) + self.settings.setValue(SettingItems.LAST_LIBRARY, "") + # dont consider this a fatal error, just skip opening the library + library_path = None + + return LibraryStatus( + success=True, + library_path=library_path, + ) diff --git a/tagstudio/src/core/enums.py b/tagstudio/src/core/enums.py index 8907ba17..416e8c64 100644 --- a/tagstudio/src/core/enums.py +++ b/tagstudio/src/core/enums.py @@ -1,4 +1,6 @@ import enum +from typing import Any +from uuid import uuid4 class SettingItems(str, enum.Enum): @@ -8,11 +10,16 @@ class SettingItems(str, enum.Enum): LAST_LIBRARY = "last_library" LIBS_LIST = "libs_list" WINDOW_SHOW_LIBS = "window_show_libs" + SHOW_FILENAMES = "show_filenames" AUTOPLAY = "autoplay_videos" class Theme(str, enum.Enum): + COLOR_BG_DARK = "#65000000" + COLOR_BG_LIGHT = "#22000000" + COLOR_DARK_LABEL = "#DD000000" COLOR_BG = "#65000000" + COLOR_HOVER = "#65AAAAAA" COLOR_PRESSED = "#65EEEEEE" COLOR_DISABLED = "#65F39CAA" @@ -31,3 +38,31 @@ class MacroID(enum.Enum): BUILD_URL = "build_url" MATCH = "match" CLEAN_URL = "clean_url" + + +class DefaultEnum(enum.Enum): + """Allow saving multiple identical values in property called .default.""" + + default: Any + + def __new__(cls, value): + # Create the enum instance + obj = object.__new__(cls) + # make value random + obj._value_ = uuid4() + # assign the actual value into .default property + obj.default = value + return obj + + @property + def value(self): + raise AttributeError("access the value via .default property instead") + + +class LibraryPrefs(DefaultEnum): + """Library preferences with default value accessible via .default property.""" + + IS_EXCLUDE_LIST = True + EXTENSION_LIST: list[str] = [".json", ".xmp", ".aae"] + PAGE_SIZE: int = 500 + DB_VERSION: int = 2 diff --git a/tagstudio/src/core/exceptions.py b/tagstudio/src/core/exceptions.py new file mode 100644 index 00000000..10bec533 --- /dev/null +++ b/tagstudio/src/core/exceptions.py @@ -0,0 +1,6 @@ +# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + + +class NoRendererError(Exception): ... diff --git a/tagstudio/src/core/library/alchemy/db.py b/tagstudio/src/core/library/alchemy/db.py index f1a23f5d..f48ed2b5 100644 --- a/tagstudio/src/core/library/alchemy/db.py +++ b/tagstudio/src/core/library/alchemy/db.py @@ -3,6 +3,7 @@ from pathlib import Path import structlog from sqlalchemy import Dialect, Engine, String, TypeDecorator, create_engine, text from sqlalchemy.orm import DeclarativeBase +from src.core.constants import RESERVED_TAG_END logger = structlog.getLogger(__name__) @@ -37,10 +38,16 @@ def make_tables(engine: Engine) -> None: # tag IDs < 1000 are reserved # create tag and delete it to bump the autoincrement sequence # TODO - find a better way + # is this the better way? with engine.connect() as conn: - conn.execute(text("INSERT INTO tags (id, name, color) VALUES (999, 'temp', 1)")) - conn.execute(text("DELETE FROM tags WHERE id = 999")) - conn.commit() + result = conn.execute(text("SELECT SEQ FROM sqlite_sequence WHERE name='tags'")) + autoincrement_val = result.scalar() + if not autoincrement_val or autoincrement_val <= RESERVED_TAG_END: + conn.execute( + text(f"INSERT INTO tags (id, name, color) VALUES ({RESERVED_TAG_END}, 'temp', 1)") + ) + conn.execute(text(f"DELETE FROM tags WHERE id = {RESERVED_TAG_END}")) + conn.commit() def drop_tables(engine: Engine) -> None: diff --git a/tagstudio/src/core/library/alchemy/enums.py b/tagstudio/src/core/library/alchemy/enums.py index aaf9d32a..0036fbbd 100644 --- a/tagstudio/src/core/library/alchemy/enums.py +++ b/tagstudio/src/core/library/alchemy/enums.py @@ -1,7 +1,10 @@ import enum -from dataclasses import dataclass +from dataclasses import dataclass, replace from pathlib import Path +from src.core.query_lang import AST as Query # noqa: N811 +from src.core.query_lang import Constraint, ConstraintType, Parser + class TagColor(enum.IntEnum): DEFAULT = 1 @@ -42,12 +45,12 @@ class TagColor(enum.IntEnum): COOL_GRAY = 36 OLIVE = 37 - -class SearchMode(enum.IntEnum): - """Operational modes for item searching.""" - - AND = 0 - OR = 1 + @staticmethod + def get_color_from_str(color_name: str) -> "TagColor": + for color in TagColor: + if color.name == color_name.upper().replace(" ", "_"): + return color + return TagColor.DEFAULT class ItemType(enum.Enum): @@ -61,63 +64,12 @@ class FilterState: """Represent a state of the Library grid view.""" # these should remain - page_index: int | None = None - page_size: int | None = None - search_mode: SearchMode = SearchMode.AND # TODO - actually implement this + page_index: int | None = 0 + page_size: int | None = 500 # these should be erased on update - # tag name - tag: str | None = None - # tag ID - tag_id: int | None = None - - # entry id - id: int | None = None - # whole path - path: Path | str | None = None - # file name - name: str | None = None - - # a generic query to be parsed - query: str | None = None - - def __post_init__(self): - # strip values automatically - if query := (self.query and self.query.strip()): - # parse the value - if ":" in query: - kind, _, value = query.partition(":") - else: - # default to tag search - kind, value = "tag", query - - if kind == "tag_id": - self.tag_id = int(value) - elif kind == "tag": - self.tag = value - elif kind == "path": - self.path = value - elif kind == "name": - self.name = value - elif kind == "id": - self.id = int(self.id) if str(self.id).isnumeric() else self.id - - else: - self.tag = self.tag and self.tag.strip() - self.tag_id = int(self.tag_id) if str(self.tag_id).isnumeric() else self.tag_id - self.path = self.path and str(self.path).strip() - self.name = self.name and self.name.strip() - self.id = int(self.id) if str(self.id).isnumeric() else self.id - - if self.page_index is None: - self.page_index = 0 - if self.page_size is None: - self.page_size = 500 - - @property - def summary(self): - """Show query summary.""" - return self.query or self.tag or self.name or self.tag_id or self.path or self.id + # Abstract Syntax Tree Of the current Search Query + ast: Query = None @property def limit(self): @@ -127,6 +79,37 @@ class FilterState: def offset(self): return self.page_size * self.page_index + @classmethod + def show_all(cls) -> "FilterState": + return FilterState() + + @classmethod + def from_search_query(cls, search_query: str) -> "FilterState": + return cls(ast=Parser(search_query).parse()) + + @classmethod + def from_tag_id(cls, tag_id: int | str) -> "FilterState": + return cls(ast=Constraint(ConstraintType.TagID, str(tag_id), [])) + + @classmethod + def from_path(cls, path: Path | str) -> "FilterState": + return cls(ast=Constraint(ConstraintType.Path, str(path).strip(), [])) + + @classmethod + def from_mediatype(cls, mediatype: str) -> "FilterState": + return cls(ast=Constraint(ConstraintType.MediaType, mediatype, [])) + + @classmethod + def from_filetype(cls, filetype: str) -> "FilterState": + return cls(ast=Constraint(ConstraintType.FileType, filetype, [])) + + @classmethod + def from_tag_name(cls, tag_name: str) -> "FilterState": + return cls(ast=Constraint(ConstraintType.Tag, tag_name, [])) + + def with_page_size(self, page_size: int) -> "FilterState": + return replace(self, page_size=page_size) + class FieldTypeEnum(enum.Enum): TEXT_LINE = "Text Line" diff --git a/tagstudio/src/core/library/alchemy/fields.py b/tagstudio/src/core/library/alchemy/fields.py index 1b77c1e6..d0252ad9 100644 --- a/tagstudio/src/core/library/alchemy/fields.py +++ b/tagstudio/src/core/library/alchemy/fields.py @@ -18,27 +18,27 @@ class BaseField(Base): __abstract__ = True @declared_attr - def id(cls) -> Mapped[int]: # noqa: N805 + def id(self) -> Mapped[int]: return mapped_column(primary_key=True, autoincrement=True) @declared_attr - def type_key(cls) -> Mapped[str]: # noqa: N805 + def type_key(self) -> Mapped[str]: return mapped_column(ForeignKey("value_type.key")) @declared_attr - def type(cls) -> Mapped[ValueType]: # noqa: N805 - return relationship(foreign_keys=[cls.type_key], lazy=False) # type: ignore + def type(self) -> Mapped[ValueType]: + return relationship(foreign_keys=[self.type_key], lazy=False) # type: ignore @declared_attr - def entry_id(cls) -> Mapped[int]: # noqa: N805 + def entry_id(self) -> Mapped[int]: return mapped_column(ForeignKey("entries.id")) @declared_attr - def entry(cls) -> Mapped[Entry]: # noqa: N805 - return relationship(foreign_keys=[cls.entry_id]) # type: ignore + def entry(self) -> Mapped[Entry]: + return relationship(foreign_keys=[self.entry_id]) # type: ignore @declared_attr - def position(cls) -> Mapped[int]: # noqa: N805 + def position(self) -> Mapped[int]: return mapped_column(default=0) def __hash__(self): diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index 125c88c8..043d9262 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -1,17 +1,21 @@ import re import shutil +import time import unicodedata +from collections.abc import Iterator from dataclasses import dataclass from datetime import UTC, datetime from os import makedirs from pathlib import Path -from typing import Any, Iterator, Type +from typing import Any from uuid import uuid4 import structlog +from humanfriendly import format_timespan from sqlalchemy import ( URL, Engine, + NullPool, and_, create_engine, delete, @@ -28,14 +32,15 @@ from sqlalchemy.orm import ( make_transient, selectinload, ) +from src.core.library.json.library import Library as JsonLibrary # type: ignore from ...constants import ( BACKUP_FOLDER_NAME, TAG_ARCHIVED, TAG_FAVORITE, TS_FOLDER_NAME, - LibraryPrefs, ) +from ...enums import LibraryPrefs from .db import make_tables from .enums import FieldTypeEnum, FilterState, TagColor from .fields import ( @@ -47,8 +52,7 @@ from .fields import ( ) from .joins import TagField, TagSubtag from .models import Entry, Folder, Preferences, Tag, TagAlias, ValueType - -LIBRARY_FILENAME: str = "ts_library.sqlite" +from .visitors import SQLBoolExpressionBuilder logger = structlog.get_logger(__name__) @@ -115,6 +119,16 @@ class SearchResult: return self.items[index] +@dataclass +class LibraryStatus: + """Keep status of library opening operation.""" + + success: bool + library_path: Path | None = None + message: str | None = None + json_migration_req: bool = False + + class Library: """Class for the Library object, and all CRUD operations made upon it.""" @@ -122,6 +136,10 @@ class Library: storage_path: Path | str | None engine: Engine | None folder: Folder | None + included_files: set[Path] = set() + + SQL_FILENAME: str = "ts_library.sqlite" + JSON_FILENAME: str = "ts_library.json" def close(self): if self.engine: @@ -129,39 +147,142 @@ class Library: self.library_dir = None self.storage_path = None self.folder = None + self.included_files = set() - def open_library(self, library_dir: Path | str, storage_path: str | None = None) -> None: - if isinstance(library_dir, str): - library_dir = Path(library_dir) + def migrate_json_to_sqlite(self, json_lib: JsonLibrary): + """Migrate JSON library data to the SQLite database.""" + logger.info("Starting Library Conversion...") + start_time = time.time() + folder: Folder = Folder(path=self.library_dir, uuid=str(uuid4())) - self.library_dir = library_dir + # Tags + for tag in json_lib.tags: + self.add_tag( + Tag( + id=tag.id, + name=tag.name, + shorthand=tag.shorthand, + color=TagColor.get_color_from_str(tag.color), + ) + ) + + # Tag Aliases + for tag in json_lib.tags: + for alias in tag.aliases: + if not alias: + break + self.add_alias(name=alias, tag_id=tag.id) + + # Tag Subtags + for tag in json_lib.tags: + for subtag_id in tag.subtag_ids: + self.add_subtag(parent_id=tag.id, child_id=subtag_id) + + # Entries + self.add_entries( + [ + Entry( + path=entry.path / entry.filename, + folder=folder, + fields=[], + id=entry.id + 1, # JSON IDs start at 0 instead of 1 + ) + for entry in json_lib.entries + ] + ) + for entry in json_lib.entries: + for field in entry.fields: + for k, v in field.items(): + self.add_entry_field_type( + entry_ids=(entry.id + 1), # JSON IDs start at 0 instead of 1 + field_id=self.get_field_name_from_id(k), + value=v, + ) + + # Preferences + self.set_prefs(LibraryPrefs.EXTENSION_LIST, [x.strip(".") for x in json_lib.ext_list]) + self.set_prefs(LibraryPrefs.IS_EXCLUDE_LIST, json_lib.is_exclude_list) + + end_time = time.time() + logger.info(f"Library Converted! ({format_timespan(end_time-start_time)})") + + def get_field_name_from_id(self, field_id: int) -> _FieldID: + for f in _FieldID: + if field_id == f.value.id: + return f + return None + + def open_library(self, library_dir: Path, storage_path: str | None = None) -> LibraryStatus: + is_new: bool = True if storage_path == ":memory:": self.storage_path = storage_path + is_new = True + return self.open_sqlite_library(library_dir, is_new) else: - self.verify_ts_folders(self.library_dir) - self.storage_path = self.library_dir / TS_FOLDER_NAME / LIBRARY_FILENAME + self.storage_path = library_dir / TS_FOLDER_NAME / self.SQL_FILENAME + if self.verify_ts_folder(library_dir) and (is_new := not self.storage_path.exists()): + json_path = library_dir / TS_FOLDER_NAME / self.JSON_FILENAME + if json_path.exists(): + return LibraryStatus( + success=False, + library_path=library_dir, + message="[JSON] Legacy v9.4 library requires conversion to v9.5+", + json_migration_req=True, + ) + + return self.open_sqlite_library(library_dir, is_new) + + def open_sqlite_library( + self, library_dir: Path, is_new: bool, add_default_data: bool = True + ) -> LibraryStatus: connection_string = URL.create( drivername="sqlite", database=str(self.storage_path), ) + # NOTE: File-based databases should use NullPool to create new DB connection in order to + # keep connections on separate threads, which prevents the DB files from being locked + # even after a connection has been closed. + # SingletonThreadPool (the default for :memory:) should still be used for in-memory DBs. + # More info can be found on the SQLAlchemy docs: + # https://docs.sqlalchemy.org/en/20/changelog/migration_07.html + # Under -> sqlite-the-sqlite-dialect-now-uses-nullpool-for-file-based-databases + poolclass = None if self.storage_path == ":memory:" else NullPool - logger.info("opening library", connection_string=connection_string) - self.engine = create_engine(connection_string) + logger.info( + "Opening SQLite Library", library_dir=library_dir, connection_string=connection_string + ) + self.engine = create_engine(connection_string, poolclass=poolclass) with Session(self.engine) as session: make_tables(self.engine) - tags = get_default_tags() - try: - session.add_all(tags) - session.commit() - except IntegrityError: - # default tags may exist already - session.rollback() + if add_default_data: + tags = get_default_tags() + try: + session.add_all(tags) + session.commit() + except IntegrityError: + # default tags may exist already + session.rollback() + + # dont check db version when creating new library + if not is_new: + db_version = session.scalar( + select(Preferences).where(Preferences.key == LibraryPrefs.DB_VERSION.name) + ) + + if not db_version: + return LibraryStatus( + success=False, + message=( + "Library version mismatch.\n" + f"Found: v0, expected: v{LibraryPrefs.DB_VERSION.default}" + ), + ) for pref in LibraryPrefs: try: - session.add(Preferences(key=pref.name, value=pref.value)) + session.add(Preferences(key=pref.name, value=pref.default)) session.commit() except IntegrityError: logger.debug("preference already exists", pref=pref) @@ -183,11 +304,29 @@ class Library: logger.debug("ValueType already exists", field=field) session.rollback() + db_version = session.scalar( + select(Preferences).where(Preferences.key == LibraryPrefs.DB_VERSION.name) + ) + # if the db version is different, we cant proceed + if db_version.value != LibraryPrefs.DB_VERSION.default: + logger.error( + "DB version mismatch", + db_version=db_version.value, + expected=LibraryPrefs.DB_VERSION.default, + ) + return LibraryStatus( + success=False, + message=( + "Library version mismatch.\n" + f"Found: v{db_version.value}, expected: v{LibraryPrefs.DB_VERSION.default}" + ), + ) + # check if folder matching current path exists already - self.folder = session.scalar(select(Folder).where(Folder.path == self.library_dir)) + self.folder = session.scalar(select(Folder).where(Folder.path == library_dir)) if not self.folder: folder = Folder( - path=self.library_dir, + path=library_dir, uuid=str(uuid4()), ) session.add(folder) @@ -196,6 +335,10 @@ class Library: session.commit() self.folder = folder + # everything is fine, set the library path + self.library_dir = library_dir + return LibraryStatus(success=True, library_path=library_dir) + @property def default_fields(self) -> list[BaseField]: with Session(self.engine) as session: @@ -260,6 +403,29 @@ class Library: make_transient(entry) return entry + def get_entry_full(self, entry_id: int) -> Entry | None: + """Load entry an join with all joins and all tags.""" + with Session(self.engine) as session: + statement = select(Entry).where(Entry.id == entry_id) + statement = ( + statement.outerjoin(Entry.text_fields) + .outerjoin(Entry.datetime_fields) + .outerjoin(Entry.tag_box_fields) + ) + statement = statement.options( + selectinload(Entry.text_fields), + selectinload(Entry.datetime_fields), + selectinload(Entry.tag_box_fields) + .joinedload(TagBoxField.tags) + .options(selectinload(Tag.aliases), selectinload(Tag.subtags)), + ) + entry = session.scalar(statement) + if not entry: + return None + session.expunge(entry) + make_transient(entry) + return entry + @property def entries_count(self) -> int: with Session(self.engine) as session: @@ -305,8 +471,12 @@ class Library: return list(tags_list) - def verify_ts_folders(self, library_dir: Path) -> None: - """Verify/create folders required by TagStudio.""" + def verify_ts_folder(self, library_dir: Path) -> bool: + """Verify/create folders required by TagStudio. + + Returns: + bool: True if path exists, False if it needed to be created. + """ if library_dir is None: raise ValueError("No path set.") @@ -317,6 +487,8 @@ class Library: if not full_ts_path.exists(): logger.info("creating library directory", dir=full_ts_path) full_ts_path.mkdir(parents=True, exist_ok=True) + return False + return True def add_entries(self, items: list[Entry]) -> list[int]: """Add multiple Entry records to the Library.""" @@ -324,15 +496,18 @@ class Library: with Session(self.engine) as session: # add all items - session.add_all(items) - session.flush() + + try: + session.add_all(items) + session.commit() + except IntegrityError: + session.rollback() + logger.exception("IntegrityError") + return [] new_ids = [item.id for item in items] - session.expunge_all() - session.commit() - return new_ids def remove_entries(self, entry_ids: list[int]) -> None: @@ -346,6 +521,13 @@ class Library: with Session(self.engine) as session: return session.query(exists().where(Entry.path == path)).scalar() + def get_paths(self, glob: str | None = None) -> list[str]: + with Session(self.engine) as session: + paths = session.scalars(select(Entry.path)).unique() + + path_strings: list[str] = list(map(lambda x: x.as_posix(), paths)) + return path_strings + def search_library( self, search: FilterState, @@ -360,45 +542,18 @@ class Library: with Session(self.engine, expire_on_commit=False) as session: statement = select(Entry) - if search.tag: - statement = ( - statement.join(Entry.tag_box_fields) - .join(TagBoxField.tags) - .where( - or_( - Tag.name.ilike(search.tag), - Tag.shorthand.ilike(search.tag), - ) - ) + if search.ast: + statement = statement.outerjoin(Entry.tag_box_fields).where( + SQLBoolExpressionBuilder(self).visit(search.ast) ) - elif search.tag_id: - statement = ( - statement.join(Entry.tag_box_fields) - .join(TagBoxField.tags) - .where(Tag.id == search.tag_id) - ) - - elif search.id: - statement = statement.where(Entry.id == search.id) - elif search.name: - statement = select(Entry).where( - and_( - Entry.path.ilike(f"%{search.name}%"), - # dont match directory name (ie. has following slash) - ~Entry.path.ilike(f"%{search.name}%/%"), - ) - ) - elif search.path: - statement = statement.where(Entry.path.ilike(f"%{search.path}%")) extensions = self.prefs(LibraryPrefs.EXTENSION_LIST) is_exclude_list = self.prefs(LibraryPrefs.IS_EXCLUDE_LIST) - if not search.id: # if `id` is set, we don't need to filter by extensions - if extensions and is_exclude_list: - statement = statement.where(Entry.path.notilike(f"%.{','.join(extensions)}")) - elif extensions: - statement = statement.where(Entry.path.ilike(f"%.{','.join(extensions)}")) + if extensions and is_exclude_list: + statement = statement.where(Entry.suffix.notin_(extensions)) + elif extensions: + statement = statement.where(Entry.suffix.in_(extensions)) statement = statement.options( selectinload(Entry.text_fields), @@ -408,6 +563,8 @@ class Library: .options(selectinload(Tag.aliases), selectinload(Tag.subtags)), ) + statement = statement.distinct(Entry.id) + query_count = select(func.count()).select_from(statement.alias("entries")) count_all: int = session.execute(query_count).scalar() @@ -421,7 +578,7 @@ class Library: res = SearchResult( total_count=count_all, - items=list(session.scalars(statement).unique()), + items=list(session.scalars(statement)), ) session.expunge_all() @@ -430,21 +587,23 @@ class Library: def search_tags( self, - search: FilterState, + name: str, ) -> list[Tag]: """Return a list of Tag records matching the query.""" + tag_limit = 100 + with Session(self.engine) as session: query = select(Tag) query = query.options( selectinload(Tag.subtags), selectinload(Tag.aliases), - ) + ).limit(tag_limit) - if search.tag: + if name: query = query.where( or_( - Tag.name.icontains(search.tag), - Tag.shorthand.icontains(search.tag), + Tag.name.icontains(name), + Tag.shorthand.icontains(name), ) ) @@ -454,7 +613,7 @@ class Library: logger.info( "searching tags", - search=search, + search=name, statement=str(query), results=len(res), ) @@ -498,6 +657,37 @@ class Library: session.execute(update_stmt) session.commit() + def remove_tag(self, tag: Tag): + with Session(self.engine, expire_on_commit=False) as session: + try: + subtags = session.scalars( + select(TagSubtag).where(TagSubtag.parent_id == tag.id) + ).all() + tags_query = select(Tag).options( + selectinload(Tag.subtags), selectinload(Tag.aliases) + ) + tag = session.scalar(tags_query.where(Tag.id == tag.id)) + aliases = session.scalars(select(TagAlias).where(TagAlias.tag_id == tag.id)) + + for alias in aliases or []: + session.delete(alias) + + for subtag in subtags or []: + session.delete(subtag) + session.expunge(subtag) + + session.delete(tag) + session.commit() + session.expunge(tag) + + return tag + + except IntegrityError as e: + logger.exception(e) + session.rollback() + + return None + def remove_tag_from_field(self, tag: Tag, field: TagBoxField) -> None: with Session(self.engine) as session: field_ = session.scalars(select(TagBoxField).where(TagBoxField.id == field.id)).one() @@ -510,7 +700,7 @@ class Library: def update_field_position( self, - field_class: Type[BaseField], + field_class: type[BaseField], field_type: str, entry_ids: list[int] | int, ): @@ -617,7 +807,7 @@ class Library: *, field: ValueType | None = None, field_id: _FieldID | str | None = None, - value: str | datetime | list[str] | None = None, + value: str | datetime | list[int] | None = None, ) -> bool: logger.info( "add_field_to_entry", @@ -650,8 +840,11 @@ class Library: if value: assert isinstance(value, list) - for tag in value: - field_model.tags.add(Tag(name=tag)) + with Session(self.engine) as session: + for tag_id in list(set(value)): + tag = session.scalar(select(Tag).where(Tag.id == tag_id)) + field_model.tags.add(tag) + session.flush() elif field.type == FieldTypeEnum.DATETIME: field_model = DatetimeField( @@ -683,21 +876,47 @@ class Library: ) return True - def add_tag(self, tag: Tag, subtag_ids: list[int] | None = None) -> Tag | None: + def tag_from_strings(self, strings: list[str] | str) -> list[int]: + """Create a Tag from a given string.""" + # TODO: Port over tag searching with aliases fallbacks + # and context clue ranking for string searches. + tags: list[int] = [] + + if isinstance(strings, str): + strings = [strings] + + with Session(self.engine) as session: + for string in strings: + tag = session.scalar(select(Tag).where(Tag.name == string)) + if tag: + tags.append(tag.id) + else: + new = session.add(Tag(name=string)) + if new: + tags.append(new.id) + session.flush() + session.commit() + return tags + + def add_tag( + self, + tag: Tag, + subtag_ids: set[int] | None = None, + alias_names: set[str] | None = None, + alias_ids: set[int] | None = None, + ) -> Tag | None: with Session(self.engine, expire_on_commit=False) as session: try: session.add(tag) session.flush() - for subtag_id in subtag_ids or []: - subtag = TagSubtag( - parent_id=tag.id, - child_id=subtag_id, - ) - session.add(subtag) + if subtag_ids is not None: + self.update_subtags(tag, subtag_ids, session) + + if alias_ids is not None and alias_names is not None: + self.update_aliases(tag, alias_ids, alias_names, session) session.commit() - session.expunge(tag) return tag @@ -770,7 +989,7 @@ class Library: target_path = self.library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME / filename shutil.copy2( - self.library_dir / TS_FOLDER_NAME / LIBRARY_FILENAME, + self.library_dir / TS_FOLDER_NAME / self.SQL_FILENAME, target_path, ) @@ -778,25 +997,47 @@ class Library: def get_tag(self, tag_id: int) -> Tag: with Session(self.engine) as session: - tags_query = select(Tag).options(selectinload(Tag.subtags)) + tags_query = select(Tag).options(selectinload(Tag.subtags), selectinload(Tag.aliases)) tag = session.scalar(tags_query.where(Tag.id == tag_id)) session.expunge(tag) for subtag in tag.subtags: session.expunge(subtag) + for alias in tag.aliases: + session.expunge(alias) + return tag - def add_subtag(self, base_id: int, new_tag_id: int) -> bool: + def get_tag_by_name(self, tag_name: str) -> Tag | None: + with Session(self.engine) as session: + statement = ( + select(Tag) + .outerjoin(TagAlias) + .where(or_(Tag.name == tag_name, TagAlias.name == tag_name)) + ) + return session.scalar(statement) + + def get_alias(self, tag_id: int, alias_id: int) -> TagAlias: + with Session(self.engine) as session: + alias_query = select(TagAlias).where(TagAlias.id == alias_id, TagAlias.tag_id == tag_id) + alias = session.scalar(alias_query.where(TagAlias.id == alias_id)) + + return alias + + def add_subtag(self, parent_id: int, child_id: int) -> bool: + if parent_id == child_id: + return False + # open session and save as parent tag with Session(self.engine) as session: - tag = TagSubtag( - parent_id=base_id, - child_id=new_tag_id, + subtag = TagSubtag( + parent_id=parent_id, + child_id=child_id, ) try: - session.add(tag) + session.add(subtag) session.commit() return True except IntegrityError: @@ -804,49 +1045,81 @@ class Library: logger.exception("IntegrityError") return False - def update_tag(self, tag: Tag, subtag_ids: list[int]) -> None: - """Edit a Tag in the Library.""" - # TODO - maybe merge this with add_tag? - - if tag.shorthand: - tag.shorthand = slugify(tag.shorthand) - - if tag.aliases: - # TODO - ... - - # save the tag + def add_alias(self, name: str, tag_id: int) -> bool: with Session(self.engine) as session: + if not name: + logger.warning("[LIBRARY][add_alias] Alias value must not be empty") + return False + alias = TagAlias( + name=name, + tag_id=tag_id, + ) + try: - # update the existing tag - session.add(tag) - session.flush() - - # load all tag's subtag to know which to remove - prev_subtags = session.scalars( - select(TagSubtag).where(TagSubtag.parent_id == tag.id) - ).all() - - for subtag in prev_subtags: - if subtag.child_id not in subtag_ids: - session.delete(subtag) - else: - # no change, remove from list - subtag_ids.remove(subtag.child_id) - - # create remaining items - for subtag_id in subtag_ids: - # add new subtag - subtag = TagSubtag( - parent_id=tag.id, - child_id=subtag_id, - ) - session.add(subtag) - + session.add(alias) session.commit() + return True except IntegrityError: session.rollback() logger.exception("IntegrityError") + return False + + def remove_subtag(self, base_id: int, remove_tag_id: int) -> bool: + with Session(self.engine) as session: + p_id = base_id + r_id = remove_tag_id + remove = session.query(TagSubtag).filter_by(parent_id=p_id, child_id=r_id).one() + session.delete(remove) + session.commit() + + return True + + def update_tag( + self, + tag: Tag, + subtag_ids: set[int] | None = None, + alias_names: set[str] | None = None, + alias_ids: set[int] | None = None, + ) -> None: + """Edit a Tag in the Library.""" + self.add_tag(tag, subtag_ids, alias_names, alias_ids) + + def update_aliases(self, tag, alias_ids, alias_names, session): + prev_aliases = session.scalars(select(TagAlias).where(TagAlias.tag_id == tag.id)).all() + + for alias in prev_aliases: + if alias.id not in alias_ids or alias.name not in alias_names: + session.delete(alias) + else: + alias_ids.remove(alias.id) + alias_names.remove(alias.name) + + for alias_name in alias_names: + alias = TagAlias(alias_name, tag.id) + session.add(alias) + + def update_subtags(self, tag, subtag_ids, session): + if tag.id in subtag_ids: + subtag_ids.remove(tag.id) + + # load all tag's subtag to know which to remove + prev_subtags = session.scalars(select(TagSubtag).where(TagSubtag.parent_id == tag.id)).all() + + for subtag in prev_subtags: + if subtag.child_id not in subtag_ids: + session.delete(subtag) + else: + # no change, remove from list + subtag_ids.remove(subtag.child_id) + + # create remaining items + for subtag_id in subtag_ids: + # add new subtag + subtag = TagSubtag( + parent_id=tag.id, + child_id=subtag_id, + ) + session.add(subtag) def prefs(self, key: LibraryPrefs) -> Any: # load given item from Preferences table diff --git a/tagstudio/src/core/library/alchemy/models.py b/tagstudio/src/core/library/alchemy/models.py index 303e9049..1c06e0fd 100644 --- a/tagstudio/src/core/library/alchemy/models.py +++ b/tagstudio/src/core/library/alchemy/models.py @@ -1,5 +1,4 @@ from pathlib import Path -from typing import Optional from sqlalchemy import JSON, ForeignKey, Integer, event from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -24,16 +23,16 @@ class TagAlias(Base): id: Mapped[int] = mapped_column(primary_key=True) - name: Mapped[str] + name: Mapped[str] = mapped_column(nullable=False) tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id")) tag: Mapped["Tag"] = relationship(back_populates="aliases") - def __init__(self, name: str, tag: Optional["Tag"] = None): + def __init__(self, name: str, tag_id: int | None = None): self.name = name - if tag: - self.tag = tag + if tag_id is not None: + self.tag_id = tag_id super().__init__() @@ -44,7 +43,7 @@ class Tag(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - name: Mapped[str] = mapped_column(unique=True) + name: Mapped[str] shorthand: Mapped[str | None] color: Mapped[TagColor] icon: Mapped[str | None] @@ -73,16 +72,20 @@ class Tag(Base): def alias_strings(self) -> list[str]: return [alias.name for alias in self.aliases] + @property + def alias_ids(self) -> list[int]: + return [tag.id for tag in self.aliases] + def __init__( self, - name: str, + id: int | None = None, + name: str | None = None, shorthand: str | None = None, aliases: set[TagAlias] | None = None, parent_tags: set["Tag"] | None = None, subtags: set["Tag"] | None = None, icon: str | None = None, color: TagColor = TagColor.DEFAULT, - id: int | None = None, ): self.name = name self.aliases = aliases or set() @@ -120,6 +123,7 @@ class Entry(Base): folder: Mapped[Folder] = relationship("Folder") path: Mapped[Path] = mapped_column(PathType, unique=True) + suffix: Mapped[str] = mapped_column() text_fields: Mapped[list[TextField]] = relationship( back_populates="entry", @@ -173,9 +177,12 @@ class Entry(Base): path: Path, folder: Folder, fields: list[BaseField], + id: int | None = None, ) -> None: self.path = path self.folder = folder + self.id = id + self.suffix = path.suffix.lstrip(".").lower() for field in fields: if isinstance(field, TextField): diff --git a/tagstudio/src/core/library/alchemy/visitors.py b/tagstudio/src/core/library/alchemy/visitors.py new file mode 100644 index 00000000..6f73a45a --- /dev/null +++ b/tagstudio/src/core/library/alchemy/visitors.py @@ -0,0 +1,125 @@ +from typing import TYPE_CHECKING + +from sqlalchemy import and_, distinct, func, or_, select +from sqlalchemy.orm import Session +from sqlalchemy.sql.expression import BinaryExpression, ColumnExpressionArgument +from src.core.media_types import MediaCategories +from src.core.query_lang import BaseVisitor +from src.core.query_lang.ast import AST, ANDList, Constraint, ConstraintType, Not, ORList, Property + +from .joins import TagField +from .models import Entry, Tag, TagAlias, TagBoxField + +# workaround to have autocompletion in the Editor +if TYPE_CHECKING: + from .library import Library +else: + Library = None # don't import .library because of circular imports + + +class SQLBoolExpressionBuilder(BaseVisitor[ColumnExpressionArgument]): + def __init__(self, lib: Library) -> None: + super().__init__() + self.lib = lib + + def visit_or_list(self, node: ORList) -> ColumnExpressionArgument: + return or_(*[self.visit(element) for element in node.elements]) + + def visit_and_list(self, node: ANDList) -> ColumnExpressionArgument: + tag_ids: list[int] = [] + bool_expressions: list[ColumnExpressionArgument] = [] + + # Search for TagID / unambigous Tag Constraints and store the respective tag ids seperately + for term in node.terms: + if isinstance(term, Constraint) and len(term.properties) == 0: + match term.type: + case ConstraintType.TagID: + tag_ids.append(int(term.value)) + continue + case ConstraintType.Tag: + if len(ids := self.__get_tag_ids(term.value)) == 1: + tag_ids.append(ids[0]) + continue + + bool_expressions.append(self.__entry_satisfies_ast(term)) + + # If there are at least two tag ids use a relational division query + # to efficiently check all of them + if len(tag_ids) > 1: + bool_expressions.append(self.__entry_has_all_tags(tag_ids)) + # If there is just one tag id, check the normal way + elif len(tag_ids) == 1: + bool_expressions.append( + self.__entry_satisfies_expression(TagField.tag_id == tag_ids[0]) + ) + + return and_(*bool_expressions) + + def visit_constraint(self, node: Constraint) -> ColumnExpressionArgument: + if len(node.properties) != 0: + raise NotImplementedError("Properties are not implemented yet") # TODO TSQLANG + + if node.type == ConstraintType.Tag: + return TagBoxField.tags.any(Tag.id.in_(self.__get_tag_ids(node.value))) + elif node.type == ConstraintType.TagID: + return TagBoxField.tags.any(Tag.id == int(node.value)) + elif node.type == ConstraintType.Path: + return Entry.path.op("GLOB")(node.value) + elif node.type == ConstraintType.MediaType: + extensions: set[str] = set[str]() + for media_cat in MediaCategories.ALL_CATEGORIES: + if node.value == media_cat.name: + extensions = extensions | media_cat.extensions + break + return Entry.suffix.in_(map(lambda x: x.replace(".", ""), extensions)) + elif node.type == ConstraintType.FileType: + return Entry.suffix.ilike(node.value) + elif node.type == ConstraintType.Special: # noqa: SIM102 unnecessary once there is a second special constraint + if node.value.lower() == "untagged": + return ~Entry.id.in_( + select(Entry.id).join(Entry.tag_box_fields).join(TagBoxField.tags) + ) + + # raise exception if Constraint stays unhandled + raise NotImplementedError("This type of constraint is not implemented yet") + + def visit_property(self, node: Property) -> None: + raise NotImplementedError("This should never be reached!") + + def visit_not(self, node: Not) -> ColumnExpressionArgument: + return ~self.__entry_satisfies_ast(node.child) + + def __get_tag_ids(self, tag_name: str) -> list[int]: + """Given a tag name find the ids of all tags that this name could refer to.""" + with Session(self.lib.engine, expire_on_commit=False) as session: + return list( + session.scalars( + select(Tag.id) + .where(or_(Tag.name.ilike(tag_name), Tag.shorthand.ilike(tag_name))) + .union(select(TagAlias.tag_id).where(TagAlias.name.ilike(tag_name))) + ) + ) + + def __entry_has_all_tags(self, tag_ids: list[int]) -> BinaryExpression[bool]: + """Returns Binary Expression that is true if the Entry has all provided tag ids.""" + # Relational Division Query + return Entry.id.in_( + select(Entry.id) + .outerjoin(TagBoxField) + .outerjoin(TagField) + .where(TagField.tag_id.in_(tag_ids)) + .group_by(Entry.id) + .having(func.count(distinct(TagField.tag_id)) == len(tag_ids)) + ) + + def __entry_satisfies_ast(self, partial_query: AST) -> BinaryExpression[bool]: + """Returns Binary Expression that is true if the Entry satisfies the partial query.""" + return self.__entry_satisfies_expression(self.visit(partial_query)) + + def __entry_satisfies_expression( + self, expr: ColumnExpressionArgument + ) -> BinaryExpression[bool]: + """Returns Binary Expression that is true if the Entry satisfies the column expression.""" + return Entry.id.in_( + select(Entry.id).outerjoin(Entry.tag_box_fields).outerjoin(TagField).where(expr) + ) diff --git a/tagstudio/src/core/library/json/library.py b/tagstudio/src/core/library/json/library.py index 4322525c..56203196 100644 --- a/tagstudio/src/core/library/json/library.py +++ b/tagstudio/src/core/library/json/library.py @@ -299,6 +299,8 @@ class Collation: class Library: """Class for the Library object, and all CRUD operations made upon it.""" + FILENAME: str = "ts_library.json" + def __init__(self) -> None: # Library Info ========================================================= self.library_dir: Path = None @@ -412,17 +414,17 @@ class Library: """Verifies/creates folders required by TagStudio.""" full_ts_path = self.library_dir / TS_FOLDER_NAME - full_backup_path = self.library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME - full_collage_path = self.library_dir / TS_FOLDER_NAME / COLLAGE_FOLDER_NAME + # full_backup_path = self.library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME + # full_collage_path = self.library_dir / TS_FOLDER_NAME / COLLAGE_FOLDER_NAME if not os.path.isdir(full_ts_path): os.mkdir(full_ts_path) - if not os.path.isdir(full_backup_path): - os.mkdir(full_backup_path) + # if not os.path.isdir(full_backup_path): + # os.mkdir(full_backup_path) - if not os.path.isdir(full_collage_path): - os.mkdir(full_collage_path) + # if not os.path.isdir(full_collage_path): + # os.mkdir(full_collage_path) def verify_default_tags(self, tag_list: list) -> list: """ @@ -447,7 +449,7 @@ class Library: return_code = OpenStatus.CORRUPTED _path: Path = self._fix_lib_path(path) - logger.info("opening library", path=_path) + logger.info("Opening JSON Library", path=_path) if (_path / TS_FOLDER_NAME / "ts_library.json").exists(): try: with open( @@ -552,7 +554,7 @@ class Library: self._next_entry_id += 1 filename = entry.get("filename", "") - e_path = entry.get("path", "") + e_path = entry.get("path", "").replace("\\", "/") fields: list = [] if "fields" in entry: # Cast JSON str keys to ints diff --git a/tagstudio/src/core/media_types.py b/tagstudio/src/core/media_types.py new file mode 100644 index 00000000..78755aac --- /dev/null +++ b/tagstudio/src/core/media_types.py @@ -0,0 +1,587 @@ +# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +import logging +import mimetypes +from dataclasses import dataclass +from enum import Enum +from pathlib import Path + +logging.basicConfig(format="%(message)s", level=logging.INFO) + + +class MediaType(str, Enum): + """Names of media types.""" + + ADOBE_PHOTOSHOP: str = "adobe_photoshop" + AFFINITY_PHOTO: str = "affinity_photo" + ARCHIVE: str = "archive" + AUDIO_MIDI: str = "audio_midi" + AUDIO: str = "audio" + BLENDER: str = "blender" + DATABASE: str = "database" + DISK_IMAGE: str = "disk_image" + DOCUMENT: str = "document" + EBOOK: str = "ebook" + FONT: str = "font" + IMAGE_ANIMATED: str = "image_animated" + IMAGE_RAW: str = "image_raw" + IMAGE_VECTOR: str = "image_vector" + IMAGE: str = "image" + INSTALLER: str = "installer" + MATERIAL: str = "material" + MODEL: str = "model" + OPEN_DOCUMENT: str = "open_document" + PACKAGE: str = "package" + PDF: str = "pdf" + PLAINTEXT: str = "plaintext" + PRESENTATION: str = "presentation" + PROGRAM: str = "program" + SHORTCUT: str = "shortcut" + SOURCE_ENGINE: str = "source_engine" + SPREADSHEET: str = "spreadsheet" + TEXT: str = "text" + VIDEO: str = "video" + + +@dataclass(frozen=True) +class MediaCategory: + """An object representing a category of media. + + Includes a MediaType identifier, extensions set, and IANA status flag. + + Args: + media_type (MediaType): The MediaType Enum representing this category. + + extensions (set[str]): The set of file extensions associated with this category. + Includes leading ".", all lowercase, and does not need to be unique to this category. + + is_iana (bool): Represents whether or not this is an IANA registered category. + """ + + media_type: MediaType + extensions: set[str] + name: str + is_iana: bool = False + + +class MediaCategories: + """Contain pre-made MediaCategory objects as well as methods to interact with them.""" + + # These sets are used either individually or together to form the final sets + # for the MediaCategory(s). + # These sets may be combined and are NOT 1:1 with the final categories. + _ADOBE_PHOTOSHOP_SET: set[str] = { + ".pdd", + ".psb", + ".psd", + } + _AFFINITY_PHOTO_SET: set[str] = {".afphoto"} + _ARCHIVE_SET: set[str] = { + ".7z", + ".gz", + ".rar", + ".s7z", + ".tar", + ".tgz", + ".zip", + } + _AUDIO_MIDI_SET: set[str] = { + ".mid", + ".midi", + } + _AUDIO_SET: set[str] = { + ".aac", + ".aif", + ".aiff", + ".alac", + ".flac", + ".m4a", + ".m4p", + ".mp3", + ".mpeg4", + ".ogg", + ".wav", + ".wma", + } + _BLENDER_SET: set[str] = { + ".blen_tc", + ".blend", + ".blend1", + ".blend2", + ".blend3", + ".blend4", + ".blend5", + ".blend6", + ".blend7", + ".blend8", + ".blend9", + ".blend10", + ".blend11", + ".blend12", + ".blend13", + ".blend14", + ".blend15", + ".blend16", + ".blend17", + ".blend18", + ".blend19", + ".blend20", + ".blend21", + ".blend22", + ".blend23", + ".blend24", + ".blend25", + ".blend26", + ".blend27", + ".blend28", + ".blend29", + ".blend30", + ".blend31", + ".blend32", + } + _DATABASE_SET: set[str] = { + ".accdb", + ".mdb", + ".sqlite", + ".sqlite3", + } + _DISK_IMAGE_SET: set[str] = {".bios", ".dmg", ".iso"} + _DOCUMENT_SET: set[str] = { + ".doc", + ".docm", + ".docx", + ".dot", + ".dotm", + ".dotx", + ".odt", + ".pages", + ".pdf", + ".rtf", + ".tex", + ".wpd", + ".wps", + } + _EBOOK_SET: set[str] = { + ".epub", + # ".azw", + # ".azw3", + # ".cb7", + # ".cba", + # ".cbr", + # ".cbt", + # ".cbz", + # ".djvu", + # ".fb2", + # ".ibook", + # ".inf", + # ".kfx", + # ".lit", + # ".mobi", + # ".pdb" + # ".prc", + } + _FONT_SET: set[str] = { + ".fon", + ".otf", + ".ttc", + ".ttf", + ".woff", + ".woff2", + } + _IMAGE_ANIMATED_SET: set[str] = { + ".apng", + ".gif", + ".webp", + } + _IMAGE_RAW_SET: set[str] = { + ".arw", + ".cr2", + ".cr3", + ".crw", + ".dng", + ".nef", + ".orf", + ".raf", + ".raw", + ".rw2", + } + _IMAGE_VECTOR_SET: set[str] = {".svg"} + _IMAGE_RASTER_SET: set[str] = { + ".apng", + ".avif", + ".bmp", + ".exr", + ".gif", + ".heic", + ".heif", + ".j2k", + ".jfif", + ".jp2", + ".jpeg_large", + ".jpeg", + ".jpg_large", + ".jpg", + ".jpg2", + ".jxl", + ".png", + ".psb", + ".psd", + ".tif", + ".tiff", + ".webp", + } + _INSTALLER_SET: set[str] = {".appx", ".msi", ".msix"} + _MATERIAL_SET: set[str] = {".mtl"} + _MODEL_SET: set[str] = {".3ds", ".fbx", ".obj", ".stl"} + _OPEN_DOCUMENT_SET: set[str] = { + ".fodg", + ".fodp", + ".fods", + ".fodt", + ".mscz", + ".odf", + ".odg", + ".odp", + ".ods", + ".odt", + } + _PACKAGE_SET: set[str] = { + ".aab", + ".akp", + ".apk", + ".apkm", + ".apks", + ".pkg", + ".xapk", + } + _PDF_SET: set[str] = { + ".pdf", + } + _PLAINTEXT_SET: set[str] = { + ".bat", + ".cfg", + ".conf", + ".cpp", + ".cs", + ".css", + ".csv", + ".fgd", + ".gi", + ".h", + ".hpp", + ".htm", + ".html", + ".inf", + ".ini", + ".js", + ".json", + ".jsonc", + ".kv3", + ".lua", + ".md", + ".nut", + ".php", + ".plist", + ".prefs", + ".py", + ".pyc", + ".qss", + ".sh", + ".toml", + ".ts", + ".txt", + ".vcfg", + ".vdf", + ".vmt", + ".vqlayout", + ".vsc", + ".vsnd_template", + ".xml", + ".yaml", + ".yml", + } + _PRESENTATION_SET: set[str] = { + ".key", + ".odp", + ".ppt", + ".pptx", + } + _PROGRAM_SET: set[str] = {".app", ".exe"} + _SOURCE_ENGINE_SET: set[str] = { + ".vtf", + } + _SHORTCUT_SET: set[str] = {".desktop", ".lnk", ".url"} + _SPREADSHEET_SET: set[str] = { + ".csv", + ".numbers", + ".ods", + ".xls", + ".xlsx", + } + _VIDEO_SET: set[str] = { + ".3gp", + ".avi", + ".flv", + ".gifv", + ".hevc", + ".m4p", + ".m4v", + ".mkv", + ".mov", + ".mp4", + ".webm", + ".wmv", + } + + ADOBE_PHOTOSHOP_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.ADOBE_PHOTOSHOP, + extensions=_ADOBE_PHOTOSHOP_SET, + is_iana=False, + name="photoshop", + ) + AFFINITY_PHOTO_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.AFFINITY_PHOTO, + extensions=_AFFINITY_PHOTO_SET, + is_iana=False, + name="affinity photo", + ) + ARCHIVE_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.ARCHIVE, + extensions=_ARCHIVE_SET, + is_iana=False, + name="archive", + ) + AUDIO_MIDI_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.AUDIO_MIDI, + extensions=_AUDIO_MIDI_SET, + is_iana=False, + name="audio midi", + ) + AUDIO_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.AUDIO, + extensions=_AUDIO_SET | _AUDIO_MIDI_SET, + is_iana=True, + name="audio", + ) + BLENDER_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.BLENDER, + extensions=_BLENDER_SET, + is_iana=False, + name="blender", + ) + DATABASE_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.DATABASE, + extensions=_DATABASE_SET, + is_iana=False, + name="database", + ) + DISK_IMAGE_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.DISK_IMAGE, + extensions=_DISK_IMAGE_SET, + is_iana=False, + name="disk image", + ) + DOCUMENT_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.DOCUMENT, + extensions=_DOCUMENT_SET, + is_iana=False, + name="document", + ) + EBOOK_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.EBOOK, + extensions=_EBOOK_SET, + is_iana=False, + name="ebook", + ) + FONT_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.FONT, + extensions=_FONT_SET, + is_iana=True, + name="font", + ) + IMAGE_ANIMATED_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.IMAGE_ANIMATED, + extensions=_IMAGE_ANIMATED_SET, + is_iana=False, + name="animated image", + ) + IMAGE_RAW_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.IMAGE_RAW, + extensions=_IMAGE_RAW_SET, + is_iana=False, + name="raw image", + ) + IMAGE_VECTOR_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.IMAGE_VECTOR, + extensions=_IMAGE_VECTOR_SET, + is_iana=False, + name="vector image", + ) + IMAGE_RASTER_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.IMAGE, + extensions=_IMAGE_RASTER_SET, + is_iana=False, + name="raster image", + ) + IMAGE_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.IMAGE, + extensions=_IMAGE_RASTER_SET | _IMAGE_RAW_SET | _IMAGE_VECTOR_SET, + is_iana=True, + name="image", + ) + INSTALLER_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.INSTALLER, + extensions=_INSTALLER_SET, + is_iana=False, + name="installer", + ) + MATERIAL_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.MATERIAL, + extensions=_MATERIAL_SET, + is_iana=False, + name="material", + ) + MODEL_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.MODEL, + extensions=_MODEL_SET, + is_iana=True, + name="model", + ) + OPEN_DOCUMENT_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.OPEN_DOCUMENT, + extensions=_OPEN_DOCUMENT_SET, + is_iana=False, + name="open document", + ) + PACKAGE_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.PACKAGE, + extensions=_PACKAGE_SET, + is_iana=False, + name="package", + ) + PDF_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.PDF, + extensions=_PDF_SET, + is_iana=False, + name="pdf", + ) + PLAINTEXT_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.PLAINTEXT, + extensions=_PLAINTEXT_SET, + is_iana=False, + name="plaintext", + ) + PRESENTATION_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.PRESENTATION, + extensions=_PRESENTATION_SET, + is_iana=False, + name="presentation", + ) + PROGRAM_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.PROGRAM, + extensions=_PROGRAM_SET, + is_iana=False, + name="program", + ) + SHORTCUT_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.SHORTCUT, + extensions=_SHORTCUT_SET, + is_iana=False, + name="shortcut", + ) + SOURCE_ENGINE_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.SOURCE_ENGINE, + extensions=_SOURCE_ENGINE_SET, + is_iana=False, + name="source engine", + ) + SPREADSHEET_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.SPREADSHEET, + extensions=_SPREADSHEET_SET, + is_iana=False, + name="spreadsheet", + ) + TEXT_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.TEXT, + extensions=_DOCUMENT_SET | _PLAINTEXT_SET, + is_iana=True, + name="text", + ) + VIDEO_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.VIDEO, + extensions=_VIDEO_SET, + is_iana=True, + name="video", + ) + + ALL_CATEGORIES: list[MediaCategory] = [ + ADOBE_PHOTOSHOP_TYPES, + AFFINITY_PHOTO_TYPES, + ARCHIVE_TYPES, + AUDIO_MIDI_TYPES, + AUDIO_TYPES, + BLENDER_TYPES, + DATABASE_TYPES, + DISK_IMAGE_TYPES, + DOCUMENT_TYPES, + EBOOK_TYPES, + FONT_TYPES, + IMAGE_ANIMATED_TYPES, + IMAGE_RAW_TYPES, + IMAGE_TYPES, + IMAGE_VECTOR_TYPES, + INSTALLER_TYPES, + MATERIAL_TYPES, + MODEL_TYPES, + OPEN_DOCUMENT_TYPES, + PACKAGE_TYPES, + PDF_TYPES, + PLAINTEXT_TYPES, + PRESENTATION_TYPES, + PROGRAM_TYPES, + SHORTCUT_TYPES, + SOURCE_ENGINE_TYPES, + SPREADSHEET_TYPES, + TEXT_TYPES, + VIDEO_TYPES, + ] + + @staticmethod + def get_types(ext: str, mime_fallback: bool = False) -> set[MediaType]: + """Return a set of MediaTypes given a file extension. + + Args: + ext (str): File extension with a leading "." and in all lowercase. + mime_fallback (bool): Flag to guess MIME type if no set matches are made. + """ + media_types: set[MediaType] = set() + # mime_guess: bool = False + + for cat in MediaCategories.ALL_CATEGORIES: + if ext in cat.extensions: + media_types.add(cat.media_type) + elif mime_fallback and cat.is_iana: + mime_type: str = mimetypes.guess_type(Path("x" + ext), strict=False)[0] + if mime_type and mime_type.startswith(cat.media_type.value): + media_types.add(cat.media_type) + # mime_guess = True + return media_types + + @staticmethod + def is_ext_in_category(ext: str, media_cat: MediaCategory, mime_fallback: bool = False) -> bool: + """Check if an extension is a member of a MediaCategory. + + Args: + ext (str): File extension with a leading "." and in all lowercase. + media_cat (MediaCategory): The MediaCategory to to check for extension membership. + mime_fallback (bool): Flag to guess MIME type if no set matches are made. + """ + if ext in media_cat.extensions: + return True + elif mime_fallback and media_cat.is_iana: + mime_type: str = mimetypes.guess_type(Path("x" + ext), strict=False)[0] + if mime_type and mime_type.startswith(media_cat.media_type.value): + return True + return False diff --git a/tagstudio/src/core/palette.py b/tagstudio/src/core/palette.py index f104b692..422b35f7 100644 --- a/tagstudio/src/core/palette.py +++ b/tagstudio/src/core/palette.py @@ -19,6 +19,15 @@ class ColorType(IntEnum): DARK_ACCENT = 4 +class UiColor(IntEnum): + DEFAULT = 0 + THEME_DARK = 1 + THEME_LIGHT = 2 + RED = 3 + GREEN = 4 + PURPLE = 5 + + TAG_COLORS: dict[TagColor, dict[ColorType, Any]] = { TagColor.DEFAULT: { ColorType.PRIMARY: "#1e1e1e", @@ -283,8 +292,56 @@ TAG_COLORS: dict[TagColor, dict[ColorType, Any]] = { }, } +UI_COLORS: dict[UiColor, dict[ColorType, Any]] = { + UiColor.DEFAULT: { + ColorType.PRIMARY: "#333333", + ColorType.BORDER: "#555555", + ColorType.LIGHT_ACCENT: "#FFFFFF", + ColorType.DARK_ACCENT: "#1e1e1e", + }, + UiColor.RED: { + ColorType.PRIMARY: "#e22c3c", + ColorType.BORDER: "#e54252", + ColorType.LIGHT_ACCENT: "#f39caa", + ColorType.DARK_ACCENT: "#440d12", + }, + UiColor.GREEN: { + ColorType.PRIMARY: "#28bb48", + ColorType.BORDER: "#43c568", + ColorType.LIGHT_ACCENT: "#DDFFCC", + ColorType.DARK_ACCENT: "#0d3828", + }, + UiColor.PURPLE: { + ColorType.PRIMARY: "#C76FF3", + ColorType.BORDER: "#c364f2", + ColorType.LIGHT_ACCENT: "#EFD4FB", + ColorType.DARK_ACCENT: "#3E1555", + }, + UiColor.THEME_DARK: { + ColorType.PRIMARY: "#333333", + ColorType.BORDER: "#555555", + ColorType.LIGHT_ACCENT: "#FFFFFF", + ColorType.DARK_ACCENT: "#1e1e1e", + }, + UiColor.THEME_LIGHT: { + ColorType.PRIMARY: "#FFFFFF", + ColorType.BORDER: "#333333", + ColorType.LIGHT_ACCENT: "#999999", + ColorType.DARK_ACCENT: "#888888", + }, +} + def get_tag_color(color_type: ColorType, color_id: TagColor) -> str: + """Return a hex value given a tag color name and ColorType. + + Args: + color_type (ColorType): The ColorType category to retrieve from. + color_id (ColorType): The color name enum to retrieve from. + + Return: + A hex value string representing a color with a leading "#". + """ try: if color_type == ColorType.TEXT: text_account: ColorType = TAG_COLORS[color_id][color_type] @@ -293,5 +350,23 @@ def get_tag_color(color_type: ColorType, color_id: TagColor) -> str: return TAG_COLORS[color_id][color_type] except KeyError: traceback.print_stack() - logger.error("Color not found", color_id=color_id) + logger.error("[PALETTE] Tag color not found.", color_id=color_id) + return "#FF00FF" + + +def get_ui_color(color_type: ColorType, color_id: UiColor) -> str: + """Return a hex value given a UI color name and ColorType. + + Args: + color_type (ColorType): The ColorType category to retrieve from. + color_id (UiColor): The color name enum to retrieve from. + + Return: + A hex value string representing a color with a leading "#". + """ + try: + return UI_COLORS[color_id][color_type] + except KeyError: + traceback.print_stack() + logger.error("[PALETTE] UI color not found", color_id=color_id) return "#FF00FF" diff --git a/tagstudio/src/core/query_lang/__init__.py b/tagstudio/src/core/query_lang/__init__.py new file mode 100644 index 00000000..9f1afca1 --- /dev/null +++ b/tagstudio/src/core/query_lang/__init__.py @@ -0,0 +1,11 @@ +from src.core.query_lang.ast import ( # noqa + AST, + ANDList, + BaseVisitor, + Constraint, + ConstraintType, + ORList, + Property, +) +from src.core.query_lang.parser import Parser # noqa +from src.core.query_lang.util import ParsingError # noqa diff --git a/tagstudio/src/core/query_lang/ast.py b/tagstudio/src/core/query_lang/ast.py new file mode 100644 index 00000000..9ebab448 --- /dev/null +++ b/tagstudio/src/core/query_lang/ast.py @@ -0,0 +1,126 @@ +from abc import ABC, abstractmethod +from enum import Enum +from typing import Generic, TypeVar + + +class ConstraintType(Enum): + Tag = 0 + TagID = 1 + MediaType = 2 + FileType = 3 + Path = 4 + Special = 5 + + @staticmethod + def from_string(text: str) -> "ConstraintType": + return { + "tag": ConstraintType.Tag, + "tag_id": ConstraintType.TagID, + "mediatype": ConstraintType.MediaType, + "filetype": ConstraintType.FileType, + "path": ConstraintType.Path, + "special": ConstraintType.Special, + }.get(text.lower(), None) + + +class AST: + parent: "AST" = None + + def __str__(self): + class_name = self.__class__.__name__ + fields = vars(self) # Get all instance variables as a dictionary + field_str = ", ".join(f"{key}={value}" for key, value in fields.items()) + return f"{class_name}({field_str})" + + def __repr__(self) -> str: + return self.__str__() + + +class ANDList(AST): + terms: list[AST] + + def __init__(self, terms: list[AST]) -> None: + super().__init__() + for term in terms: + term.parent = self + self.terms = terms + + +class ORList(AST): + elements: list[AST] + + def __init__(self, elements: list[AST]) -> None: + super().__init__() + for element in elements: + element.parent = self + self.elements = elements + + +class Constraint(AST): + type: ConstraintType + value: str + properties: list["Property"] + + def __init__(self, type: ConstraintType, value: str, properties: list["Property"]) -> None: + super().__init__() + for prop in properties: + prop.parent = self + self.type = type + self.value = value + self.properties = properties + + +class Property(AST): + key: str + value: str + + def __init__(self, key: str, value: str) -> None: + super().__init__() + self.key = key + self.value = value + + +class Not(AST): + child: AST + + def __init__(self, child: AST) -> None: + super().__init__() + self.child = child + + +T = TypeVar("T") + + +class BaseVisitor(ABC, Generic[T]): + def visit(self, node: AST) -> T: + if isinstance(node, ANDList): + return self.visit_and_list(node) + elif isinstance(node, ORList): + return self.visit_or_list(node) + elif isinstance(node, Constraint): + return self.visit_constraint(node) + elif isinstance(node, Property): + return self.visit_property(node) + elif isinstance(node, Not): + return self.visit_not(node) + raise Exception(f"Unknown Node Type of {node}") # pragma: nocover + + @abstractmethod + def visit_and_list(self, node: ANDList) -> T: + raise NotImplementedError() # pragma: nocover + + @abstractmethod + def visit_or_list(self, node: ORList) -> T: + raise NotImplementedError() # pragma: nocover + + @abstractmethod + def visit_constraint(self, node: Constraint) -> T: + raise NotImplementedError() # pragma: nocover + + @abstractmethod + def visit_property(self, node: Property) -> T: + raise NotImplementedError() # pragma: nocover + + @abstractmethod + def visit_not(self, node: Not) -> T: + raise NotImplementedError() # pragma: nocover diff --git a/tagstudio/src/core/query_lang/parser.py b/tagstudio/src/core/query_lang/parser.py new file mode 100644 index 00000000..7194faad --- /dev/null +++ b/tagstudio/src/core/query_lang/parser.py @@ -0,0 +1,120 @@ +from .ast import AST, ANDList, Constraint, Not, ORList, Property +from .tokenizer import ConstraintType, Token, Tokenizer, TokenType +from .util import ParsingError + + +class Parser: + text: str + tokenizer: Tokenizer + next_token: Token + + last_constraint_type: ConstraintType = ConstraintType.Tag + + def __init__(self, text: str) -> None: + self.text = text + self.tokenizer = Tokenizer(self.text) + self.next_token = self.tokenizer.get_next_token() + + def parse(self) -> AST: + if self.next_token.type == TokenType.EOF: + return ORList([]) + out = self.__or_list() + if self.next_token.type != TokenType.EOF: + raise ParsingError(self.next_token.start, self.next_token.end, "Syntax Error") + return out + + def __or_list(self) -> AST: + terms = [self.__and_list()] + + while self.__is_next_or(): + self.__eat(TokenType.ULITERAL) + terms.append(self.__and_list()) + + return ORList(terms) if len(terms) > 1 else terms[0] + + def __is_next_or(self) -> bool: + return self.next_token.type == TokenType.ULITERAL and self.next_token.value.upper() == "OR" + + def __and_list(self) -> AST: + elements = [self.__term()] + while ( + self.next_token.type + in [ + TokenType.QLITERAL, + TokenType.ULITERAL, + TokenType.CONSTRAINTTYPE, + TokenType.RBRACKETO, + ] + and not self.__is_next_or() + ): + self.__skip_and() + elements.append(self.__term()) + return ANDList(elements) if len(elements) > 1 else elements[0] + + def __skip_and(self) -> None: + if self.__is_next_and(): + self.__eat(TokenType.ULITERAL) + + if self.__is_next_and(): + raise self.__syntax_error("Unexpected AND") + + def __is_next_and(self) -> bool: + return self.next_token.type == TokenType.ULITERAL and self.next_token.value.upper() == "AND" + + def __term(self) -> AST: + if self.__is_next_not(): + self.__eat(TokenType.ULITERAL) + term = self.__term() + if isinstance(term, Not): # instead of Not(Not(child)) return child + return term.child + return Not(term) + if self.next_token.type == TokenType.RBRACKETO: + self.__eat(TokenType.RBRACKETO) + out = self.__or_list() + self.__eat(TokenType.RBRACKETC) + return out + else: + return self.__constraint() + + def __is_next_not(self) -> bool: + return self.next_token.type == TokenType.ULITERAL and self.next_token.value.upper() == "NOT" + + def __constraint(self) -> Constraint: + if self.next_token.type == TokenType.CONSTRAINTTYPE: + self.last_constraint_type = self.__eat(TokenType.CONSTRAINTTYPE).value + + value = self.__literal() + + properties = [] + if self.next_token.type == TokenType.SBRACKETO: + self.__eat(TokenType.SBRACKETO) + properties.append(self.__property()) + + while self.next_token.type == TokenType.COMMA: + self.__eat(TokenType.COMMA) + properties.append(self.__property()) + + self.__eat(TokenType.SBRACKETC) + + return Constraint(self.last_constraint_type, value, properties) + + def __property(self) -> Property: + key = self.__eat(TokenType.ULITERAL).value + self.__eat(TokenType.EQUALS) + value = self.__literal() + return Property(key, value) + + def __literal(self) -> str: + if self.next_token.type in [TokenType.QLITERAL, TokenType.ULITERAL]: + return self.__eat(self.next_token.type).value + raise self.__syntax_error() + + def __eat(self, type: TokenType) -> Token: + if self.next_token.type != type: + raise self.__syntax_error(f"expected {type} found {self.next_token.type}") + out = self.next_token + self.next_token = self.tokenizer.get_next_token() + return out + + def __syntax_error(self, msg: str = "Syntax Error") -> ParsingError: + return ParsingError(self.next_token.start, self.next_token.end, msg) diff --git a/tagstudio/src/core/query_lang/tokenizer.py b/tagstudio/src/core/query_lang/tokenizer.py new file mode 100644 index 00000000..ba86abdf --- /dev/null +++ b/tagstudio/src/core/query_lang/tokenizer.py @@ -0,0 +1,147 @@ +from enum import Enum +from typing import Any + +from .ast import ConstraintType +from .util import ParsingError + + +class TokenType(Enum): + EOF = -1 + QLITERAL = 0 # Quoted Literal + ULITERAL = 1 # Unquoted Literal (does not contain ":", " ", "[", "]", "(", ")", "=", ",") + RBRACKETO = 2 # Round Bracket Open + RBRACKETC = 3 # Round Bracket Close + SBRACKETO = 4 # Square Bracket Open + SBRACKETC = 5 # Square Bracket Close + CONSTRAINTTYPE = 6 + COLON = 10 + COMMA = 11 + EQUALS = 12 + + +class Token: + type: TokenType + value: Any + + start: int + end: int + + def __init__(self, type: TokenType, value: Any, start: int = None, end: int = None) -> None: + self.type = type + self.value = value + self.start = start + self.end = end + + @staticmethod + def from_type(type: TokenType, pos: int = None) -> "Token": + return Token(type, None, pos, pos) + + @staticmethod + def EOF() -> "Token": # noqa: N802 + return Token.from_type(TokenType.EOF) + + def __str__(self) -> str: + return f"Token({self.type}, {self.value}, {self.start}, {self.end})" # pragma: nocover + + def __repr__(self) -> str: + return self.__str__() # pragma: nocover + + +class Tokenizer: + text: str + pos: int + current_char: str + + ESCAPABLE_CHARS = ["\\", '"', '"'] + NOT_IN_ULITERAL = [":", " ", "[", "]", "(", ")", "=", ","] + + def __init__(self, text: str) -> None: + self.text = text + self.pos = 0 + self.current_char = self.text[self.pos] if len(text) > 0 else None + + def get_next_token(self) -> Token: + self.__skip_whitespace() + if self.current_char is None: + return Token.EOF() + + if self.current_char in ("'", '"'): + return self.__quoted_string() + elif self.current_char == "(": + self.__advance() + return Token.from_type(TokenType.RBRACKETO, self.pos - 1) + elif self.current_char == ")": + self.__advance() + return Token.from_type(TokenType.RBRACKETC, self.pos - 1) + elif self.current_char == "[": + self.__advance() + return Token.from_type(TokenType.SBRACKETO, self.pos - 1) + elif self.current_char == "]": + self.__advance() + return Token.from_type(TokenType.SBRACKETC, self.pos - 1) + elif self.current_char == ",": + self.__advance() + return Token.from_type(TokenType.COMMA, self.pos - 1) + elif self.current_char == "=": + self.__advance() + return Token.from_type(TokenType.EQUALS, self.pos - 1) + else: + return self.__unquoted_string_or_constraint_type() + + def __unquoted_string_or_constraint_type(self) -> Token: + out = "" + + start = self.pos + + while self.current_char not in self.NOT_IN_ULITERAL and self.current_char is not None: + out += self.current_char + self.__advance() + + end = self.pos - 1 + + if self.current_char == ":": + if len(out) == 0: + raise ParsingError(self.pos, self.pos) + self.__advance() + constraint_type = ConstraintType.from_string(out) + if constraint_type is None: + raise ParsingError(start, end, f'Invalid ContraintType "{out}"') + return Token(TokenType.CONSTRAINTTYPE, constraint_type, start, end) + else: + return Token(TokenType.ULITERAL, out, start, end) + + def __quoted_string(self) -> Token: + start = self.pos + quote = self.current_char + self.__advance() + escape = False + out = "" + + while escape or self.current_char != quote: + if escape: + escape = False + if self.current_char not in Tokenizer.ESCAPABLE_CHARS: + out += "\\" + else: + out += self.current_char + self.__advance() + continue + if self.current_char == "\\": + escape = True + else: + out += self.current_char + self.__advance() + end = self.pos + self.__advance() + return Token(TokenType.QLITERAL, out, start, end) + + def __advance(self) -> None: + if self.pos < len(self.text) - 1: + self.pos += 1 + self.current_char = self.text[self.pos] + else: + self.current_char = None + + def __skip_whitespace(self) -> None: + while self.current_char is not None and self.current_char.isspace(): + self.__advance() diff --git a/tagstudio/src/core/query_lang/util.py b/tagstudio/src/core/query_lang/util.py new file mode 100644 index 00000000..8deaecf2 --- /dev/null +++ b/tagstudio/src/core/query_lang/util.py @@ -0,0 +1,15 @@ +class ParsingError(BaseException): + start: int + end: int + msg: str + + def __init__(self, start: int, end: int, msg: str = "Syntax Error") -> None: + self.start = start + self.end = end + self.msg = msg + + def __str__(self) -> str: + return f"Syntax Error {self.start}->{self.end}: {self.msg}" # pragma: nocover + + def __repr__(self) -> str: + return self.__str__() # pragma: nocover diff --git a/tagstudio/src/core/ts_core.py b/tagstudio/src/core/ts_core.py index 9611397e..aaae942e 100644 --- a/tagstudio/src/core/ts_core.py +++ b/tagstudio/src/core/ts_core.py @@ -24,7 +24,7 @@ class TagStudioCore: Return a formatted object with notable values or an empty object if none is found. """ info = {} - _filepath = filepath.parent / (filepath.stem + ".json") + _filepath = filepath.parent / (filepath.name + ".json") # NOTE: This fixes an unknown (recent?) bug in Gallery-DL where Instagram sidecar # files may be downloaded with indices starting at 1 rather than 0, unlike the posts. diff --git a/tagstudio/src/core/utils/dupe_files.py b/tagstudio/src/core/utils/dupe_files.py index 2d0a074b..3c1d55d1 100644 --- a/tagstudio/src/core/utils/dupe_files.py +++ b/tagstudio/src/core/utils/dupe_files.py @@ -50,7 +50,7 @@ class DupeRegistry: continue results = self.library.search_library( - FilterState(path=path_relative), + FilterState.from_path(path_relative), ) if not results: diff --git a/tagstudio/src/core/utils/refresh_dir.py b/tagstudio/src/core/utils/refresh_dir.py index 87b734ea..6bd5ba00 100644 --- a/tagstudio/src/core/utils/refresh_dir.py +++ b/tagstudio/src/core/utils/refresh_dir.py @@ -9,6 +9,20 @@ from src.core.library import Entry, Library logger = structlog.get_logger(__name__) +GLOBAL_IGNORE_SET: set[str] = set( + [ + TS_FOLDER_NAME, + "$RECYCLE.BIN", + ".Trashes", + ".Trash", + "tagstudio_thumbs", + ".fseventsd", + ".Spotlight-V100", + "System Volume Information", + ".DS_Store", + ] +) + @dataclass class RefreshDirTracker: @@ -49,29 +63,45 @@ class RefreshDirTracker: self.files_not_in_library = [] dir_file_count = 0 - for path in lib_path.glob("**/*"): - str_path = str(path) - if path.is_dir(): + for f in lib_path.glob("**/*"): + end_time_loop = time() + # Yield output every 1/30 of a second + if (end_time_loop - start_time_loop) > 0.034: + yield dir_file_count + start_time_loop = time() + + # Skip if the file/path is already mapped in the Library + if f in self.library.included_files: + dir_file_count += 1 continue - if "$RECYCLE.BIN" in str_path or TS_FOLDER_NAME in str_path: + # Ignore if the file is a directory + if f.is_dir(): + continue + + # Ensure new file isn't in a globally ignored folder + skip: bool = False + for part in f.parts: + if part in GLOBAL_IGNORE_SET: + skip = True + break + if skip: continue dir_file_count += 1 - relative_path = path.relative_to(lib_path) + self.library.included_files.add(f) + + relative_path = f.relative_to(lib_path) # TODO - load these in batch somehow if not self.library.has_path_entry(relative_path): self.files_not_in_library.append(relative_path) - # Yield output every 1/30 of a second - if (time() - start_time_loop) > 0.034: - yield dir_file_count - start_time_loop = time() - end_time_total = time() + yield dir_file_count logger.info( "Directory scan time", path=lib_path, duration=(end_time_total - start_time_total), - new_files_count=dir_file_count, + files_not_in_lib=self.files_not_in_library, + files_scanned=dir_file_count, ) diff --git a/tagstudio/src/qt/flowlayout.py b/tagstudio/src/qt/flowlayout.py index 6334cf95..046b93c9 100644 --- a/tagstudio/src/qt/flowlayout.py +++ b/tagstudio/src/qt/flowlayout.py @@ -140,4 +140,7 @@ class FlowLayout(QLayout): x = next_x line_height = max(line_height, item.sizeHint().height()) + if len(self._item_list) == 0: + return 0 + return y + line_height - rect.y() * ((len(self._item_list)) / len(self._item_list)) diff --git a/tagstudio/src/qt/helpers/blender_thumbnailer.py b/tagstudio/src/qt/helpers/blender_thumbnailer.py new file mode 100644 index 00000000..2df0a350 --- /dev/null +++ b/tagstudio/src/qt/helpers/blender_thumbnailer.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 + +# ##### BEGIN GPL LICENSE BLOCK ##### +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# ##### END GPL LICENSE BLOCK ##### + +# + + +## This file is a modified script that gets the thumbnail data stored in a blend file + + +import gzip +import os +import struct +from io import BufferedReader + +from PIL import ( + Image, + ImageOps, +) + + +def blend_extract_thumb(path): + rend = b"REND" + test = b"TEST" + + blendfile: BufferedReader | gzip.GzipFile = open(path, "rb") # noqa: SIM115 + + head = blendfile.read(12) + + if head[0:2] == b"\x1f\x8b": # gzip magic + blendfile.close() + blendfile = gzip.GzipFile("", "rb", 0, open(path, "rb")) # noqa: SIM115 + head = blendfile.read(12) + + if not head.startswith(b"BLENDER"): + blendfile.close() + return None, 0, 0 + + is_64_bit = head[7] == b"-"[0] + + # true for PPC, false for X86 + is_big_endian = head[8] == b"V"[0] + + # blender pre 2.5 had no thumbs + if head[9:11] <= b"24": + return None, 0, 0 + + sizeof_bhead = 24 if is_64_bit else 20 + int_endian = ">i" if is_big_endian else " Image.Image: +def theme_fg_overlay(image: Image.Image, use_alpha: bool = True) -> Image.Image: """Overlay the foreground theme color onto an image. Args: image (Image): The PIL Image object to apply an overlay to. + use_alpha (bool): Option to retain the base image's alpha value when applying the overlay. """ + dark_fg: str = _THEME_DARK_FG[:-2] if not use_alpha else _THEME_DARK_FG + light_fg: str = _THEME_LIGHT_FG[:-2] if not use_alpha else _THEME_LIGHT_FG + overlay_color = ( - _THEME_DARK_FG - if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else _THEME_LIGHT_FG + dark_fg if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark else light_fg ) + im = Image.new(mode="RGBA", size=image.size, color=overlay_color) return _apply_overlay(image, im) @@ -42,7 +47,7 @@ def gradient_overlay(image: Image.Image, gradient=list[str]) -> Image.Image: def _apply_overlay(image: Image.Image, overlay: Image.Image) -> Image.Image: - """Apply an overlay on top of an image, using the image's alpha channel as a mask. + """Apply an overlay on top of an image using the image's alpha channel as a mask. Args: image (Image): The PIL Image object to apply an overlay to. diff --git a/tagstudio/src/qt/helpers/file_opener.py b/tagstudio/src/qt/helpers/file_opener.py index 0f4a7e0a..f696d416 100644 --- a/tagstudio/src/qt/helpers/file_opener.py +++ b/tagstudio/src/qt/helpers/file_opener.py @@ -19,9 +19,9 @@ def open_file(path: str | Path, file_manager: bool = False): """Open a file in the default application or file explorer. Args: - path (str): The path to the file to open. - file_manager (bool, optional): Whether to open the file in the file manager - (e.g. Finder on macOS). Defaults to False. + path (str): The path to the file to open. + file_manager (bool, optional): Whether to open the file in the file manager + (e.g. Finder on macOS). Defaults to False. """ path = Path(path) logger.info("Opening file", path=path) @@ -31,14 +31,15 @@ def open_file(path: str | Path, file_manager: bool = False): try: if sys.platform == "win32": - normpath = Path(path).resolve().as_posix() + normpath = str(Path(path).resolve()) if file_manager: command_name = "explorer" - command_args = '/select,"' + normpath + '"' + command_arg = f'/select,"{normpath}"' + # For some reason, if the args are passed in a list, this will error when the # path has spaces, even while surrounded in double quotes. subprocess.Popen( - command_name + command_args, + command_name + command_arg, shell=True, close_fds=True, creationflags=subprocess.CREATE_NEW_PROCESS_GROUP @@ -92,7 +93,7 @@ class FileOpenerHelper: """Initialize the FileOpenerHelper. Args: - filepath (str): The path to the file to open. + filepath (str): The path to the file to open. """ self.filepath = str(filepath) @@ -100,7 +101,7 @@ class FileOpenerHelper: """Set the filepath to open. Args: - filepath (str): The path to the file to open. + filepath (str): The path to the file to open. """ self.filepath = str(filepath) @@ -114,20 +115,19 @@ class FileOpenerHelper: class FileOpenerLabel(QLabel): - def __init__(self, text, parent=None): + def __init__(self, parent=None): """Initialize the FileOpenerLabel. Args: - text (str): The text to display. - parent (QWidget, optional): The parent widget. Defaults to None. + parent (QWidget, optional): The parent widget. Defaults to None. """ - super().__init__(text, parent) + super().__init__(parent) def set_file_path(self, filepath): """Set the filepath to open. Args: - filepath (str): The path to the file to open. + filepath (str): The path to the file to open. """ self.filepath = filepath @@ -138,7 +138,7 @@ class FileOpenerLabel(QLabel): On a right click, show a context menu. Args: - event (QMouseEvent): The mouse press event. + event (QMouseEvent): The mouse press event. """ super().mousePressEvent(event) diff --git a/tagstudio/src/qt/helpers/file_tester.py b/tagstudio/src/qt/helpers/file_tester.py new file mode 100644 index 00000000..022ac191 --- /dev/null +++ b/tagstudio/src/qt/helpers/file_tester.py @@ -0,0 +1,32 @@ +# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + + +from pathlib import Path + +import ffmpeg +from src.qt.helpers.vendored.ffmpeg import _probe + + +def is_readable_video(filepath: Path | str): + """Test if a video is in a readable format. + + Examples of unreadable videos include files with undetermined codecs and DRM-protected content. + + Args: + filepath (Path | str): The filepath of the video to check. + """ + try: + probe = _probe(Path(filepath)) + for stream in probe["streams"]: + # DRM check + if stream.get("codec_tag_string") in [ + "drma", + "drms", + "drmi", + ]: + return False + except ffmpeg.Error: + return False + return True diff --git a/tagstudio/src/qt/helpers/gradient.py b/tagstudio/src/qt/helpers/gradient.py index a5f3b9fa..fe3f7c7d 100644 --- a/tagstudio/src/qt/helpers/gradient.py +++ b/tagstudio/src/qt/helpers/gradient.py @@ -2,26 +2,14 @@ # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -from PIL import Image, ImageChops, ImageEnhance +from PIL import Image -def four_corner_gradient_background(image: Image.Image, adj_size, mask, hl) -> Image.Image: - if image.size != (adj_size, adj_size): - # Old 1 color method. - # bg_col = image.copy().resize((1, 1)).getpixel((0,0)) - # bg = Image.new(mode='RGB',size=(adj_size,adj_size),color=bg_col) - # bg.thumbnail((1, 1)) - # bg = bg.resize((adj_size,adj_size), resample=Image.Resampling.NEAREST) - - # Small gradient background. Looks decent, and is only a one-liner. - # bg = ( - # image.copy() - # .resize((2, 2), resample=Image.Resampling.BILINEAR) - # .resize((adj_size, adj_size), resample=Image.Resampling.BILINEAR) - # ) - +def four_corner_gradient( + image: Image.Image, size: tuple[int, int], mask: Image.Image +) -> Image.Image: + if image.size != size: # Four-Corner Gradient Background. - # Not exactly a one-liner, but it's (subjectively) really cool. tl = image.getpixel((0, 0)) tr = image.getpixel(((image.size[0] - 1), 0)) bl = image.getpixel((0, (image.size[1] - 1))) @@ -31,26 +19,25 @@ def four_corner_gradient_background(image: Image.Image, adj_size, mask, hl) -> I bg.paste(tr, (1, 0, 2, 2)) bg.paste(bl, (0, 1, 2, 2)) bg.paste(br, (1, 1, 2, 2)) - bg = bg.resize((adj_size, adj_size), resample=Image.Resampling.BICUBIC) - + bg = bg.resize(size, resample=Image.Resampling.BICUBIC) bg.paste( image, box=( - (adj_size - image.size[0]) // 2, - (adj_size - image.size[1]) // 2, + (size[0] - image.size[0]) // 2, + (size[1] - image.size[1]) // 2, ), ) - bg.putalpha(mask) - final = bg + final = Image.new("RGBA", bg.size, (0, 0, 0, 0)) + final.paste(bg, mask=mask.getchannel(0)) else: - image.putalpha(mask) - final = image + final = Image.new("RGBA", size, (0, 0, 0, 0)) + final.paste(image, mask=mask.getchannel(0)) + + if final.mode != "RGBA": + final = final.convert("RGBA") - hl_soft = hl.copy() - hl_soft.putalpha(ImageEnhance.Brightness(hl.getchannel(3)).enhance(0.5)) - final.paste(ImageChops.soft_light(final, hl_soft), mask=hl_soft.getchannel(3)) return final diff --git a/tagstudio/src/qt/helpers/image_effects.py b/tagstudio/src/qt/helpers/image_effects.py new file mode 100644 index 00000000..139d5274 --- /dev/null +++ b/tagstudio/src/qt/helpers/image_effects.py @@ -0,0 +1,27 @@ +# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +import numpy as np +from PIL import Image + + +def replace_transparent_pixels( + img: Image.Image, color: tuple[int, int, int, int] = (255, 255, 255, 255) +) -> Image.Image: + """Replace (copying/without mutating) all transparent pixels in an image with the color. + + Args: + img (Image.Image): + The source image + color (tuple[int, int, int, int]): + The color (RGBA, 0 to 255) which transparent pixels should be set to. + Defaults to white (255, 255, 255, 255) + + Returns: + Image.Image: + A copy of img with the pixels replaced. + """ + pixel_array = np.asarray(img.convert("RGBA")).copy() + pixel_array[pixel_array[:, :, 3] == 0] = color + return Image.fromarray(pixel_array) diff --git a/tagstudio/src/qt/helpers/rounded_pixmap_style.py b/tagstudio/src/qt/helpers/rounded_pixmap_style.py new file mode 100644 index 00000000..36c33c79 --- /dev/null +++ b/tagstudio/src/qt/helpers/rounded_pixmap_style.py @@ -0,0 +1,29 @@ +# Based on the implementation by eyllanesc: +# https://stackoverflow.com/questions/54230005/qmovie-with-border-radius +# Licensed under the Creative Commons CC BY-SA 4.0 License: +# https://creativecommons.org/licenses/by-sa/4.0/ +# Modified for TagStudio: https://github.com/CyanVoxel/TagStudio + +from PySide6.QtGui import QBrush, QColor, QPainter, QPixmap +from PySide6.QtWidgets import ( + QProxyStyle, +) + + +class RoundedPixmapStyle(QProxyStyle): + def __init__(self, radius=8): + super().__init__() + self._radius = radius + + def drawItemPixmap(self, painter, rectangle, alignment, pixmap): # noqa: N802 + painter.save() + pix = QPixmap(pixmap.size()) + pix.fill(QColor("transparent")) + p = QPainter(pix) + p.setBrush(QBrush(pixmap)) + p.setPen(QColor("transparent")) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + p.drawRoundedRect(pixmap.rect(), self._radius, self._radius) + p.end() + super().drawItemPixmap(painter, rectangle, alignment, pix) + painter.restore() diff --git a/tagstudio/src/qt/helpers/silent_popen.py b/tagstudio/src/qt/helpers/silent_popen.py new file mode 100644 index 00000000..df426fe5 --- /dev/null +++ b/tagstudio/src/qt/helpers/silent_popen.py @@ -0,0 +1,70 @@ +# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +import subprocess +import sys + +"""Implementation of subprocess.Popen that does not spawn console windows or log output.""" + + +def promptless_Popen( # noqa: N802 + args, + bufsize=-1, + executable=None, + stdin=None, + stdout=None, + stderr=None, + preexec_fn=None, + close_fds=True, + shell=False, + cwd=None, + env=None, + universal_newlines=None, + startupinfo=None, + restore_signals=True, + start_new_session=False, + pass_fds=(), + *, + group=None, + extra_groups=None, + user=None, + umask=-1, + encoding=None, + errors=None, + text=None, + pipesize=-1, + process_group=None, +): + """Call subprocess.Popen without creating a console window.""" + creation_flags = 0 + if sys.platform == "win32": + creation_flags = subprocess.CREATE_NO_WINDOW + + return subprocess.Popen( + args=args, + bufsize=bufsize, + executable=executable, + stdin=stdin, + stdout=stdout, + stderr=stderr, + preexec_fn=preexec_fn, + close_fds=close_fds, + shell=shell, + cwd=cwd, + env=env, + universal_newlines=universal_newlines, + startupinfo=startupinfo, + creationflags=creation_flags, + restore_signals=restore_signals, + start_new_session=start_new_session, + pass_fds=pass_fds, + group=group, + extra_groups=extra_groups, + user=user, + umask=umask, + encoding=encoding, + errors=errors, + text=text, + pipesize=pipesize, + process_group=process_group, + ) diff --git a/tagstudio/src/qt/helpers/text_wrapper.py b/tagstudio/src/qt/helpers/text_wrapper.py new file mode 100644 index 00000000..073f6e15 --- /dev/null +++ b/tagstudio/src/qt/helpers/text_wrapper.py @@ -0,0 +1,49 @@ +# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +from PIL import Image, ImageDraw, ImageFont + + +def wrap_line( # type: ignore + text: str, + font: ImageFont.ImageFont, + width: int = 256, + draw: ImageDraw.ImageDraw = None, +) -> int: + """Take in a single text line and return the index it should be broken up at. + + Only splits once. + """ + if draw is None: + bg = Image.new("RGB", (width, width), color="#1e1e1e") + draw = ImageDraw.Draw(bg) + if draw.textlength(text, font=font) > width: + for i in range( + int(len(text) / int(draw.textlength(text, font=font)) * width) - 2, + 0, + -1, + ): + if draw.textlength(text[:i], font=font) < width: + return i + else: + return -1 + + +def wrap_full_text( + text: str, + font: ImageFont.ImageFont, + width: int = 256, + draw: ImageDraw.ImageDraw = None, +) -> str: + """Break up a string to fit the canvas given a kerning value, font size, etc.""" + lines = [] + i = 0 + last_i = 0 + while wrap_line(text[i:], font=font, width=width, draw=draw) > 0: + i = wrap_line(text[i:], font=font, width=width, draw=draw) + last_i + lines.append(text[last_i:i]) + last_i = i + lines.append(text[last_i:]) + text_wrapped = "\n".join(lines) + return text_wrapped diff --git a/tagstudio/src/qt/helpers/vendored/ffmpeg.py b/tagstudio/src/qt/helpers/vendored/ffmpeg.py new file mode 100644 index 00000000..05e1d258 --- /dev/null +++ b/tagstudio/src/qt/helpers/vendored/ffmpeg.py @@ -0,0 +1,54 @@ +# Copyright (C) 2022 Karl Kroening (kkroening). +# Licensed under the GPL-3.0 License. +# Vendored from ffmpeg-python and ffmpeg-python PR#790 by amamic1803 + +import json +import platform +import shutil +import subprocess + +import ffmpeg +import structlog +from src.qt.helpers.silent_popen import promptless_Popen + +logger = structlog.get_logger(__name__) + +FFMPEG_MACOS_LOCATIONS: list[str] = ["", "/opt/homebrew/bin/", "/usr/local/bin/"] + + +def _get_ffprobe_location() -> str: + cmd: str = "ffprobe" + if platform.system() == "Darwin": + for loc in FFMPEG_MACOS_LOCATIONS: + if shutil.which(loc + cmd): + cmd = loc + cmd + break + logger.info(f"[FFPROBE] Using FFmpeg location: {cmd}") + return cmd + + +FFPROBE_CMD = _get_ffprobe_location() + + +def _probe(filename, cmd=FFPROBE_CMD, timeout=None, **kwargs): + """Run ffprobe on the specified file and return a JSON representation of the output. + + Raises: + :class:`ffmpeg.Error`: if ffprobe returns a non-zero exit code, + an :class:`Error` is returned with a generic error message. + The stderr output can be retrieved by accessing the + ``stderr`` property of the exception. + """ + args = [cmd, "-show_format", "-show_streams", "-of", "json"] + args += ffmpeg._utils.convert_kwargs_to_cmd_line_args(kwargs) + args += [filename] + + # PATCHED + p = promptless_Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + communicate_kwargs = {} + if timeout is not None: + communicate_kwargs["timeout"] = timeout + out, err = p.communicate(**communicate_kwargs) + if p.returncode != 0: + raise ffmpeg.Error("ffprobe", out, err) + return json.loads(out.decode("utf-8")) diff --git a/tagstudio/src/qt/helpers/vendored/pydub/audio_segment.py b/tagstudio/src/qt/helpers/vendored/pydub/audio_segment.py new file mode 100644 index 00000000..a43ea5b3 --- /dev/null +++ b/tagstudio/src/qt/helpers/vendored/pydub/audio_segment.py @@ -0,0 +1,1444 @@ +# type: ignore +# Copyright (C) 2022 James Robert (jiaaro). +# Licensed under the MIT License. +# Vendored from pydub + + +import array +import base64 +import os +import struct +import subprocess +import sys +import wave +from collections import namedtuple +from io import BytesIO, StringIO +from tempfile import NamedTemporaryFile + +from pydub.logging_utils import log_conversion, log_subprocess_output +from pydub.utils import fsdecode + +try: + from itertools import izip +except Exception: + izip = zip + +from pydub.exceptions import ( + CouldntDecodeError, + CouldntEncodeError, + InvalidDuration, + InvalidID3TagVersion, + InvalidTag, + MissingAudioParameter, + TooManyMissingFrames, +) +from pydub.utils import ( + _fd_or_path_or_tempfile, + audioop, + db_to_float, + get_array_type, + get_encoder_name, + ratio_to_db, +) +from src.qt.helpers.silent_popen import promptless_Popen +from src.qt.helpers.vendored.pydub.utils import _mediainfo_json + +basestring = str +xrange = range +StringIO = BytesIO # noqa: F811 + + +class ClassPropertyDescriptor: + def __init__(self, fget, fset=None): + self.fget = fget + self.fset = fset + + def __get__(self, obj, klass=None): + if klass is None: + klass = type(obj) + return self.fget.__get__(obj, klass)() + + def __set__(self, obj, value): + if not self.fset: + raise AttributeError("can't set attribute") + type_ = type(obj) + return self.fset.__get__(obj, type_)(value) + + def setter(self, func): + if not isinstance(func, (classmethod, staticmethod)): + func = classmethod(func) + self.fset = func + return self + + +def classproperty(func): + if not isinstance(func, (classmethod, staticmethod)): + func = classmethod(func) + + return ClassPropertyDescriptor(func) + + +AUDIO_FILE_EXT_ALIASES = { + "m4a": "mp4", + "wave": "wav", +} + +WavSubChunk = namedtuple("WavSubChunk", ["id", "position", "size"]) +WavData = namedtuple( + "WavData", ["audio_format", "channels", "sample_rate", "bits_per_sample", "raw_data"] +) + + +def extract_wav_headers(data): + # def search_subchunk(data, subchunk_id): + pos = 12 # The size of the RIFF chunk descriptor + subchunks = [] + while pos + 8 <= len(data) and len(subchunks) < 10: + subchunk_id = data[pos : pos + 4] + subchunk_size = struct.unpack_from(" 2**32: + raise CouldntDecodeError("Unable to process >4GB files") + + # Set the file size in the RIFF chunk descriptor + data[4:8] = struct.pack(" b"\x7f"[0]]) + old_bytes = struct.pack(pack_fmt, b0, b1, b2) + byte_buffer.write(old_bytes) + + self._data = byte_buffer.getvalue() + self.sample_width = 4 + self.frame_width = self.channels * self.sample_width + + super(_AudioSegment, self).__init__(*args, **kwargs) + + @property + def raw_data(self): + """Public access to the raw audio data as a bytestring.""" + return self._data + + def get_array_of_samples(self, array_type_override=None): + """Return the raw_data as an array of samples.""" + if array_type_override is None: + array_type_override = self.array_type + return array.array(array_type_override, self._data) + + @property + def array_type(self): + return get_array_type(self.sample_width * 8) + + def __len__(self): + """Return the length of this audio segment in milliseconds.""" + return round(1000 * (self.frame_count() / self.frame_rate)) + + def __eq__(self, other): + try: + return self._data == other._data + except Exception: + return False + + def __hash__(self): + return hash(_AudioSegment) ^ hash( + (self.channels, self.frame_rate, self.sample_width, self._data) + ) + + def __ne__(self, other): + return not (self == other) + + def __iter__(self): + return (self[i] for i in xrange(len(self))) + + def __getitem__(self, millisecond): + if isinstance(millisecond, slice): + if millisecond.step: + return ( + self[i : i + millisecond.step] for i in xrange(*millisecond.indices(len(self))) + ) + + start = millisecond.start if millisecond.start is not None else 0 + end = millisecond.stop if millisecond.stop is not None else len(self) + + start = min(start, len(self)) + end = min(end, len(self)) + else: + start = millisecond + end = millisecond + 1 + + start = self._parse_position(start) * self.frame_width + end = self._parse_position(end) * self.frame_width + data = self._data[start:end] + + # ensure the output is as long as the requester is expecting + expected_length = end - start + missing_frames = (expected_length - len(data)) // self.frame_width + if missing_frames: + if missing_frames > self.frame_count(ms=2): + raise TooManyMissingFrames( + "You should never be filling in " # noqa: UP031 + " more than 2 ms with silence here, " + "missing frames: %s" % missing_frames + ) + silence = audioop.mul(data[: self.frame_width], self.sample_width, 0) + data += silence * missing_frames + + return self._spawn(data) + + def get_sample_slice(self, start_sample=None, end_sample=None): + """Get a section of the audio segment by sample index. + + NOTE: Negative indices do *not* address samples backword + from the end of the audio segment like a python list. + This is intentional. + """ + max_val = int(self.frame_count()) + + def bounded(val, default): + if val is None: + return default + if val < 0: + return 0 + if val > max_val: + return max_val + return val + + start_i = bounded(start_sample, 0) * self.frame_width + end_i = bounded(end_sample, max_val) * self.frame_width + + data = self._data[start_i:end_i] + return self._spawn(data) + + def __add__(self, arg): + if isinstance(arg, _AudioSegment): + return self.append(arg, crossfade=0) + else: + return self.apply_gain(arg) + + def __radd__(self, rarg): + """Permit use of sum() builtin with an iterable of AudioSegments.""" + if rarg == 0: + return self + raise TypeError("Gains must be the second addend after the " "AudioSegment") + + def __sub__(self, arg): + if isinstance(arg, _AudioSegment): + raise TypeError("AudioSegment objects can't be subtracted from " "each other") + else: + return self.apply_gain(-arg) + + def __mul__(self, arg): + """If the argument is an AudioSegment, overlay the multiplied audio segment. + + If it's a number, just use the string multiply operation to repeat the + audio. + + The following would return an AudioSegment that contains the + audio of audio_seg eight times + + `audio_seg * 8` + """ + if isinstance(arg, _AudioSegment): + return self.overlay(arg, position=0, loop=True) + else: + return self._spawn(data=self._data * arg) + + def _spawn(self, data, overrides={}): # noqa: B006 + """Create a new audio segment using the metadata from the current one & the data passed in. + + Should be used whenever an AudioSegment is being returned by an operation that would alters + the current one, since AudioSegment objects are immutable. + """ + # accept lists of data chunks + if isinstance(data, list): + data = b"".join(data) + + if isinstance(data, array.array): + try: + data = data.tobytes() + except Exception: + data = data.tostring() + + # accept file-like objects + if hasattr(data, "read"): + if hasattr(data, "seek"): + data.seek(0) + data = data.read() + + metadata = { + "sample_width": self.sample_width, + "frame_rate": self.frame_rate, + "frame_width": self.frame_width, + "channels": self.channels, + } + metadata.update(overrides) + return self.__class__(data=data, metadata=metadata) + + @classmethod + def _sync(cls, *segs): + channels = max(seg.channels for seg in segs) + frame_rate = max(seg.frame_rate for seg in segs) + sample_width = max(seg.sample_width for seg in segs) + + return tuple( + seg.set_channels(channels).set_frame_rate(frame_rate).set_sample_width(sample_width) + for seg in segs + ) + + def _parse_position(self, val): + if val < 0: + val = len(self) - abs(val) + val = self.frame_count(ms=len(self)) if val == float("inf") else self.frame_count(ms=val) + return int(val) + + @classmethod + def empty(cls): + return cls( + b"", metadata={"channels": 1, "sample_width": 1, "frame_rate": 1, "frame_width": 1} + ) + + @classmethod + def silent(cls, duration=1000, frame_rate=11025): + """Generate a silent audio segment. + + Duration specified in milliseconds (default duration: 1000ms, default frame_rate: 11025). + """ + frames = int(frame_rate * (duration / 1000.0)) + data = b"\0\0" * frames + return cls( + data, + metadata={"channels": 1, "sample_width": 2, "frame_rate": frame_rate, "frame_width": 2}, + ) + + @classmethod + def from_mono_audiosegments(cls, *mono_segments): + if not len(mono_segments): + raise ValueError("At least one AudioSegment instance is required") + + segs = cls._sync(*mono_segments) + + if segs[0].channels != 1: + raise ValueError( + "AudioSegment.from_mono_audiosegments requires all " + "arguments are mono AudioSegment instances" + ) + + channels = len(segs) + sample_width = segs[0].sample_width + frame_rate = segs[0].frame_rate + + frame_count = max(int(seg.frame_count()) for seg in segs) + data = array.array(segs[0].array_type, b"\0" * (frame_count * sample_width * channels)) + + for i, seg in enumerate(segs): + data[i::channels] = seg.get_array_of_samples() + + return cls( + data, + channels=channels, + sample_width=sample_width, + frame_rate=frame_rate, + ) + + @classmethod + def from_file_using_temporary_files( + cls, + file, + format=None, + codec=None, + parameters=None, + start_second=None, + duration=None, + **kwargs, + ): + orig_file = file + file, close_file = _fd_or_path_or_tempfile(file, "rb", tempfile=False) + + if format: + format = format.lower() + format = AUDIO_FILE_EXT_ALIASES.get(format, format) + + def is_format(f): + f = f.lower() + if format == f: + return True + if isinstance(orig_file, basestring): + return orig_file.lower().endswith(f".{f}") + if isinstance(orig_file, bytes): + return orig_file.lower().endswith((f".{f}").encode()) + return False + + if is_format("wav"): + try: + obj = cls._from_safe_wav(file) + if close_file: + file.close() + if start_second is None and duration is None: + return obj + elif start_second is not None and duration is None: + return obj[start_second * 1000 :] + elif start_second is None and duration is not None: + return obj[: duration * 1000] + else: + return obj[start_second * 1000 : (start_second + duration) * 1000] + except Exception: + file.seek(0) + elif is_format("raw") or is_format("pcm"): + sample_width = kwargs["sample_width"] + frame_rate = kwargs["frame_rate"] + channels = kwargs["channels"] + metadata = { + "sample_width": sample_width, + "frame_rate": frame_rate, + "channels": channels, + "frame_width": channels * sample_width, + } + obj = cls(data=file.read(), metadata=metadata) + if close_file: + file.close() + if start_second is None and duration is None: + return obj + elif start_second is not None and duration is None: + return obj[start_second * 1000 :] + elif start_second is None and duration is not None: + return obj[: duration * 1000] + else: + return obj[start_second * 1000 : (start_second + duration) * 1000] + + input_file = NamedTemporaryFile(mode="wb", delete=False) + try: + input_file.write(file.read()) + except OSError: + input_file.flush() + input_file.close() + input_file = NamedTemporaryFile(mode="wb", delete=False, buffering=2**31 - 1) + if close_file: + file.close() + close_file = True + file = open(orig_file, buffering=2**13 - 1, mode="rb") + reader = file.read(2**31 - 1) + while reader: + input_file.write(reader) + reader = file.read(2**31 - 1) + input_file.flush() + if close_file: + file.close() + + output = NamedTemporaryFile(mode="rb", delete=False) + + conversion_command = [ + cls.converter, + "-y", # always overwrite existing files + ] + + # If format is not defined + # ffmpeg/avconv will detect it automatically + if format: + conversion_command += ["-f", format] + + if codec: + # force audio decoder + conversion_command += ["-acodec", codec] + + conversion_command += [ + "-i", + input_file.name, # input_file options (filename last) + "-vn", # Drop any video streams if there are any + "-f", + "wav", # output options (filename last) + ] + + if start_second is not None: + conversion_command += ["-ss", str(start_second)] + + if duration is not None: + conversion_command += ["-t", str(duration)] + + conversion_command += [output.name] + + if parameters is not None: + # extend arguments with arbitrary set + conversion_command.extend(parameters) + + log_conversion(conversion_command) + + with open(os.devnull, "rb") as devnull: + # PATCHED + p = promptless_Popen( + conversion_command, stdin=devnull, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + p_out, p_err = p.communicate() + + log_subprocess_output(p_out) + log_subprocess_output(p_err) + + try: + if p.returncode != 0: + raise CouldntDecodeError( + "Decoding failed. ffmpeg returned error code: " # noqa: UP030 + "{0}\n\nOutput from ffmpeg/avlib:\n\n{1}".format( + p.returncode, p_err.decode(errors="ignore") + ) + ) + obj = cls._from_safe_wav(output) + finally: + input_file.close() + output.close() + os.unlink(input_file.name) + os.unlink(output.name) + + if start_second is None and duration is None: + return obj + elif start_second is not None and duration is None: + return obj[0:] + elif start_second is None and duration is not None: + return obj[: duration * 1000] + else: + return obj[0 : duration * 1000] + + @classmethod + def from_file( + cls, + file, + format=None, + codec=None, + parameters=None, + start_second=None, + duration=None, + **kwargs, + ): + orig_file = file + try: + filename = fsdecode(file) + except TypeError: + filename = None + file, close_file = _fd_or_path_or_tempfile(file, "rb", tempfile=False) + + if format: + format = format.lower() + format = AUDIO_FILE_EXT_ALIASES.get(format, format) + + def is_format(f): + f = f.lower() + if format == f: + return True + + if filename: + return filename.lower().endswith(f".{f}") + + return False + + if is_format("wav"): + try: + if start_second is None and duration is None: + return cls._from_safe_wav(file) + elif start_second is not None and duration is None: + return cls._from_safe_wav(file)[start_second * 1000 :] + elif start_second is None and duration is not None: + return cls._from_safe_wav(file)[: duration * 1000] + else: + return cls._from_safe_wav(file)[ + start_second * 1000 : (start_second + duration) * 1000 + ] + except Exception: + file.seek(0) + elif is_format("raw") or is_format("pcm"): + sample_width = kwargs["sample_width"] + frame_rate = kwargs["frame_rate"] + channels = kwargs["channels"] + metadata = { + "sample_width": sample_width, + "frame_rate": frame_rate, + "channels": channels, + "frame_width": channels * sample_width, + } + if start_second is None and duration is None: + return cls(data=file.read(), metadata=metadata) + elif start_second is not None and duration is None: + return cls(data=file.read(), metadata=metadata)[start_second * 1000 :] + elif start_second is None and duration is not None: + return cls(data=file.read(), metadata=metadata)[: duration * 1000] + else: + return cls(data=file.read(), metadata=metadata)[ + start_second * 1000 : (start_second + duration) * 1000 + ] + + conversion_command = [ + cls.converter, + "-y", # always overwrite existing files + ] + + # If format is not defined + # ffmpeg/avconv will detect it automatically + if format: + conversion_command += ["-f", format] + + if codec: + # force audio decoder + conversion_command += ["-acodec", codec] + + read_ahead_limit = kwargs.get("read_ahead_limit", -1) + if filename: + conversion_command += ["-i", filename] + stdin_parameter = None + stdin_data = None + else: + if cls.converter == "ffmpeg": + conversion_command += [ + "-read_ahead_limit", + str(read_ahead_limit), + "-i", + "cache:pipe:0", + ] + else: + conversion_command += ["-i", "-"] + stdin_parameter = subprocess.PIPE + stdin_data = file.read() + + if codec: + info = None + else: + # PATCHED + try: + info = _mediainfo_json(orig_file, read_ahead_limit=read_ahead_limit) + except FileNotFoundError: + raise ChildProcessError + if info: + audio_streams = [x for x in info["streams"] if x["codec_type"] == "audio"] + # This is a workaround for some ffprobe versions that always say + # that mp3/mp4/aac/webm/ogg files contain fltp samples + audio_codec = audio_streams[0].get("codec_name") + if audio_streams[0].get("sample_fmt") == "fltp" and audio_codec in [ + "mp3", + "mp4", + "aac", + "webm", + "ogg", + ]: + bits_per_sample = 16 + else: + bits_per_sample = audio_streams[0]["bits_per_sample"] + acodec = "pcm_u8" if bits_per_sample == 8 else "pcm_s%dle" % bits_per_sample + + conversion_command += ["-acodec", acodec] + + conversion_command += [ + "-vn", # Drop any video streams if there are any + "-f", + "wav", # output options (filename last) + ] + + if start_second is not None: + conversion_command += ["-ss", str(start_second)] + + if duration is not None: + conversion_command += ["-t", str(duration)] + + conversion_command += ["-"] + + if parameters is not None: + # extend arguments with arbitrary set + conversion_command.extend(parameters) + + log_conversion(conversion_command) + + # PATCHED + p = promptless_Popen( + conversion_command, + stdin=stdin_parameter, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + p_out, p_err = p.communicate(input=stdin_data) + + if p.returncode != 0 or len(p_out) == 0: + if close_file: + file.close() + raise CouldntDecodeError( + "Decoding failed. ffmpeg returned error code: " # noqa: UP030 + "{0}\n\nOutput from ffmpeg/avlib:\n\n{1}".format( + p.returncode, p_err.decode(errors="ignore") + ) + ) + + p_out = bytearray(p_out) + fix_wav_headers(p_out) + p_out = bytes(p_out) + obj = cls(p_out) + + if close_file: + file.close() + + if start_second is None and duration is None: + return obj + elif start_second is not None and duration is None: + return obj[0:] + elif start_second is None and duration is not None: + return obj[: duration * 1000] + else: + return obj[0 : duration * 1000] + + @classmethod + def from_mp3(cls, file, parameters=None): + return cls.from_file(file, "mp3", parameters=parameters) + + @classmethod + def from_flv(cls, file, parameters=None): + return cls.from_file(file, "flv", parameters=parameters) + + @classmethod + def from_ogg(cls, file, parameters=None): + return cls.from_file(file, "ogg", parameters=parameters) + + @classmethod + def from_wav(cls, file, parameters=None): + return cls.from_file(file, "wav", parameters=parameters) + + @classmethod + def from_raw(cls, file, **kwargs): + return cls.from_file( + file, + "raw", + sample_width=kwargs["sample_width"], + frame_rate=kwargs["frame_rate"], + channels=kwargs["channels"], + ) + + @classmethod + def _from_safe_wav(cls, file): + file, close_file = _fd_or_path_or_tempfile(file, "rb", tempfile=False) + file.seek(0) + obj = cls(data=file) + if close_file: + file.close() + return obj + + def export( + self, + out_f=None, + format="mp3", + codec=None, + bitrate=None, + parameters=None, + tags=None, + id3v2_version="4", + cover=None, + ): + """Export an AudioSegment to a file with the given options. + + out_f (string): + Path to destination audio file. Also accepts os.PathLike objects on + python >= 3.6 + + format (string) + Format for destination audio file. + ('mp3', 'wav', 'raw', 'ogg' or other ffmpeg/avconv supported files) + + codec (string) + Codec used to encode the destination file. + + bitrate (string) + Bitrate used when encoding destination file. (64, 92, 128, 256, 312k...) + Each codec accepts different bitrate arguments so take a look at the + ffmpeg documentation for details (bitrate usually shown as -b, -ba or + -a:b). + + parameters (list of strings) + Aditional ffmpeg/avconv parameters + + tags (dict) + Set metadata information to destination files + usually used as tags. ({title='Song Title', artist='Song Artist'}) + + id3v2_version (string) + Set ID3v2 version for tags. (default: '4') + + cover (file) + Set cover for audio file from image file. (png or jpg) + """ + id3v2_allowed_versions = ["3", "4"] + + if format == "raw" and (codec is not None or parameters is not None): + raise AttributeError( + 'Can not invoke ffmpeg when export format is "raw"; ' + 'specify an ffmpeg raw format like format="s16le" instead ' + 'or call export(format="raw") with no codec or parameters' + ) + + out_f, _ = _fd_or_path_or_tempfile(out_f, "wb+") + out_f.seek(0) + + if format == "raw": + out_f.write(self._data) + out_f.seek(0) + return out_f + + # wav with no ffmpeg parameters can just be written directly to out_f + easy_wav = format == "wav" and codec is None and parameters is None + + data = out_f if easy_wav else NamedTemporaryFile(mode="wb", delete=False) + + pcm_for_wav = self._data + if self.sample_width == 1: + # convert to unsigned integers for wav + pcm_for_wav = audioop.bias(self._data, 1, 128) + + wave_data = wave.open(data, "wb") + wave_data.setnchannels(self.channels) + wave_data.setsampwidth(self.sample_width) + wave_data.setframerate(self.frame_rate) + # For some reason packing the wave header struct with + # a float in python 2 doesn't throw an exception + wave_data.setnframes(int(self.frame_count())) + wave_data.writeframesraw(pcm_for_wav) + wave_data.close() + + # for easy wav files, we're done (wav data is written directly to out_f) + if easy_wav: + out_f.seek(0) + return out_f + + output = NamedTemporaryFile(mode="w+b", delete=False) + + # build converter command to export + conversion_command = [ + self.converter, + "-y", # always overwrite existing files + "-f", + "wav", + "-i", + data.name, # input options (filename last) + ] + + if codec is None: + codec = self.DEFAULT_CODECS.get(format, None) + + if cover is not None: + if ( + cover.lower().endswith((".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff")) + and format == "mp3" + ): + conversion_command.extend(["-i", cover, "-map", "0", "-map", "1", "-c:v", "mjpeg"]) + else: + raise AttributeError( + "Currently cover images are only supported by MP3 files. The allowed image " + "formats are: .tif, .jpg, .bmp, .jpeg and .png." + ) + + if codec is not None: + # force audio encoder + conversion_command.extend(["-acodec", codec]) + + if bitrate is not None: + conversion_command.extend(["-b:a", bitrate]) + + if parameters is not None: + # extend arguments with arbitrary set + conversion_command.extend(parameters) + + if tags is not None: + if not isinstance(tags, dict): + raise InvalidTag("Tags must be a dictionary.") + else: + # Extend converter command with tags + # print(tags) + for key, value in tags.items(): + conversion_command.extend(["-metadata", f"{key}={value}"]) + + if format == "mp3": + # set id3v2 tag version + if id3v2_version not in id3v2_allowed_versions: + raise InvalidID3TagVersion( + "id3v2_version not allowed, allowed versions: %s" # noqa: UP031 + % id3v2_allowed_versions + ) + conversion_command.extend(["-id3v2_version", id3v2_version]) + + if sys.platform == "darwin" and codec == "mp3": + conversion_command.extend(["-write_xing", "0"]) + + conversion_command.extend( + [ + "-f", + format, + output.name, # output options (filename last) + ] + ) + + log_conversion(conversion_command) + + # read stdin / write stdout + with open(os.devnull, "rb") as devnull: + # PATCHED + p = promptless_Popen( + conversion_command, stdin=devnull, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + p_out, p_err = p.communicate() + + log_subprocess_output(p_out) + log_subprocess_output(p_err) + + try: + if p.returncode != 0: + raise CouldntEncodeError( + "Encoding failed. ffmpeg/avlib returned error code: " # noqa: UP030 + "{0}\n\nCommand:{1}\n\nOutput from ffmpeg/avlib:\n\n{2}".format( + p.returncode, conversion_command, p_err.decode(errors="ignore") + ) + ) + + output.seek(0) + out_f.write(output.read()) + + finally: + data.close() + output.close() + os.unlink(data.name) + os.unlink(output.name) + + out_f.seek(0) + return out_f + + def get_frame(self, index): + frame_start = index * self.frame_width + frame_end = frame_start + self.frame_width + return self._data[frame_start:frame_end] + + def frame_count(self, ms=None): + """Return the number of frames for the given number of milliseconds. + + If not specified, return the number of frames in the whole AudioSegment + """ + if ms is not None: + return ms * (self.frame_rate / 1000.0) + else: + return float(len(self._data) // self.frame_width) + + def set_sample_width(self, sample_width): + if sample_width == self.sample_width: + return self + + frame_width = self.channels * sample_width + + return self._spawn( + audioop.lin2lin(self._data, self.sample_width, sample_width), + overrides={"sample_width": sample_width, "frame_width": frame_width}, + ) + + def set_frame_rate(self, frame_rate): + if frame_rate == self.frame_rate: + return self + + if self._data: + converted, _ = audioop.ratecv( + self._data, self.sample_width, self.channels, self.frame_rate, frame_rate, None + ) + else: + converted = self._data + + return self._spawn(data=converted, overrides={"frame_rate": frame_rate}) + + def set_channels(self, channels): + if channels == self.channels: + return self + + if channels == 2 and self.channels == 1: + fn = audioop.tostereo + frame_width = self.frame_width * 2 + fac = 1 + converted = fn(self._data, self.sample_width, fac, fac) + elif channels == 1 and self.channels == 2: + fn = audioop.tomono + frame_width = self.frame_width // 2 + fac = 0.5 + converted = fn(self._data, self.sample_width, fac, fac) + elif channels == 1: + channels_data = [seg.get_array_of_samples() for seg in self.split_to_mono()] + frame_count = int(self.frame_count()) + converted = array.array( + channels_data[0].typecode, b"\0" * (frame_count * self.sample_width) + ) + for raw_channel_data in channels_data: + for i in range(frame_count): + converted[i] += raw_channel_data[i] // self.channels + frame_width = self.frame_width // self.channels + elif self.channels == 1: + dup_channels = [self for iChannel in range(channels)] + return _AudioSegment.from_mono_audiosegments(*dup_channels) + else: + raise ValueError( + "AudioSegment.set_channels only supports mono-to-multi channel " + "and multi-to-mono channel conversion" + ) + + return self._spawn( + data=converted, overrides={"channels": channels, "frame_width": frame_width} + ) + + def split_to_mono(self): + if self.channels == 1: + return [self] + + samples = self.get_array_of_samples() + + mono_channels = [] + for i in range(self.channels): + samples_for_current_channel = samples[i :: self.channels] + + try: + mono_data = samples_for_current_channel.tobytes() + except AttributeError: + mono_data = samples_for_current_channel.tostring() + + mono_channels.append( + self._spawn(mono_data, overrides={"channels": 1, "frame_width": self.sample_width}) + ) + + return mono_channels + + @property + def rms(self): + return audioop.rms(self._data, self.sample_width) + + @property + def dBFS(self): # noqa: N802 + rms = self.rms + if not rms: + return -float("infinity") + return ratio_to_db(self.rms / self.max_possible_amplitude) + + @property + def max(self): + return audioop.max(self._data, self.sample_width) + + @property + def max_possible_amplitude(self): + bits = self.sample_width * 8 + max_possible_val = 2**bits + + # since half is above 0 and half is below the max amplitude is divided + return max_possible_val / 2 + + @property + def max_dBFS(self): + return ratio_to_db(self.max, self.max_possible_amplitude) + + @property + def duration_seconds(self): + return self.frame_rate and self.frame_count() / self.frame_rate or 0.0 + + def get_dc_offset(self, channel=1): + """Return a value between -1.0 and 1.0 representing the DC offset of a channel. + + 1 for left, 2 for right. + """ + if not 1 <= channel <= 2: + raise ValueError("channel value must be 1 (left) or 2 (right)") + + if self.channels == 1: + data = self._data + elif channel == 1: + data = audioop.tomono(self._data, self.sample_width, 1, 0) + else: + data = audioop.tomono(self._data, self.sample_width, 0, 1) + + return float(audioop.avg(data, self.sample_width)) / self.max_possible_amplitude + + def remove_dc_offset(self, channel=None, offset=None): + """Remove DC offset of given channel. Calculates offset if it's not given. + + Offset values must be in range -1.0 to 1.0. If channel is None, removes + DC offset from all available channels. + """ + if channel and not 1 <= channel <= 2: + raise ValueError("channel value must be None, 1 (left) or 2 (right)") + + if offset and not -1.0 <= offset <= 1.0: + raise ValueError("offset value must be in range -1.0 to 1.0") + + if offset: + offset = int(round(offset * self.max_possible_amplitude)) + + def remove_data_dc(data, off): + if not off: + off = audioop.avg(data, self.sample_width) + return audioop.bias(data, self.sample_width, -off) + + if self.channels == 1: + return self._spawn(data=remove_data_dc(self._data, offset)) + + left_channel = audioop.tomono(self._data, self.sample_width, 1, 0) + right_channel = audioop.tomono(self._data, self.sample_width, 0, 1) + + if not channel or channel == 1: + left_channel = remove_data_dc(left_channel, offset) + + if not channel or channel == 2: + right_channel = remove_data_dc(right_channel, offset) + + left_channel = audioop.tostereo(left_channel, self.sample_width, 1, 0) + right_channel = audioop.tostereo(right_channel, self.sample_width, 0, 1) + + return self._spawn(data=audioop.add(left_channel, right_channel, self.sample_width)) + + def apply_gain(self, volume_change): + return self._spawn( + data=audioop.mul(self._data, self.sample_width, db_to_float(float(volume_change))) + ) + + def overlay(self, seg, position=0, loop=False, times=None, gain_during_overlay=None): + """Overlay the provided segment on to this segment. + + Starts at the specified position and uses the specified looping behavior. + + seg (AudioSegment): + The audio segment to overlay on to this one. + + position (optional int): + The position to start overlaying the provided segment in to this + one. + + loop (optional bool): + Loop seg as many times as necessary to match this segment's length. + Overrides loops param. + + times (optional int): + Loop seg the specified number of times or until it matches this + segment's length. 1 means once, 2 means twice, ... 0 would make the + call a no-op + gain_during_overlay (optional int): + Changes this segment's volume by the specified amount during the + duration of time that seg is overlaid on top of it. When negative, + this has the effect of 'ducking' the audio under the overlay. + """ + if loop: + # match loop=True's behavior with new times (count) mechanism. + times = -1 + elif times is None: + # no times specified, just once through + times = 1 + elif times == 0: + # it's a no-op, make a copy since we never mutate + return self._spawn(self._data) + + output = StringIO() + + seg1, seg2 = _AudioSegment._sync(self, seg) + sample_width = seg1.sample_width + spawn = seg1._spawn + + output.write(seg1[:position]._data) + + # drop down to the raw data + seg1 = seg1[position:]._data + seg2 = seg2._data + pos = 0 + seg1_len = len(seg1) + seg2_len = len(seg2) + while times: + remaining = max(0, seg1_len - pos) + if seg2_len >= remaining: + seg2 = seg2[:remaining] + seg2_len = remaining + # we've hit the end, we're done looping (if we were) and this + # is our last go-around + times = 1 + + if gain_during_overlay: + seg1_overlaid = seg1[pos : pos + seg2_len] + seg1_adjusted_gain = audioop.mul( + seg1_overlaid, self.sample_width, db_to_float(float(gain_during_overlay)) + ) + output.write(audioop.add(seg1_adjusted_gain, seg2, sample_width)) + else: + output.write(audioop.add(seg1[pos : pos + seg2_len], seg2, sample_width)) + pos += seg2_len + + # dec times to break our while loop (eventually) + times -= 1 + + output.write(seg1[pos:]) + + return spawn(data=output) + + def append(self, seg, crossfade=100): + seg1, seg2 = _AudioSegment._sync(self, seg) + + if not crossfade: + return seg1._spawn(seg1._data + seg2._data) + elif crossfade > len(self): + raise ValueError( + f"Crossfade is longer than the original AudioSegment ({crossfade}ms > {len(self)}ms)" + ) + elif crossfade > len(seg): + raise ValueError( + f"Crossfade is longer than the appended AudioSegment ({crossfade}ms > {len(seg)}ms)" + ) + + xf = seg1[-crossfade:].fade(to_gain=-120, start=0, end=float("inf")) + xf *= seg2[:crossfade].fade(from_gain=-120, start=0, end=float("inf")) + + output = BytesIO() + + output.write(seg1[:-crossfade]._data) + output.write(xf._data) + output.write(seg2[crossfade:]._data) + + output.seek(0) + obj = seg1._spawn(data=output) + output.close() + return obj + + def fade(self, to_gain=0, from_gain=0, start=None, end=None, duration=None): + """Fade the volume of this audio segment. + + to_gain (float): + resulting volume_change in db + + start (int): + default = beginning of the segment + when in this segment to start fading in milliseconds + + end (int): + default = end of the segment + when in this segment to start fading in milliseconds + + duration (int): + default = until the end of the audio segment + the duration of the fade + """ + if None not in [duration, end, start]: + raise TypeError( + 'Only two of the three arguments, "start", ' + '"end", and "duration" may be specified' + ) + + # no fade == the same audio + if to_gain == 0 and from_gain == 0: + return self + + start = min(len(self), start) if start is not None else None + end = min(len(self), end) if end is not None else None + + if start is not None and start < 0: + start += len(self) + if end is not None and end < 0: + end += len(self) + + if duration is not None and duration < 0: + raise InvalidDuration("duration must be a positive integer") + + if duration: + if start is not None: + end = start + duration + elif end is not None: + start = end - duration + else: + duration = end - start + + from_power = db_to_float(from_gain) + + output = [] + + # original data - up until the crossfade portion, as is + before_fade = self[:start]._data + if from_gain != 0: + before_fade = audioop.mul(before_fade, self.sample_width, from_power) + output.append(before_fade) + + gain_delta = db_to_float(to_gain) - from_power + + # fades longer than 100ms can use coarse fading (one gain step per ms), + # shorter fades will have audible clicks so they use precise fading + # (one gain step per sample) + if duration > 100: + scale_step = gain_delta / duration + + for i in range(duration): + volume_change = from_power + (scale_step * i) + chunk = self[start + i] + chunk = audioop.mul(chunk._data, self.sample_width, volume_change) + + output.append(chunk) + else: + start_frame = self.frame_count(ms=start) + end_frame = self.frame_count(ms=end) + fade_frames = end_frame - start_frame + scale_step = gain_delta / fade_frames + + for i in range(int(fade_frames)): + volume_change = from_power + (scale_step * i) + sample = self.get_frame(int(start_frame + i)) + sample = audioop.mul(sample, self.sample_width, volume_change) + + output.append(sample) + + # original data after the crossfade portion, at the new volume + after_fade = self[end:]._data + if to_gain != 0: + after_fade = audioop.mul(after_fade, self.sample_width, db_to_float(to_gain)) + output.append(after_fade) + + return self._spawn(data=output) + + def fade_out(self, duration): + return self.fade(to_gain=-120, duration=duration, end=float("inf")) + + def fade_in(self, duration): + return self.fade(from_gain=-120, duration=duration, start=0) + + def reverse(self): + return self._spawn(data=audioop.reverse(self._data, self.sample_width)) + + def _repr_html_(self): + src = """ + + """ + fh = self.export() + data = base64.b64encode(fh.read()).decode("ascii") + return src.format(base64=data) diff --git a/tagstudio/src/qt/helpers/vendored/pydub/utils.py b/tagstudio/src/qt/helpers/vendored/pydub/utils.py new file mode 100644 index 00000000..5c9e3c4b --- /dev/null +++ b/tagstudio/src/qt/helpers/vendored/pydub/utils.py @@ -0,0 +1,89 @@ +import json +import re +import subprocess + +from pydub.utils import ( + _fd_or_path_or_tempfile, + fsdecode, + get_extra_info, + get_prober_name, +) +from src.qt.helpers.silent_popen import promptless_Popen + + +def _mediainfo_json(filepath, read_ahead_limit=-1): + """Return json dictionary with media info(codec, duration, size, bitrate...) from filepath.""" + prober = get_prober_name() + command_args = [ + "-v", + "info", + "-show_format", + "-show_streams", + ] + try: + command_args += [fsdecode(filepath)] + stdin_parameter = None + stdin_data = None + except TypeError: + if prober == "ffprobe": + command_args += ["-read_ahead_limit", str(read_ahead_limit), "cache:pipe:0"] + else: + command_args += ["-"] + stdin_parameter = subprocess.PIPE + file, close_file = _fd_or_path_or_tempfile(filepath, "rb", tempfile=False) + file.seek(0) + stdin_data = file.read() + if close_file: + file.close() + + command = [prober, "-of", "json"] + command_args + # PATCHED + res = promptless_Popen( + command, stdin=stdin_parameter, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + output, stderr = res.communicate(input=stdin_data) + output = output.decode("utf-8", "ignore") + stderr = stderr.decode("utf-8", "ignore") + + try: + info = json.loads(output) + except json.decoder.JSONDecodeError: + # If ffprobe didn't give any information, just return it + # (for example, because the file doesn't exist) + return None + if not info: + return info + + extra_info = get_extra_info(stderr) + + audio_streams = [x for x in info["streams"] if x["codec_type"] == "audio"] + if len(audio_streams) == 0: + return info + + # We just operate on the first audio stream in case there are more + stream = audio_streams[0] + + def set_property(stream, prop, value): + if prop not in stream or stream[prop] == 0: + stream[prop] = value + + for token in extra_info[stream["index"]]: + m = re.match(r"([su]([0-9]{1,2})p?) \(([0-9]{1,2}) bit\)$", token) + m2 = re.match(r"([su]([0-9]{1,2})p?)( \(default\))?$", token) + if m: + set_property(stream, "sample_fmt", m.group(1)) + set_property(stream, "bits_per_sample", int(m.group(2))) + set_property(stream, "bits_per_raw_sample", int(m.group(3))) + elif m2: + set_property(stream, "sample_fmt", m2.group(1)) + set_property(stream, "bits_per_sample", int(m2.group(2))) + set_property(stream, "bits_per_raw_sample", int(m2.group(2))) + elif re.match(r"(flt)p?( \(default\))?$", token): + set_property(stream, "sample_fmt", token) + set_property(stream, "bits_per_sample", 32) + set_property(stream, "bits_per_raw_sample", 32) + elif re.match(r"(dbl)p?( \(default\))?$", token): + set_property(stream, "sample_fmt", token) + set_property(stream, "bits_per_sample", 64) + set_property(stream, "bits_per_raw_sample", 64) + return info diff --git a/tagstudio/src/qt/main_window.py b/tagstudio/src/qt/main_window.py index ce6b1e33..3de0118d 100644 --- a/tagstudio/src/qt/main_window.py +++ b/tagstudio/src/qt/main_window.py @@ -15,13 +15,13 @@ import logging import typing -from PySide6.QtCore import (QCoreApplication, QMetaObject, QRect,QSize, Qt) +from PySide6.QtCore import (QCoreApplication, QMetaObject, QRect,QSize, Qt, QStringListModel) from PySide6.QtGui import QFont from PySide6.QtWidgets import (QComboBox, QFrame, QGridLayout, QHBoxLayout, QVBoxLayout, QLayout, QLineEdit, QMainWindow, QPushButton, QScrollArea, QSizePolicy, QStatusBar, QWidget, QSplitter, QCheckBox, - QSpacerItem) + QSpacerItem, QCompleter) from src.qt.pagination import Pagination from src.qt.widgets.landing import LandingWidget @@ -36,7 +36,7 @@ class Ui_MainWindow(QMainWindow): def __init__(self, driver: "QtDriver", parent=None) -> None: super().__init__(parent) - self.driver = driver + self.driver: "QtDriver" = driver self.setupUi(self) # NOTE: These are old attempts to allow for a translucent/acrylic @@ -66,7 +66,7 @@ class Ui_MainWindow(QMainWindow): self.horizontalLayout = QHBoxLayout() self.horizontalLayout.setObjectName(u"horizontalLayout") - # ComboBox goup for search type and thumbnail size + # ComboBox group for search type and thumbnail size self.horizontalLayout_3 = QHBoxLayout() self.horizontalLayout_3.setObjectName("horizontalLayout_3") @@ -74,26 +74,18 @@ class Ui_MainWindow(QMainWindow): spacerItem = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum) self.horizontalLayout_3.addItem(spacerItem) - # Search type selector - self.comboBox_2 = QComboBox(self.centralwidget) - self.comboBox_2.setMinimumSize(QSize(165, 0)) - self.comboBox_2.setObjectName("comboBox_2") - self.comboBox_2.addItem("") - self.comboBox_2.addItem("") - self.horizontalLayout_3.addWidget(self.comboBox_2) - # Thumbnail Size placeholder - self.comboBox = QComboBox(self.centralwidget) - self.comboBox.setObjectName(u"comboBox") + self.thumb_size_combobox = QComboBox(self.centralwidget) + self.thumb_size_combobox.setObjectName(u"thumbSizeComboBox") sizePolicy = QSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth( - self.comboBox.sizePolicy().hasHeightForWidth()) - self.comboBox.setSizePolicy(sizePolicy) - self.comboBox.setMinimumWidth(128) - self.comboBox.setMaximumWidth(128) - self.horizontalLayout_3.addWidget(self.comboBox) + self.thumb_size_combobox.sizePolicy().hasHeightForWidth()) + self.thumb_size_combobox.setSizePolicy(sizePolicy) + self.thumb_size_combobox.setMinimumWidth(128) + self.thumb_size_combobox.setMaximumWidth(352) + self.horizontalLayout_3.addWidget(self.thumb_size_combobox) self.gridLayout.addLayout(self.horizontalLayout_3, 5, 0, 1, 1) self.splitter = QSplitter() @@ -167,6 +159,11 @@ class Ui_MainWindow(QMainWindow): font2.setBold(False) self.searchField.setFont(font2) + self.searchFieldCompletionList = QStringListModel() + self.searchFieldCompleter = QCompleter(self.searchFieldCompletionList, self.searchField) + self.searchFieldCompleter.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) + self.searchField.setCompleter(self.searchFieldCompleter) + self.horizontalLayout_2.addWidget(self.searchField) self.searchButton = QPushButton(self.centralwidget) @@ -209,13 +206,10 @@ class Ui_MainWindow(QMainWindow): self.searchButton.setText( QCoreApplication.translate("MainWindow", u"Search", None)) - # Search type selector - self.comboBox_2.setItemText(0, QCoreApplication.translate("MainWindow", "And (Includes All Tags)")) - self.comboBox_2.setItemText(1, QCoreApplication.translate("MainWindow", "Or (Includes Any Tag)")) - self.comboBox.setCurrentText("") + self.thumb_size_combobox.setCurrentText("") # Thumbnail size selector - self.comboBox.setPlaceholderText( + self.thumb_size_combobox.setPlaceholderText( QCoreApplication.translate("MainWindow", u"Thumbnail Size", None)) # retranslateUi @@ -236,3 +230,4 @@ class Ui_MainWindow(QMainWindow): self.landing_widget.setHidden(True) self.landing_widget.set_status_label("") self.scrollArea.setHidden(False) + \ No newline at end of file diff --git a/tagstudio/src/qt/modals/build_tag.py b/tagstudio/src/qt/modals/build_tag.py index d8f77f65..b375f49e 100644 --- a/tagstudio/src/qt/modals/build_tag.py +++ b/tagstudio/src/qt/modals/build_tag.py @@ -3,25 +3,31 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio +import math +from typing import cast + import structlog +from PySide6 import QtCore from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import ( + QAction, +) from PySide6.QtWidgets import ( + QApplication, QComboBox, - QFrame, QLabel, QLineEdit, QPushButton, - QScrollArea, - QTextEdit, QVBoxLayout, QWidget, ) from src.core.library import Library, Tag from src.core.library.alchemy.enums import TagColor -from src.core.palette import ColorType, get_tag_color +from src.core.palette import ColorType, UiColor, get_tag_color, get_ui_color +from src.qt.flowlayout import FlowLayout from src.qt.modals.tag_search import TagSearchPanel from src.qt.widgets.panel import PanelModal, PanelWidget -from src.qt.widgets.tag import TagWidget +from src.qt.widgets.tag import TagAliasWidget, TagWidget logger = structlog.get_logger(__name__) @@ -51,6 +57,9 @@ class BuildTagPanel(PanelWidget): self.name_title.setText("Name") self.name_layout.addWidget(self.name_title) self.name_field = QLineEdit() + self.name_field.setFixedHeight(24) + self.name_field.textChanged.connect(self.on_name_changed) + self.name_field.setPlaceholderText("Tag Name (Required)") self.name_layout.addWidget(self.name_field) # Shorthand ------------------------------------------------------------ @@ -76,13 +85,47 @@ class BuildTagPanel(PanelWidget): self.aliases_title = QLabel() self.aliases_title.setText("Aliases") self.aliases_layout.addWidget(self.aliases_title) - self.aliases_field = QTextEdit() - self.aliases_field.setAcceptRichText(False) - self.aliases_field.setMinimumHeight(40) - self.aliases_field.setTabChangesFocus(True) - self.aliases_layout.addWidget(self.aliases_field) + + self.aliases_flow_widget = QWidget() + self.aliases_flow_layout = FlowLayout(self.aliases_flow_widget) + self.aliases_flow_layout.setContentsMargins(0, 0, 0, 0) + self.aliases_flow_layout.enable_grid_optimizations(value=False) + + self.alias_add_button = QPushButton() + self.alias_add_button.setMinimumSize(23, 23) + self.alias_add_button.setMaximumSize(23, 23) + self.alias_add_button.setText("+") + self.alias_add_button.setToolTip("CTRL + A") + self.alias_add_button.setShortcut( + QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier), + QtCore.Qt.Key.Key_A, + ) + ) + self.alias_add_button.setStyleSheet( + f"QPushButton{{" + f"background: #1e1e1e;" + f"color: #FFFFFF;" + f"font-weight: bold;" + f"border-color: #333333;" + f"border-radius: 6px;" + f"border-style:solid;" + f"border-width:{math.ceil(self.devicePixelRatio())}px;" + f"padding-bottom: 5px;" + f"font-size: 20px;" + f"}}" + f"QPushButton::hover" + f"{{" + f"border-color: #CCCCCC;" + f"background: #555555;" + f"}}" + ) + + self.alias_add_button.clicked.connect(lambda: self.add_alias_callback()) + self.aliases_flow_layout.addWidget(self.alias_add_button) # Subtags ------------------------------------------------------------ + self.subtags_widget = QWidget() self.subtags_layout = QVBoxLayout(self.subtags_widget) self.subtags_layout.setStretch(1, 1) @@ -94,28 +137,52 @@ class BuildTagPanel(PanelWidget): self.subtags_title.setText("Parent Tags") self.subtags_layout.addWidget(self.subtags_title) - self.scroll_contents = QWidget() - self.scroll_layout = QVBoxLayout(self.scroll_contents) - self.scroll_layout.setContentsMargins(6, 0, 6, 0) - self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop) - - self.scroll_area = QScrollArea() - # self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn) - self.scroll_area.setWidgetResizable(True) - self.scroll_area.setFrameShadow(QFrame.Shadow.Plain) - self.scroll_area.setFrameShape(QFrame.Shape.NoFrame) - self.scroll_area.setWidget(self.scroll_contents) - # self.scroll_area.setMinimumHeight(60) - - self.subtags_layout.addWidget(self.scroll_area) + self.subtag_flow_widget = QWidget() + self.subtag_flow_layout = FlowLayout(self.subtag_flow_widget) + self.subtag_flow_layout.setContentsMargins(0, 0, 0, 0) + self.subtag_flow_layout.enable_grid_optimizations(value=False) self.subtags_add_button = QPushButton() + self.subtags_add_button.setCursor(Qt.CursorShape.PointingHandCursor) self.subtags_add_button.setText("+") - tsp = TagSearchPanel(self.lib) + self.subtags_add_button.setToolTip("CTRL + P") + self.subtags_add_button.setMinimumSize(23, 23) + self.subtags_add_button.setMaximumSize(23, 23) + self.subtags_add_button.setShortcut( + QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier), + QtCore.Qt.Key.Key_P, + ) + ) + self.subtags_add_button.setStyleSheet( + f"QPushButton{{" + f"background: #1e1e1e;" + f"color: #FFFFFF;" + f"font-weight: bold;" + f"border-color: #333333;" + f"border-radius: 6px;" + f"border-style:solid;" + f"border-width:{math.ceil(self.devicePixelRatio())}px;" + f"padding-bottom: 5px;" + f"font-size: 20px;" + f"}}" + f"QPushButton::hover" + f"{{" + f"border-color: #CCCCCC;" + f"background: #555555;" + f"}}" + ) + self.subtag_flow_layout.addWidget(self.subtags_add_button) + + exclude_ids: list[int] = list() + if tag is not None: + exclude_ids.append(tag.id) + + tsp = TagSearchPanel(self.lib, exclude_ids) tsp.tag_chosen.connect(lambda x: self.add_subtag_callback(x)) self.add_tag_modal = PanelModal(tsp, "Add Parent Tags", "Add Parent Tags") self.subtags_add_button.clicked.connect(self.add_tag_modal.show) - self.subtags_layout.addWidget(self.subtags_add_button) + # self.subtags_layout.addWidget(self.subtags_add_button) # self.subtags_field = TagBoxWidget() # self.subtags_field.setMinimumHeight(60) @@ -136,7 +203,7 @@ class BuildTagPanel(PanelWidget): self.color_field.setMaxVisibleItems(10) self.color_field.setStyleSheet("combobox-popup:0;") for color in TagColor: - self.color_field.addItem(color.name, userData=color.value) + self.color_field.addItem(color.name.replace("_", " ").title(), userData=color.value) # self.color_field.setProperty("appearance", "flat") self.color_field.currentIndexChanged.connect( lambda c: ( @@ -145,71 +212,223 @@ class BuildTagPanel(PanelWidget): "font-weight:600;" f"color:{get_tag_color(ColorType.TEXT, self.color_field.currentData())};" f"background-color:{get_tag_color( - ColorType.PRIMARY, + ColorType.PRIMARY, self.color_field.currentData())};" ) ) ) self.color_layout.addWidget(self.color_field) + remove_selected_alias_action = QAction("remove selected alias", self) + remove_selected_alias_action.triggered.connect(self.remove_selected_alias) + remove_selected_alias_action.setShortcut( + QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier), + QtCore.Qt.Key.Key_D, + ) + ) + self.addAction(remove_selected_alias_action) # Add Widgets to Layout ================================================ self.root_layout.addWidget(self.name_widget) self.root_layout.addWidget(self.shorthand_widget) self.root_layout.addWidget(self.aliases_widget) + self.root_layout.addWidget(self.aliases_flow_widget) self.root_layout.addWidget(self.subtags_widget) + self.root_layout.addWidget(self.subtag_flow_widget) self.root_layout.addWidget(self.color_widget) # self.parent().done.connect(self.update_tag) - # TODO - fill subtags - self.subtags: set[int] = set() + self.subtag_ids: set[int] = set() + self.alias_ids: set[int] = set() + self.alias_names: set[str] = set() + self.new_alias_names: dict = dict() + self.set_tag(tag or Tag(name="New Tag")) + def keyPressEvent(self, event): # noqa: N802 + if event.key() == Qt.Key_Return or event.key() == Qt.Key_Enter: # type: ignore + focused_widget = QApplication.focusWidget() + if isinstance(focused_widget.parent(), TagAliasWidget): + self.add_alias_callback() + + def remove_selected_alias(self): + count = self.aliases_flow_layout.count() + if count <= 0: + return + + focused_widget = QApplication.focusWidget() + + if focused_widget is None: + return + + if isinstance(focused_widget.parent(), TagAliasWidget): + cast(TagAliasWidget, focused_widget.parent()).on_remove.emit() + + count = self.aliases_flow_layout.count() + if count > 1: + cast( + TagAliasWidget, self.aliases_flow_layout.itemAt(count - 2).widget() + ).text_field.setFocus() + else: + self.alias_add_button.setFocus() + def add_subtag_callback(self, tag_id: int): logger.info("add_subtag_callback", tag_id=tag_id) - self.subtags.add(tag_id) + self.subtag_ids.add(tag_id) self.set_subtags() def remove_subtag_callback(self, tag_id: int): logger.info("removing subtag", tag_id=tag_id) - self.subtags.remove(tag_id) + self.subtag_ids.remove(tag_id) self.set_subtags() - def set_subtags(self): - while self.scroll_layout.itemAt(0): - self.scroll_layout.takeAt(0).widget().deleteLater() + def add_alias_callback(self): + logger.info("add_alias_callback") + # bug passing in the text for a here means when the text changes + # the remove callback uses what a whas initialy assigned + new_field = TagAliasWidget() + id = new_field.__hash__() + new_field.id = id - c = QWidget() - layout = QVBoxLayout(c) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(3) - for tag_id in self.subtags: + new_field.on_remove.connect(lambda a="": self.remove_alias_callback(a, id)) + new_field.setMaximumHeight(25) + new_field.setMinimumHeight(25) + + self.alias_ids.add(id) + self.new_alias_names[id] = "" + self.aliases_flow_layout.addWidget(new_field) + new_field.text_field.setFocus() + self.aliases_flow_layout.addWidget(self.alias_add_button) + + def remove_alias_callback(self, alias_name: str, alias_id: int | None = None): + logger.info("remove_alias_callback") + self.alias_ids.remove(alias_id) + self._set_aliases() + + def set_subtags(self): + while self.subtag_flow_layout.itemAt(1): + self.subtag_flow_layout.takeAt(0).widget().deleteLater() + + for tag_id in self.subtag_ids: tag = self.lib.get_tag(tag_id) tw = TagWidget(tag, has_edit=False, has_remove=True) tw.on_remove.connect(lambda t=tag_id: self.remove_subtag_callback(t)) - layout.addWidget(tw) - self.scroll_layout.addWidget(c) + self.subtag_flow_layout.addWidget(tw) + + self.subtag_flow_layout.addWidget(self.subtags_add_button) + + def add_aliases(self): + fields: set[TagAliasWidget] = set() + for i in range(0, self.aliases_flow_layout.count() - 1): + widget = self.aliases_flow_layout.itemAt(i).widget() + + if not isinstance(widget, TagAliasWidget): + return + + field: TagAliasWidget = cast(TagAliasWidget, widget) + fields.add(field) + + remove: set[str] = self.alias_names - set([a.text_field.text() for a in fields]) + + self.alias_names = self.alias_names - remove + + for field in fields: + # add new aliases + if field.text_field.text() != "": + self.alias_names.add(field.text_field.text()) + + def _update_new_alias_name_dict(self): + for i in range(0, self.aliases_flow_layout.count() - 1): + widget = self.aliases_flow_layout.itemAt(i).widget() + + if not isinstance(widget, TagAliasWidget): + return + + field: TagAliasWidget = cast(TagAliasWidget, widget) + text_field_text = field.text_field.text() + + self.new_alias_names[field.id] = text_field_text + + def _set_aliases(self): + self._update_new_alias_name_dict() + + while self.aliases_flow_layout.itemAt(1): + self.aliases_flow_layout.takeAt(0).widget().deleteLater() + + self.alias_names.clear() + + for alias_id in self.alias_ids: + alias = self.lib.get_alias(self.tag.id, alias_id) + + alias_name = alias.name if alias else self.new_alias_names[alias_id] + + new_field = TagAliasWidget( + alias_id, + alias_name, + lambda a=alias_name, id=alias_id: self.remove_alias_callback(a, id), + ) + new_field.setMaximumHeight(25) + new_field.setMinimumHeight(25) + self.aliases_flow_layout.addWidget(new_field) + self.alias_names.add(alias_name) + + self.aliases_flow_layout.addWidget(self.alias_add_button) def set_tag(self, tag: Tag): + self.tag = tag + + self.tag = tag + logger.info("setting tag", tag=tag) self.name_field.setText(tag.name) self.shorthand_field.setText(tag.shorthand or "") - # TODO: Implement aliases - # self.aliases_field.setText("\n".join(tag.aliases)) + + for alias_id in tag.alias_ids: + self.alias_ids.add(alias_id) + + self._set_aliases() + + for subtag in tag.subtag_ids: + self.subtag_ids.add(subtag) + + for alias_id in tag.alias_ids: + self.alias_ids.add(alias_id) + + self._set_aliases() + + for subtag in tag.subtag_ids: + self.subtag_ids.add(subtag) + self.set_subtags() + # select item in self.color_field where the userData value matched tag.color for i in range(self.color_field.count()): if self.color_field.itemData(i) == tag.color: self.color_field.setCurrentIndex(i) break + self.name_field.selectAll() - self.tag = tag + + def on_name_changed(self): + is_empty = not self.name_field.text().strip() + + self.name_field.setStyleSheet( + f"border: 1px solid {get_ui_color(ColorType.PRIMARY, UiColor.RED)}; border-radius: 2px" + if is_empty + else "" + ) + + if self.panel_save_button is not None: + self.panel_save_button.setDisabled(is_empty) def build_tag(self) -> Tag: color = self.color_field.currentData() or TagColor.DEFAULT tag = self.tag + self.add_aliases() + tag.name = self.name_field.text() tag.shorthand = self.shorthand_field.text() tag.color = color diff --git a/tagstudio/src/qt/modals/delete_unlinked.py b/tagstudio/src/qt/modals/delete_unlinked.py index 04b7f8f5..b382d083 100644 --- a/tagstudio/src/qt/modals/delete_unlinked.py +++ b/tagstudio/src/qt/modals/delete_unlinked.py @@ -4,7 +4,7 @@ import typing -from PySide6.QtCore import Qt, QThreadPool, Signal +from PySide6.QtCore import Qt, Signal from PySide6.QtGui import QStandardItem, QStandardItemModel from PySide6.QtWidgets import ( QHBoxLayout, @@ -15,8 +15,6 @@ from PySide6.QtWidgets import ( QWidget, ) from src.core.utils.missing_files import MissingRegistry -from src.qt.helpers.custom_runnable import CustomRunnable -from src.qt.helpers.function_iterator import FunctionIterator from src.qt.widgets.progress import ProgressWidget # Only import for type checking/autocompletion, will not be imported at runtime. @@ -77,9 +75,14 @@ class DeleteUnlinkedEntriesModal(QWidget): self.model.clear() for i in self.tracker.missing_files: - self.model.appendRow(QStandardItem(str(i.path))) + item = QStandardItem(str(i.path)) + item.setEditable(False) + self.model.appendRow(item) def delete_entries(self): + def displayed_text(x): + return f"Deleting {x}/{self.tracker.missing_files_count} Unlinked Entries" + pw = ProgressWidget( window_title="Deleting Entries", label_text="", @@ -87,23 +90,5 @@ class DeleteUnlinkedEntriesModal(QWidget): minimum=0, maximum=self.tracker.missing_files_count, ) - pw.show() - iterator = FunctionIterator(self.tracker.execute_deletion) - files_count = self.tracker.missing_files_count - iterator.value.connect( - lambda idx: ( - pw.update_progress(idx), - pw.update_label(f"Deleting {idx}/{files_count} Unlinked Entries"), - ) - ) - - r = CustomRunnable(iterator.run) - QThreadPool.globalInstance().start(r) - r.done.connect( - lambda: ( - pw.hide(), - pw.deleteLater(), - self.done.emit(), - ) - ) + pw.from_iterable_function(self.tracker.execute_deletion, displayed_text, self.done.emit) diff --git a/tagstudio/src/qt/modals/drop_import.py b/tagstudio/src/qt/modals/drop_import.py new file mode 100644 index 00000000..ec94a3e5 --- /dev/null +++ b/tagstudio/src/qt/modals/drop_import.py @@ -0,0 +1,229 @@ +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +import enum +import shutil +from pathlib import Path +from typing import TYPE_CHECKING + +import structlog +from PySide6.QtCore import Qt, QUrl +from PySide6.QtGui import QStandardItem, QStandardItemModel +from PySide6.QtWidgets import ( + QHBoxLayout, + QLabel, + QListView, + QPushButton, + QVBoxLayout, + QWidget, +) +from src.qt.widgets.progress import ProgressWidget + +if TYPE_CHECKING: + from src.qt.ts_qt import QtDriver + +logger = structlog.get_logger(__name__) + + +class DuplicateChoice(enum.StrEnum): + SKIP = "Skipped" + OVERWRITE = "Overwritten" + RENAME = "Renamed" + CANCEL = "Cancelled" + + +class DropImportModal(QWidget): + DUPE_NAME_LIMT: int = 5 + + def __init__(self, driver: "QtDriver"): + super().__init__() + + self.driver: QtDriver = driver + + # Widget ====================== + self.setWindowTitle("Conflicting File(s)") + self.setWindowModality(Qt.WindowModality.ApplicationModal) + self.setMinimumSize(500, 400) + self.root_layout = QVBoxLayout(self) + self.root_layout.setContentsMargins(6, 6, 6, 6) + + self.desc_widget = QLabel() + self.desc_widget.setObjectName("descriptionLabel") + self.desc_widget.setWordWrap(True) + self.desc_widget.setText("The following files have filenames already exist in the library") + self.desc_widget.setAlignment(Qt.AlignmentFlag.AlignCenter) + + # Duplicate File List ======== + self.list_view = QListView() + self.model = QStandardItemModel() + self.list_view.setModel(self.model) + + # Buttons ==================== + self.button_container = QWidget() + self.button_layout = QHBoxLayout(self.button_container) + self.button_layout.setContentsMargins(6, 6, 6, 6) + self.button_layout.addStretch(1) + + self.skip_button = QPushButton() + self.skip_button.setText("&Skip") + self.skip_button.setDefault(True) + self.skip_button.clicked.connect(lambda: self.begin_transfer(DuplicateChoice.SKIP)) + self.button_layout.addWidget(self.skip_button) + + self.overwrite_button = QPushButton() + self.overwrite_button.setText("&Overwrite") + self.overwrite_button.clicked.connect( + lambda: self.begin_transfer(DuplicateChoice.OVERWRITE) + ) + self.button_layout.addWidget(self.overwrite_button) + + self.rename_button = QPushButton() + self.rename_button.setText("&Rename") + self.rename_button.clicked.connect(lambda: self.begin_transfer(DuplicateChoice.RENAME)) + self.button_layout.addWidget(self.rename_button) + + self.cancel_button = QPushButton() + self.cancel_button.setText("&Cancel") + self.cancel_button.clicked.connect(lambda: self.begin_transfer(DuplicateChoice.CANCEL)) + self.button_layout.addWidget(self.cancel_button) + + # Layout ===================== + self.root_layout.addWidget(self.desc_widget) + self.root_layout.addWidget(self.list_view) + self.root_layout.addWidget(self.button_container) + + def import_urls(self, urls: list[QUrl]): + """Add a colleciton of urls to the library.""" + self.files: list[Path] = [] + self.dirs_in_root: list[Path] = [] + self.duplicate_files: list[Path] = [] + + self.collect_files_to_import(urls) + + if len(self.duplicate_files) > 0: + self.ask_duplicates_choice() + else: + self.begin_transfer() + + def collect_files_to_import(self, urls: list[QUrl]): + """Collect one or more files from drop event urls.""" + for url in urls: + if not url.isLocalFile(): + continue + + file = Path(url.toLocalFile()) + + if file.is_dir(): + for f in file.glob("**/*"): + if f.is_dir(): + continue + + self.files.append(f) + if (self.driver.lib.library_dir / self._get_relative_path(file)).exists(): + self.duplicate_files.append(f) + + self.dirs_in_root.append(file.parent) + else: + self.files.append(file) + + if file.parent not in self.dirs_in_root: + self.dirs_in_root.append( + file.parent + ) # to create relative path of files not in folder + + if (Path(self.driver.lib.library_dir) / file.name).exists(): + self.duplicate_files.append(file) + + def ask_duplicates_choice(self): + """Display the message widgeth with a list of the duplicated files.""" + self.desc_widget.setText( + f"The following {len(self.duplicate_files)} file(s) have filenames already exist in the library." # noqa: E501 + ) + + self.model.clear() + for dupe in self.duplicate_files: + item = QStandardItem(str(self._get_relative_path(dupe))) + item.setEditable(False) + self.model.appendRow(item) + + self.driver.main_window.raise_() + self.show() + + def begin_transfer(self, choice: DuplicateChoice | None = None): + """Display a progress bar and begin copying files into library.""" + self.hide() + self.choice: DuplicateChoice | None = choice + logger.info("duplicated choice selected", choice=self.choice) + if self.choice == DuplicateChoice.CANCEL: + return + + def displayed_text(x): + text = ( + f"Importing New Files...\n{x[0] + 1} File{'s' if x[0] + 1 != 1 else ''} Imported." + ) + if self.choice: + text += f" {x[1]} {self.choice.value}" + + return text + + pw = ProgressWidget( + window_title="Import Files", + label_text="Importing New Files...", + cancel_button_text=None, + minimum=0, + maximum=len(self.files), + ) + + pw.from_iterable_function( + self.copy_files, + displayed_text, + self.driver.add_new_files_callback, + self.deleteLater, + ) + + def copy_files(self): + """Copy files from original location to the library directory.""" + file_count = 0 + duplicated_files_progress = 0 + for file in self.files: + if file.is_dir(): + continue + + dest_file = self._get_relative_path(file) + + if file in self.duplicate_files: + duplicated_files_progress += 1 + if self.choice == DuplicateChoice.SKIP: + file_count += 1 + continue + elif self.choice == DuplicateChoice.RENAME: + new_name = self._get_renamed_duplicate_filename(dest_file) + dest_file = dest_file.with_name(new_name) + + (self.driver.lib.library_dir / dest_file).parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(file, self.driver.lib.library_dir / dest_file) + + file_count += 1 + yield [file_count, duplicated_files_progress] + + def _get_relative_path(self, path: Path) -> Path: + for dir in self.dirs_in_root: + if path.is_relative_to(dir): + return path.relative_to(dir) + return Path(path.name) + + def _get_renamed_duplicate_filename(self, filepath: Path) -> str: + index = 2 + o_filename = filepath.name + + try: + dot_idx = o_filename.index(".") + except ValueError: + dot_idx = len(o_filename) + + while (self.driver.lib.library_dir / filepath).exists(): + filepath = filepath.with_name( + o_filename[:dot_idx] + f" ({index})" + o_filename[dot_idx:] + ) + index += 1 + return filepath.name diff --git a/tagstudio/src/qt/modals/file_extension.py b/tagstudio/src/qt/modals/file_extension.py index 15403a88..224638ca 100644 --- a/tagstudio/src/qt/modals/file_extension.py +++ b/tagstudio/src/qt/modals/file_extension.py @@ -16,7 +16,7 @@ from PySide6.QtWidgets import ( QVBoxLayout, QWidget, ) -from src.core.constants import LibraryPrefs +from src.core.enums import LibraryPrefs from src.core.library import Library from src.qt.widgets.panel import PanelWidget @@ -104,7 +104,7 @@ class FileExtensionModal(PanelWidget): for i in range(self.table.rowCount()): ext = self.table.item(i, 0) if ext and ext.text().strip(): - extensions.append(ext.text().strip().lower()) + extensions.append(ext.text().strip().lstrip(".").lower()) # save preference self.lib.set_prefs(LibraryPrefs.EXTENSION_LIST, extensions) diff --git a/tagstudio/src/qt/modals/fix_unlinked.py b/tagstudio/src/qt/modals/fix_unlinked.py index 0e51144f..3c4627df 100644 --- a/tagstudio/src/qt/modals/fix_unlinked.py +++ b/tagstudio/src/qt/modals/fix_unlinked.py @@ -5,12 +5,10 @@ import typing -from PySide6.QtCore import Qt, QThreadPool +from PySide6.QtCore import Qt from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget from src.core.library import Library from src.core.utils.missing_files import MissingRegistry -from src.qt.helpers.custom_runnable import CustomRunnable -from src.qt.helpers.function_iterator import FunctionIterator from src.qt.modals.delete_unlinked import DeleteUnlinkedEntriesModal from src.qt.modals.merge_dupe_entries import MergeDuplicateEntries from src.qt.modals.relink_unlinked import RelinkUnlinkedEntries @@ -85,7 +83,7 @@ class FixUnlinkedEntriesModal(QWidget): self.delete_modal = DeleteUnlinkedEntriesModal(self.driver, self.tracker) self.delete_modal.done.connect( lambda: ( - self.set_missing_count(self.tracker.missing_files_count), + self.set_missing_count(), # refresh the grid self.driver.filter_items(), ) @@ -125,23 +123,19 @@ class FixUnlinkedEntriesModal(QWidget): maximum=self.lib.entries_count, ) - pw.show() - - iterator = FunctionIterator(self.tracker.refresh_missing_files) - iterator.value.connect(lambda v: pw.update_progress(v + 1)) - r = CustomRunnable(iterator.run) - QThreadPool.globalInstance().start(r) - r.done.connect( - lambda: ( - pw.hide(), - pw.deleteLater(), - self.set_missing_count(self.tracker.missing_files_count), - self.delete_modal.refresh_list(), - ) + pw.from_iterable_function( + self.tracker.refresh_missing_files, + None, + self.set_missing_count, + self.delete_modal.refresh_list, ) - def set_missing_count(self, count: int): - self.missing_count = count + def set_missing_count(self, count: int | None = None): + if count is not None: + self.missing_count = count + else: + self.missing_count = self.tracker.missing_files_count + if self.missing_count < 0: self.search_button.setDisabled(True) self.delete_button.setDisabled(True) diff --git a/tagstudio/src/qt/modals/merge_dupe_entries.py b/tagstudio/src/qt/modals/merge_dupe_entries.py index cc81949d..66c94dda 100644 --- a/tagstudio/src/qt/modals/merge_dupe_entries.py +++ b/tagstudio/src/qt/modals/merge_dupe_entries.py @@ -4,11 +4,9 @@ import typing -from PySide6.QtCore import QObject, QThreadPool, Signal +from PySide6.QtCore import QObject, Signal from src.core.library import Library from src.core.utils.dupe_files import DupeRegistry -from src.qt.helpers.custom_runnable import CustomRunnable -from src.qt.helpers.function_iterator import FunctionIterator from src.qt.widgets.progress import ProgressWidget # Only import for type checking/autocompletion, will not be imported at runtime. @@ -26,20 +24,12 @@ class MergeDuplicateEntries(QObject): self.tracker = DupeRegistry(library=self.lib) def merge_entries(self): - iterator = FunctionIterator(self.tracker.merge_dupe_entries) - pw = ProgressWidget( window_title="Merging Duplicate Entries", - label_text="", + label_text="Merging Duplicate Entries...", cancel_button_text=None, minimum=0, maximum=self.tracker.groups_count, ) - pw.show() - iterator.value.connect(lambda x: pw.update_progress(x)) - iterator.value.connect(lambda: (pw.update_label("Merging Duplicate Entries..."))) - - r = CustomRunnable(iterator.run) - r.done.connect(lambda: (pw.hide(), pw.deleteLater(), self.done.emit())) - QThreadPool.globalInstance().start(r) + pw.from_iterable_function(self.tracker.merge_dupe_entries, None, self.done.emit) diff --git a/tagstudio/src/qt/modals/mirror_entities.py b/tagstudio/src/qt/modals/mirror_entities.py index d7178ec4..6ab19957 100644 --- a/tagstudio/src/qt/modals/mirror_entities.py +++ b/tagstudio/src/qt/modals/mirror_entities.py @@ -6,7 +6,7 @@ import typing from time import sleep -from PySide6.QtCore import Qt, QThreadPool, Signal +from PySide6.QtCore import Qt, Signal from PySide6.QtGui import QStandardItem, QStandardItemModel from PySide6.QtWidgets import ( QHBoxLayout, @@ -17,8 +17,6 @@ from PySide6.QtWidgets import ( QWidget, ) from src.core.utils.dupe_files import DupeRegistry -from src.qt.helpers.custom_runnable import CustomRunnable -from src.qt.helpers.function_iterator import FunctionIterator from src.qt.widgets.progress import ProgressWidget # Only import for type checking/autocompletion, will not be imported at runtime. @@ -83,28 +81,22 @@ class MirrorEntriesModal(QWidget): self.model.appendRow(QStandardItem(str(i))) def mirror_entries(self): - iterator = FunctionIterator(self.mirror_entries_runnable) + def displayed_text(x): + return f"Mirroring {x + 1}/{self.tracker.groups_count} Entries..." + pw = ProgressWidget( window_title="Mirroring Entries", - label_text=f"Mirroring 1/{self.tracker.groups_count} Entries...", + label_text="", cancel_button_text=None, minimum=0, maximum=self.tracker.groups_count, ) - pw.show() - iterator.value.connect(lambda x: pw.update_progress(x + 1)) - iterator.value.connect( - lambda x: pw.update_label(f"Mirroring {x + 1}/{self.tracker.groups_count} Entries...") - ) - r = CustomRunnable(iterator.run) - QThreadPool.globalInstance().start(r) - r.done.connect( - lambda: ( - pw.hide(), - pw.deleteLater(), - self.driver.preview_panel.update_widgets(), - self.done.emit(), - ) + + pw.from_iterable_function( + self.mirror_entries_runnable, + displayed_text, + self.driver.preview_panel.update_widgets, + self.done.emit, ) def mirror_entries_runnable(self): diff --git a/tagstudio/src/qt/modals/relink_unlinked.py b/tagstudio/src/qt/modals/relink_unlinked.py index e567f8b5..ab2a1d40 100644 --- a/tagstudio/src/qt/modals/relink_unlinked.py +++ b/tagstudio/src/qt/modals/relink_unlinked.py @@ -3,10 +3,8 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -from PySide6.QtCore import QObject, QThreadPool, Signal +from PySide6.QtCore import QObject, Signal from src.core.utils.missing_files import MissingRegistry -from src.qt.helpers.custom_runnable import CustomRunnable -from src.qt.helpers.function_iterator import FunctionIterator from src.qt.widgets.progress import ProgressWidget @@ -18,7 +16,10 @@ class RelinkUnlinkedEntries(QObject): self.tracker = tracker def repair_entries(self): - iterator = FunctionIterator(self.tracker.fix_missing_files) + def displayed_text(x): + text = f"Attempting to Relink {x}/{self.tracker.missing_files_count} Entries. \n" + text += f"{self.tracker.files_fixed_count} Successfully Relinked." + return text pw = ProgressWidget( window_title="Relinking Entries", @@ -28,24 +29,4 @@ class RelinkUnlinkedEntries(QObject): maximum=self.tracker.missing_files_count, ) - pw.show() - - iterator.value.connect( - lambda idx: ( - pw.update_progress(idx), - pw.update_label( - f"Attempting to Relink {idx}/{self.tracker.missing_files_count} Entries. " - f"{self.tracker.files_fixed_count} Successfully Relinked." - ), - ) - ) - - r = CustomRunnable(iterator.run) - r.done.connect( - lambda: ( - pw.hide(), - pw.deleteLater(), - self.done.emit(), - ) - ) - QThreadPool.globalInstance().start(r) + pw.from_iterable_function(self.tracker.fix_missing_files, displayed_text, self.done.emit) diff --git a/tagstudio/src/qt/modals/tag_database.py b/tagstudio/src/qt/modals/tag_database.py index f1aebad6..168420d5 100644 --- a/tagstudio/src/qt/modals/tag_database.py +++ b/tagstudio/src/qt/modals/tag_database.py @@ -2,21 +2,31 @@ # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio +import structlog from PySide6.QtCore import QSize, Qt, Signal +from PySide6.QtGui import QShowEvent from PySide6.QtWidgets import ( QFrame, QHBoxLayout, QLineEdit, + QMessageBox, + QPushButton, QScrollArea, QVBoxLayout, QWidget, ) +from src.core.constants import RESERVED_TAG_END, RESERVED_TAG_START from src.core.library import Library, Tag -from src.core.library.alchemy.enums import FilterState from src.qt.modals.build_tag import BuildTagPanel from src.qt.widgets.panel import PanelModal, PanelWidget from src.qt.widgets.tag import TagWidget +logger = structlog.get_logger(__name__) + +# TODO: This class shares the majority of its code with tag_search.py. +# It should either be made DRY, or be replaced with the intended and more robust +# Tag Management tab/pane outlined on the Feature Roadmap. + class TagDatabasePanel(PanelWidget): tag_chosen = Signal(int) @@ -24,8 +34,8 @@ class TagDatabasePanel(PanelWidget): def __init__(self, library: Library): super().__init__() self.lib: Library = library + self.is_initialized: bool = False self.first_tag_id = -1 - self.tag_limit = 30 self.setMinimumSize(300, 400) self.root_layout = QVBoxLayout(self) @@ -52,10 +62,38 @@ class TagDatabasePanel(PanelWidget): self.scroll_area.setFrameShape(QFrame.Shape.NoFrame) self.scroll_area.setWidget(self.scroll_contents) + self.create_tag_button = QPushButton() + self.create_tag_button.setText("Create Tag") + self.create_tag_button.clicked.connect(self.build_tag) + self.root_layout.addWidget(self.search_field) self.root_layout.addWidget(self.scroll_area) + self.root_layout.addWidget(self.create_tag_button) self.update_tags() + def build_tag(self): + self.modal = PanelModal( + BuildTagPanel(self.lib), + "New Tag", + "Add Tag", + has_save=True, + ) + + panel: BuildTagPanel = self.modal.widget + self.modal.saved.connect( + lambda: ( + self.lib.add_tag( + tag=panel.build_tag(), + subtag_ids=panel.subtag_ids, + alias_names=panel.alias_names, + alias_ids=panel.alias_ids, + ), + self.modal.hide(), + self.update_tags(), + ) + ) + self.modal.show() + def on_return(self, text: str): if text and self.first_tag_id >= 0: # callback(self.first_tag_id) @@ -67,24 +105,52 @@ class TagDatabasePanel(PanelWidget): def update_tags(self, query: str | None = None): # TODO: Look at recycling rather than deleting and re-initializing + logger.info("[Tag Manager Modal] Updating Tags") while self.scroll_layout.itemAt(0): self.scroll_layout.takeAt(0).widget().deleteLater() - tags = self.lib.search_tags(FilterState(path=query, page_size=self.tag_limit)) + tags_results = self.lib.search_tags(name=query) - for tag in tags: + for tag in tags_results: container = QWidget() row = QHBoxLayout(container) row.setContentsMargins(0, 0, 0, 0) row.setSpacing(3) - tag_widget = TagWidget(tag, has_edit=True, has_remove=False) + + if tag.id in range(RESERVED_TAG_START, RESERVED_TAG_END): + tag_widget = TagWidget(tag, has_edit=False, has_remove=False) + else: + tag_widget = TagWidget(tag, has_edit=True, has_remove=True) + tag_widget.on_edit.connect(lambda checked=False, t=tag: self.edit_tag(t)) + tag_widget.on_remove.connect(lambda t=tag: self.remove_tag(t)) row.addWidget(tag_widget) self.scroll_layout.addWidget(container) self.search_field.setFocus() + def remove_tag(self, tag: Tag): + if tag.id in range(RESERVED_TAG_START, RESERVED_TAG_END): + return + + message_box = QMessageBox() + message_box.setWindowTitle("Remove Tag") + message_box.setText(f'Are you sure you want to delete the tag "{tag.name}"?') + message_box.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel) # type: ignore + message_box.setIcon(QMessageBox.Question) # type: ignore + + result = message_box.exec() + + if result != QMessageBox.Ok: # type: ignore + return + + self.lib.remove_tag(tag) + self.update_tags() + def edit_tag(self, tag: Tag): + if tag.id in range(RESERVED_TAG_START, RESERVED_TAG_END): + return + build_tag_panel = BuildTagPanel(self.lib, tag=tag) self.edit_modal = PanelModal( @@ -99,5 +165,11 @@ class TagDatabasePanel(PanelWidget): self.edit_modal.show() def edit_tag_callback(self, btp: BuildTagPanel): - self.lib.add_tag(btp.build_tag()) + self.lib.update_tag(btp.build_tag(), btp.subtag_ids, btp.alias_names, btp.alias_ids) self.update_tags(self.search_field.text()) + + def showEvent(self, event: QShowEvent) -> None: # noqa N802 + if not self.is_initialized: + self.update_tags() + self.is_initialized = True + return super().showEvent(event) diff --git a/tagstudio/src/qt/modals/tag_search.py b/tagstudio/src/qt/modals/tag_search.py index c44278fd..72f3650b 100644 --- a/tagstudio/src/qt/modals/tag_search.py +++ b/tagstudio/src/qt/modals/tag_search.py @@ -7,6 +7,7 @@ import math import structlog from PySide6.QtCore import QSize, Qt, Signal +from PySide6.QtGui import QShowEvent from PySide6.QtWidgets import ( QFrame, QHBoxLayout, @@ -17,7 +18,6 @@ from PySide6.QtWidgets import ( QWidget, ) from src.core.library import Library -from src.core.library.alchemy.enums import FilterState from src.core.palette import ColorType, get_tag_color from src.qt.widgets.panel import PanelWidget from src.qt.widgets.tag import TagWidget @@ -28,11 +28,12 @@ logger = structlog.get_logger(__name__) class TagSearchPanel(PanelWidget): tag_chosen = Signal(int) - def __init__(self, library: Library): + def __init__(self, library: Library, exclude: list[int] | None = None): super().__init__() self.lib = library - self.first_tag_id = None - self.tag_limit = 100 + self.exclude = exclude + self.is_initialized: bool = False + self.first_tag_id: int = None self.setMinimumSize(300, 400) self.root_layout = QVBoxLayout(self) self.root_layout.setContentsMargins(6, 0, 6, 0) @@ -60,30 +61,31 @@ class TagSearchPanel(PanelWidget): self.root_layout.addWidget(self.search_field) self.root_layout.addWidget(self.scroll_area) - self.update_tags() def on_return(self, text: str): if text and self.first_tag_id is not None: - # callback(self.first_tag_id) self.tag_chosen.emit(self.first_tag_id) self.search_field.setText("") self.update_tags() else: self.search_field.setFocus() - self.parentWidget().hide() + self.parentWidget().hide() - def update_tags(self, name: str | None = None): + def update_tags(self, query: str | None = None): + logger.info("[Tag Search Modal] Updating Tags") while self.scroll_layout.count(): self.scroll_layout.takeAt(0).widget().deleteLater() - found_tags = self.lib.search_tags( - FilterState( - path=name, - page_size=self.tag_limit, - ) - ) + tag_results = self.lib.search_tags(name=query) + if len(tag_results) > 0: + self.first_tag_id = tag_results[0].id + else: + self.first_tag_id = None + + for tag in tag_results: + if self.exclude is not None and tag.id in self.exclude: + continue - for tag in found_tags: c = QWidget() layout = QHBoxLayout(c) layout.setContentsMargins(0, 0, 0, 0) @@ -120,3 +122,9 @@ class TagSearchPanel(PanelWidget): self.scroll_layout.addWidget(c) self.search_field.setFocus() + + def showEvent(self, event: QShowEvent) -> None: # noqa N802 + if not self.is_initialized: + self.update_tags() + self.is_initialized = True + return super().showEvent(event) diff --git a/tagstudio/src/qt/platform_strings.py b/tagstudio/src/qt/platform_strings.py new file mode 100644 index 00000000..9eda3ef8 --- /dev/null +++ b/tagstudio/src/qt/platform_strings.py @@ -0,0 +1,16 @@ +# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +"""A collection of platform-dependant strings.""" + +import platform + + +class PlatformStrings: + open_file_str: str = "Open in file explorer" + + if platform.system() == "Windows": + open_file_str = "Open in Explorer" + elif platform.system() == "Darwin": + open_file_str = "Reveal in Finder" diff --git a/tagstudio/src/qt/resource_manager.py b/tagstudio/src/qt/resource_manager.py index a9826e72..d9929e7b 100644 --- a/tagstudio/src/qt/resource_manager.py +++ b/tagstudio/src/qt/resource_manager.py @@ -7,6 +7,7 @@ from typing import Any import structlog import ujson +from PIL import Image logger = structlog.get_logger(__name__) @@ -17,6 +18,7 @@ class ResourceManager: _map: dict = {} _cache: dict[str, Any] = {} _initialized: bool = False + _res_folder: Path = Path(__file__).parents[2] def __init__(self) -> None: # Load JSON resource map @@ -26,6 +28,21 @@ class ResourceManager: logger.info("resources registered", count=len(ResourceManager._map.items())) ResourceManager._initialized = True + @staticmethod + def get_path(id: str) -> Path | None: + """Get a resource's path from the ResourceManager. + + Args: + id (str): The name of the resource. + + Returns: + Path: The resource path if found, else None. + """ + res: dict = ResourceManager._map.get(id) + if res: + return ResourceManager._res_folder / "resources" / res.get("path") + return None + def get(self, id: str) -> Any: """Get a resource from the ResourceManager. @@ -43,19 +60,29 @@ class ResourceManager: return cached_res else: res: dict = ResourceManager._map.get(id) - if res.get("mode") in ["r", "rb"]: - with open( - (Path(__file__).parents[2] / "resources" / res.get("path")), - res.get("mode"), - ) as f: - data = f.read() - if res.get("mode") == "rb": - data = bytes(data) - ResourceManager._cache[id] = data + if not res: + return None + try: + if res.get("mode") in ["r", "rb"]: + with open( + (ResourceManager._res_folder / "resources" / res.get("path")), + res.get("mode"), + ) as f: + data = f.read() + if res.get("mode") == "rb": + data = bytes(data) + ResourceManager._cache[id] = data + return data + elif res and res.get("mode") == "pil": + data = Image.open(ResourceManager._res_folder / "resources" / res.get("path")) return data - elif res.get("mode") in ["qt"]: - # TODO: Qt resource loading logic - pass + elif res.get("mode") in ["qt"]: + # TODO: Qt resource loading logic + pass + except FileNotFoundError: + path: Path = ResourceManager._res_folder / "resources" / res.get("path") + logger.error("[ResourceManager][ERROR]: Could not find resource: ", path) + return None def __getattr__(self, __name: str) -> Any: attr = self.get(__name) diff --git a/tagstudio/src/qt/resources.json b/tagstudio/src/qt/resources.json index 1f8663d3..e5857909 100644 --- a/tagstudio/src/qt/resources.json +++ b/tagstudio/src/qt/resources.json @@ -14,5 +14,85 @@ "volume_mute_icon": { "path": "qt/images/volume_mute.svg", "mode": "rb" + }, + "broken_link_icon": { + "path": "qt/images/broken_link_icon.png", + "mode": "pil" + }, + "adobe_illustrator": { + "path": "qt/images/file_icons/adobe_illustrator.png", + "mode": "pil" + }, + "adobe_photoshop": { + "path": "qt/images/file_icons/adobe_photoshop.png", + "mode": "pil" + }, + "affinity_photo": { + "path": "qt/images/file_icons/affinity_photo.png", + "mode": "pil" + }, + "audio": { + "path": "qt/images/file_icons/audio.png", + "mode": "pil" + }, + "blender": { + "path": "qt/images/file_icons/blender.png", + "mode": "pil" + }, + "document": { + "path": "qt/images/file_icons/document.png", + "mode": "pil" + }, + "file_generic": { + "path": "qt/images/file_icons/file_generic.png", + "mode": "pil" + }, + "font": { + "path": "qt/images/file_icons/font.png", + "mode": "pil" + }, + "image": { + "path": "qt/images/file_icons/image.png", + "mode": "pil" + }, + "image_vector": { + "path": "qt/images/file_icons/image_vector.png", + "mode": "pil" + }, + "material": { + "path": "qt/images/file_icons/material.png", + "mode": "pil" + }, + "model": { + "path": "qt/images/file_icons/model.png", + "mode": "pil" + }, + "presentation": { + "path": "qt/images/file_icons/presentation.png", + "mode": "pil" + }, + "program": { + "path": "qt/images/file_icons/program.png", + "mode": "pil" + }, + "spreadsheet": { + "path": "qt/images/file_icons/spreadsheet.png", + "mode": "pil" + }, + "text": { + "path": "qt/images/file_icons/text.png", + "mode": "pil" + }, + "video": { + "path": "qt/images/file_icons/video.png", + "mode": "pil" + }, + "thumb_loading": { + "path": "qt/images/thumb_loading.png", + "mode": "pil" + }, + "placeholder_mp4": { + "path": "qt/videos/placeholder.mp4", + "mode": "rb" } } diff --git a/tagstudio/src/qt/resources_rc.py b/tagstudio/src/qt/resources_rc.py index f11bf7fb..dda5e565 100644 --- a/tagstudio/src/qt/resources_rc.py +++ b/tagstudio/src/qt/resources_rc.py @@ -1,6 +1,6 @@ # Resource object code (Python 3) # Created by: object code -# Created by: The Resource Compiler for Qt version 6.6.3 +# Created by: The Resource Compiler for Qt version 6.7.1 # WARNING! All changes made in this file will be lost! from PySide6 import QtCore @@ -16240,7 +16240,7 @@ qt_resource_struct = b"\ \x00\x00\x00\x12\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ \x00\x00\x01\x8a\xfb\xc6\x86\xda\ \x00\x00\x00H\x00\x00\x00\x00\x00\x01\x00\x00\x22\x83\ -\x00\x00\x01\x8e\xfd%\xc3\xc7\ +\x00\x00\x01\x92\xc5.Ns\ " def qInitResources(): diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index 8ecd4e7a..4a568b57 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -11,6 +11,7 @@ import ctypes import dataclasses import math import os +import re import sys import time import webbrowser @@ -24,18 +25,13 @@ import src.qt.resources_rc # noqa: F401 import structlog from humanfriendly import format_timespan from PySide6 import QtCore -from PySide6.QtCore import ( - QObject, - QSettings, - Qt, - QThread, - QThreadPool, - QTimer, - Signal, -) +from PySide6.QtCore import QObject, QSettings, Qt, QThread, QThreadPool, QTimer, Signal from PySide6.QtGui import ( QAction, QColor, + QDragEnterEvent, + QDragMoveEvent, + QDropEvent, QFontDatabase, QGuiApplication, QIcon, @@ -50,6 +46,7 @@ from PySide6.QtWidgets import ( QLineEdit, QMenu, QMenuBar, + QMessageBox, QPushButton, QScrollArea, QSplashScreen, @@ -58,19 +55,20 @@ from PySide6.QtWidgets import ( from src.core.constants import ( TAG_ARCHIVED, TAG_FAVORITE, - TS_FOLDER_NAME, VERSION, VERSION_BRANCH, - LibraryPrefs, ) -from src.core.enums import MacroID, SettingItems +from src.core.driver import DriverMixin +from src.core.enums import LibraryPrefs, MacroID, SettingItems +from src.core.library.alchemy import Library from src.core.library.alchemy.enums import ( FieldTypeEnum, FilterState, ItemType, - SearchMode, ) from src.core.library.alchemy.fields import _FieldID +from src.core.library.alchemy.library import Entry, LibraryStatus +from src.core.media_types import MediaCategories from src.core.ts_core import TagStudioCore from src.core.utils.refresh_dir import RefreshDirTracker from src.core.utils.web import strip_web_protocol @@ -79,6 +77,7 @@ from src.qt.helpers.custom_runnable import CustomRunnable from src.qt.helpers.function_iterator import FunctionIterator from src.qt.main_window import Ui_MainWindow from src.qt.modals.build_tag import BuildTagPanel +from src.qt.modals.drop_import import DropImportModal from src.qt.modals.file_extension import FileExtensionModal from src.qt.modals.fix_dupes import FixDupeFilesModal from src.qt.modals.fix_unlinked import FixUnlinkedEntriesModal @@ -86,6 +85,7 @@ from src.qt.modals.folders_to_tags import FoldersToTagsModal from src.qt.modals.tag_database import TagDatabasePanel from src.qt.resource_manager import ResourceManager from src.qt.widgets.item_thumb import BadgeType, ItemThumb +from src.qt.widgets.migration_modal import JsonMigrationModal from src.qt.widgets.panel import PanelModal from src.qt.widgets.preview_panel import PreviewPanel from src.qt.widgets.progress import ProgressWidget @@ -120,12 +120,13 @@ class Consumer(QThread): pass -class QtDriver(QObject): +class QtDriver(DriverMixin, QObject): """A Qt GUI frontend driver for TagStudio.""" SIGTERM = Signal() preview_panel: PreviewPanel + lib: Library def __init__(self, backend, args): super().__init__() @@ -135,7 +136,7 @@ class QtDriver(QObject): self.rm: ResourceManager = ResourceManager() self.args = args self.frame_content = [] - self.filter = FilterState() + self.filter = FilterState.show_all() self.pages_count = 0 self.scrollbar_pos = 0 @@ -173,16 +174,15 @@ class QtDriver(QObject): filename=self.settings.fileName(), ) - max_threads = os.cpu_count() - for i in range(max_threads): - # thread = threading.Thread( - # target=self.consumer, name=f"ThumbRenderer_{i}", args=(), daemon=True - # ) - # thread.start() - thread = Consumer(self.thumb_job_queue) - thread.setObjectName(f"ThumbRenderer_{i}") - self.thumb_threads.append(thread) - thread.start() + def init_workers(self): + """Init workers for rendering thumbnails.""" + if not self.thumb_threads: + max_threads = os.cpu_count() + for i in range(max_threads): + thread = Consumer(self.thumb_job_queue) + thread.setObjectName(f"ThumbRenderer_{i}") + self.thumb_threads.append(thread) + thread.start() def open_library_from_dialog(self): dir = QFileDialog.getExistingDirectory( @@ -230,19 +230,10 @@ class QtDriver(QObject): # self.main_window = loader.load(home_path) self.main_window = Ui_MainWindow(self) self.main_window.setWindowTitle(self.base_title) - self.main_window.mousePressEvent = self.mouse_navigation # type: ignore - # self.main_window.setStyleSheet( - # f'QScrollBar::{{background:red;}}' - # ) - - # # self.main_window.windowFlags() & - # # self.main_window.setWindowFlag(Qt.WindowType.FramelessWindowHint, True) - # self.main_window.setWindowFlag(Qt.WindowType.NoDropShadowWindowHint, True) - # self.main_window.setWindowFlag(Qt.WindowType.WindowTransparentForInput, False) - # self.main_window.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) - - # self.windowFX = WindowEffect() - # self.windowFX.setAcrylicEffect(self.main_window.winId()) + self.main_window.mousePressEvent = self.mouse_navigation # type: ignore[method-assign] + self.main_window.dragEnterEvent = self.drag_enter_event # type: ignore[method-assign] + self.main_window.dragMoveEvent = self.drag_move_event # type: ignore[method-assign] + self.main_window.dropEvent = self.drop_event # type: ignore[method-assign] splash_pixmap = QPixmap(":/images/splash.png") splash_pixmap.setDevicePixelRatio(self.main_window.devicePixelRatio()) @@ -252,7 +243,7 @@ class QtDriver(QObject): if os.name == "nt": appid = "cyanvoxel.tagstudio.9" - ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(appid) # type: ignore + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(appid) # type: ignore[attr-defined,unused-ignore] if sys.platform != "darwin": icon = QIcon() @@ -265,15 +256,12 @@ class QtDriver(QObject): file_menu = QMenu("&File", menu_bar) edit_menu = QMenu("&Edit", menu_bar) + view_menu = QMenu("&View", menu_bar) tools_menu = QMenu("&Tools", menu_bar) macros_menu = QMenu("&Macros", menu_bar) - window_menu = QMenu("&Window", menu_bar) help_menu = QMenu("&Help", menu_bar) # File Menu ============================================================ - # file_menu.addAction(QAction('&New Library', menu_bar)) - # file_menu.addAction(QAction('&Open Library', menu_bar)) - open_library_action = QAction("&Open/Create Library", menu_bar) open_library_action.triggered.connect(lambda: self.open_library_from_dialog()) open_library_action.setShortcut( @@ -303,8 +291,6 @@ class QtDriver(QObject): file_menu.addSeparator() - # refresh_lib_action = QAction('&Refresh Directories', self.main_window) - # refresh_lib_action.triggered.connect(lambda: self.lib.refresh_dir()) add_new_files_action = QAction("&Refresh Directories", menu_bar) add_new_files_action.triggered.connect( lambda: self.callback_library_needed_check(self.add_new_files_callback) @@ -316,13 +302,23 @@ class QtDriver(QObject): ) ) add_new_files_action.setStatusTip("Ctrl+R") - # file_menu.addAction(refresh_lib_action) file_menu.addAction(add_new_files_action) file_menu.addSeparator() close_library_action = QAction("&Close Library", menu_bar) close_library_action.triggered.connect(self.close_library) file_menu.addAction(close_library_action) + file_menu.addSeparator() + + open_on_start_action = QAction("Open Library on Start", self) + open_on_start_action.setCheckable(True) + open_on_start_action.setChecked( + bool(self.settings.value(SettingItems.START_LOAD_LAST, defaultValue=True, type=bool)) + ) + open_on_start_action.triggered.connect( + lambda checked: self.settings.setValue(SettingItems.START_LOAD_LAST, checked) + ) + file_menu.addAction(open_on_start_action) # Edit Menu ============================================================ new_tag_action = QAction("New &Tag", menu_bar) @@ -365,15 +361,32 @@ class QtDriver(QObject): tag_database_action.triggered.connect(lambda: self.show_tag_database()) edit_menu.addAction(tag_database_action) - check_action = QAction("Open library on start", self) - check_action.setCheckable(True) - check_action.setChecked( - bool(self.settings.value(SettingItems.START_LOAD_LAST, defaultValue=True, type=bool)) + # View Menu ============================================================ + show_libs_list_action = QAction("Show Recent Libraries", menu_bar) + show_libs_list_action.setCheckable(True) + show_libs_list_action.setChecked( + bool(self.settings.value(SettingItems.WINDOW_SHOW_LIBS, defaultValue=True, type=bool)) ) - check_action.triggered.connect( - lambda checked: self.settings.setValue(SettingItems.START_LOAD_LAST, checked) + show_libs_list_action.triggered.connect( + lambda checked: ( + self.settings.setValue(SettingItems.WINDOW_SHOW_LIBS, checked), + self.toggle_libs_list(checked), + ) ) - window_menu.addAction(check_action) + view_menu.addAction(show_libs_list_action) + + show_filenames_action = QAction("Show Filenames in Grid", menu_bar) + show_filenames_action.setCheckable(True) + show_filenames_action.setChecked( + bool(self.settings.value(SettingItems.SHOW_FILENAMES, defaultValue=True, type=bool)) + ) + show_filenames_action.triggered.connect( + lambda checked: ( + self.settings.setValue(SettingItems.SHOW_FILENAMES, checked), + self.show_grid_filenames(checked), + ) + ) + view_menu.addAction(show_filenames_action) # Tools Menu =========================================================== def create_fix_unlinked_entries_modal(): @@ -408,19 +421,6 @@ class QtDriver(QObject): ) macros_menu.addAction(self.autofill_action) - show_libs_list_action = QAction("Show Recent Libraries", menu_bar) - show_libs_list_action.setCheckable(True) - show_libs_list_action.setChecked( - bool(self.settings.value(SettingItems.WINDOW_SHOW_LIBS, defaultValue=True, type=bool)) - ) - show_libs_list_action.triggered.connect( - lambda checked: ( - self.settings.setValue(SettingItems.WINDOW_SHOW_LIBS, checked), - self.toggle_libs_list(checked), - ) - ) - window_menu.addAction(show_libs_list_action) - def create_folders_tags_modal(): if not hasattr(self, "folders_modal"): self.folders_modal = FoldersToTagsModal(self.lib, self) @@ -430,7 +430,7 @@ class QtDriver(QObject): folders_to_tags_action.triggered.connect(create_folders_tags_modal) macros_menu.addAction(folders_to_tags_action) - # Help Menu ========================================================== + # Help Menu ============================================================ self.repo_action = QAction("Visit GitHub Repository", menu_bar) self.repo_action.triggered.connect( lambda: webbrowser.open("https://github.com/TagStudioDev/TagStudio") @@ -440,11 +440,13 @@ class QtDriver(QObject): menu_bar.addMenu(file_menu) menu_bar.addMenu(edit_menu) + menu_bar.addMenu(view_menu) menu_bar.addMenu(tools_menu) menu_bar.addMenu(macros_menu) - menu_bar.addMenu(window_menu) menu_bar.addMenu(help_menu) + self.main_window.searchField.textChanged.connect(self.update_completions_list) + self.preview_panel = PreviewPanel(self.lib, self) splitter = self.main_window.splitter splitter.addWidget(self.preview_panel) @@ -453,59 +455,78 @@ class QtDriver(QObject): str(Path(__file__).parents[2] / "resources/qt/fonts/Oxanium-Bold.ttf") ) - self.thumb_size = 128 + self.thumb_sizes: list[tuple[str, int]] = [ + ("Extra Large Thumbnails", 256), + ("Large Thumbnails", 192), + ("Medium Thumbnails", 128), + ("Small Thumbnails", 96), + ("Mini Thumbnails", 76), + ] self.item_thumbs: list[ItemThumb] = [] self.thumb_renderers: list[ThumbRenderer] = [] - self.filter = FilterState() - + self.filter = FilterState.show_all() self.init_library_window() + self.migration_modal: JsonMigrationModal = None - lib: str | None = None - if self.args.open: - lib = self.args.open - elif self.settings.value(SettingItems.START_LOAD_LAST, defaultValue=True, type=bool): - lib = str(self.settings.value(SettingItems.LAST_LIBRARY)) - - # TODO: Remove this check if the library is no longer saved with files - if lib and not (Path(lib) / TS_FOLDER_NAME).exists(): - logger.error(f"[QT DRIVER] {TS_FOLDER_NAME} folder in {lib} does not exist.") - self.settings.setValue(SettingItems.LAST_LIBRARY, "") - lib = None - - if lib: + path_result = self.evaluate_path(self.args.open) + # check status of library path evaluating + if path_result.success and path_result.library_path: self.splash.showMessage( - f'Opening Library "{lib}"...', + f'Opening Library "{path_result.library_path}"...', int(Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter), QColor("#9782ff"), ) - self.open_library(lib) + self.open_library(path_result.library_path) app.exec() - self.shutdown() + def show_error_message(self, message: str): + self.main_window.statusbar.showMessage(message, Qt.AlignmentFlag.AlignLeft) + self.main_window.landing_widget.set_status_label(message) + self.main_window.setWindowTitle(message) + + msg_box = QMessageBox() + msg_box.setIcon(QMessageBox.Icon.Critical) + msg_box.setText(message) + msg_box.setWindowTitle("Error") + msg_box.addButton("Close", QMessageBox.ButtonRole.AcceptRole) + + # Show the message box + msg_box.exec() + def init_library_window(self): # self._init_landing_page() # Taken care of inside the widget now - self._init_thumb_grid() # TODO: Put this into its own method that copies the font file(s) into memory # so the resource isn't being used, then store the specific size variations # in a global dict for methods to access for different DPIs. # adj_font_size = math.floor(12 * self.main_window.devicePixelRatio()) + # Search Button search_button: QPushButton = self.main_window.searchButton search_button.clicked.connect( - lambda: self.filter_items(FilterState(query=self.main_window.searchField.text())) + lambda: self.filter_items( + FilterState.from_search_query(self.main_window.searchField.text()) + ) ) + # Search Field search_field: QLineEdit = self.main_window.searchField search_field.returnPressed.connect( # TODO - parse search field for filters - lambda: self.filter_items(FilterState(query=self.main_window.searchField.text())) + lambda: self.filter_items( + FilterState.from_search_query(self.main_window.searchField.text()) + ) ) - search_type_selector: QComboBox = self.main_window.comboBox_2 - search_type_selector.currentIndexChanged.connect( - lambda: self.set_search_type(SearchMode(search_type_selector.currentIndex())) + # Thumbnail Size ComboBox + thumb_size_combobox: QComboBox = self.main_window.thumb_size_combobox + for size in self.thumb_sizes: + thumb_size_combobox.addItem(size[0]) + thumb_size_combobox.setCurrentIndex(2) # Default: Medium + thumb_size_combobox.currentIndexChanged.connect( + lambda: self.thumb_size_callback(thumb_size_combobox.currentIndex()) ) + self._init_thumb_grid() back_button: QPushButton = self.main_window.backButton back_button.clicked.connect(lambda: self.page_move(-1)) @@ -531,6 +552,10 @@ class QtDriver(QObject): self.preview_panel.libs_flow_container.hide() self.preview_panel.update() + def show_grid_filenames(self, value: bool): + for thumb in self.item_thumbs: + thumb.set_filename_visibility(value) + def callback_library_needed_check(self, func): """Check if loaded library has valid path before executing the button function.""" if self.lib.library_dir: @@ -562,11 +587,12 @@ class QtDriver(QObject): self.main_window.statusbar.showMessage("Closing Library...") start_time = time.time() - self.settings.setValue(SettingItems.LAST_LIBRARY, self.lib.library_dir) + self.settings.setValue(SettingItems.LAST_LIBRARY, str(self.lib.library_dir)) self.settings.sync() self.lib.close() + self.thumb_job_queue.queue.clear() if is_shutdown: # no need to do other things on shutdown return @@ -608,7 +634,9 @@ class QtDriver(QObject): panel: BuildTagPanel = self.modal.widget self.modal.saved.connect( lambda: ( - self.lib.add_tag(panel.build_tag(), panel.subtags), + self.lib.add_tag( + panel.build_tag(), panel.subtag_ids, panel.alias_names, panel.alias_ids + ), self.modal.hide(), ) ) @@ -633,7 +661,11 @@ class QtDriver(QObject): def show_tag_database(self): self.modal = PanelModal( - TagDatabasePanel(self.lib), "Library Tags", "Library Tags", has_save=False + widget=TagDatabasePanel(self.lib), + title="Library Tags", + window_title="Library Tags", + done_callback=self.preview_panel.update_widgets, + has_save=False, ) self.modal.show() @@ -668,7 +700,7 @@ class QtDriver(QObject): pw.update_progress(x + 1), pw.update_label( f"Scanning Directories for New Files...\n{x + 1}" - f" File{"s" if x + 1 != 1 else ""} Searched," + f' File{"s" if x + 1 != 1 else ""} Searched,' f" {tracker.files_count} New Files Found" ), ) @@ -688,26 +720,6 @@ class QtDriver(QObject): Threaded method. """ - # pb = QProgressDialog( - # f"Running Configured Macros on 1/{len(new_ids)} New Entries", None, 0, len(new_ids) - # ) - # pb.setFixedSize(432, 112) - # pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint) - # pb.setWindowTitle('Running Macros') - # pb.setWindowModality(Qt.WindowModality.ApplicationModal) - # pb.show() - - # r = CustomRunnable(lambda: self.new_file_macros_runnable(pb, new_ids)) - # r.done.connect(lambda: (pb.hide(), pb.deleteLater(), self.filter_items(''))) - # r.run() - # # QThreadPool.globalInstance().start(r) - - # # self.main_window.statusbar.showMessage( - # # f"Running configured Macros on {len(new_ids)} new Entries...", 3 - # # ) - - # # pb.hide() - files_count = tracker.files_count iterator = FunctionIterator(tracker.save_new_files) @@ -719,6 +731,7 @@ class QtDriver(QObject): maximum=files_count, ) pw.show() + iterator.value.connect( lambda x: ( pw.update_progress(x + 1), @@ -760,9 +773,9 @@ class QtDriver(QObject): def run_macro(self, name: MacroID, grid_idx: int): """Run a specific Macro on an Entry given a Macro name.""" - entry = self.frame_content[grid_idx] - ful_path = self.lib.library_dir / entry.path - source = entry.path.parts[0] + entry: Entry = self.frame_content[grid_idx] + full_path = self.lib.library_dir / entry.path + source = "" if entry.path.parent == Path(".") else entry.path.parts[0].lower() logger.info( "running macro", @@ -776,11 +789,13 @@ class QtDriver(QObject): for macro_id in MacroID: if macro_id == MacroID.AUTOFILL: continue - self.run_macro(macro_id, entry.id) + self.run_macro(macro_id, grid_idx) elif name == MacroID.SIDECAR: - parsed_items = TagStudioCore.get_gdl_sidecar(ful_path, source) + parsed_items = TagStudioCore.get_gdl_sidecar(full_path, source) for field_id, value in parsed_items.items(): + if isinstance(value, list) and len(value) > 0 and isinstance(value[0], str): + value = self.lib.tag_from_strings(value) self.lib.add_entry_field_type( entry.id, field_id=field_id, @@ -788,8 +803,9 @@ class QtDriver(QObject): ) elif name == MacroID.BUILD_URL: - url = TagStudioCore.build_url(entry.id, source) - self.lib.add_entry_field_type(entry.id, field_id=_FieldID.SOURCE, value=url) + url = TagStudioCore.build_url(entry, source) + if url is not None: + self.lib.add_entry_field_type(entry.id, field_id=_FieldID.SOURCE, value=url) elif name == MacroID.MATCH: TagStudioCore.match_conditions(self.lib, entry.id) elif name == MacroID.CLEAN_URL: @@ -797,9 +813,38 @@ class QtDriver(QObject): if field.type.type == FieldTypeEnum.TEXT_LINE and field.value: self.lib.update_entry_field( entry_ids=entry.id, + field=field, content=strip_web_protocol(field.value), ) + def thumb_size_callback(self, index: int): + """Perform actions needed when the thumbnail size selection is changed. + + Args: + index (int): The index of the item_thumbs/ComboBox list to use. + """ + spacing_divisor: int = 10 + min_spacing: int = 12 + # Index 2 is the default (Medium) + if index < len(self.thumb_sizes) and index >= 0: + self.thumb_size = self.thumb_sizes[index][1] + else: + logger.error(f"ERROR: Invalid thumbnail size index ({index}). Defaulting to 128px.") + self.thumb_size = 128 + + self.update_thumbs() + blank_icon: QIcon = QIcon() + for it in self.item_thumbs: + it.thumb_button.setIcon(blank_icon) + it.resize(self.thumb_size, self.thumb_size) + it.thumb_size = (self.thumb_size, self.thumb_size) + it.setFixedSize(self.thumb_size, self.thumb_size) + it.thumb_button.thumb_size = (self.thumb_size, self.thumb_size) + it.set_filename_visibility(it.show_filename_label) + self.flow_container.layout().setSpacing( + min(self.thumb_size // spacing_divisor, min_spacing) + ) + def mouse_navigation(self, event: QMouseEvent): # print(event.button()) if event.button() == Qt.MouseButton.ForwardButton: @@ -843,8 +888,16 @@ class QtDriver(QObject): # TODO - init after library is loaded, it can have different page_size for grid_idx in range(self.filter.page_size): item_thumb = ItemThumb( - None, self.lib, self, (self.thumb_size, self.thumb_size), grid_idx + None, + self.lib, + self, + (self.thumb_size, self.thumb_size), + grid_idx, + bool( + self.settings.value(SettingItems.SHOW_FILENAMES, defaultValue=True, type=bool) + ), ) + layout.addWidget(item_thumb) self.item_thumbs.append(item_thumb) @@ -901,6 +954,74 @@ class QtDriver(QObject): def set_macro_menu_viability(self): self.autofill_action.setDisabled(not self.selected) + def update_completions_list(self, text: str) -> None: + matches = re.search( + r"((?:.* )?)(mediatype|filetype|path|tag|tag_id):(\"?[A-Za-z0-9\ \t]+\"?)?", text + ) + + completion_list: list[str] = [] + if len(text) < 3: + completion_list = [ + "mediatype:", + "filetype:", + "path:", + "tag:", + "tag_id:", + "special:untagged", + ] + self.main_window.searchFieldCompletionList.setStringList(completion_list) + + if not matches: + return + + query_type: str + query_value: str | None + prefix, query_type, query_value = matches.groups() + + if not query_value: + return + + if query_type == "tag": + completion_list = list(map(lambda x: prefix + "tag:" + x.name, self.lib.tags)) + elif query_type == "tag_id": + completion_list = list(map(lambda x: prefix + "tag_id:" + str(x.id), self.lib.tags)) + elif query_type == "path": + completion_list = list(map(lambda x: prefix + "path:" + x, self.lib.get_paths())) + elif query_type == "mediatype": + single_word_completions = map( + lambda x: prefix + "mediatype:" + x.name, + filter(lambda y: " " not in y.name, MediaCategories.ALL_CATEGORIES), + ) + single_word_completions_quoted = map( + lambda x: prefix + 'mediatype:"' + x.name + '"', + filter(lambda y: " " not in y.name, MediaCategories.ALL_CATEGORIES), + ) + multi_word_completions = map( + lambda x: prefix + 'mediatype:"' + x.name + '"', + filter(lambda y: " " in y.name, MediaCategories.ALL_CATEGORIES), + ) + + all_completions = [ + single_word_completions, + single_word_completions_quoted, + multi_word_completions, + ] + completion_list = [j for i in all_completions for j in i] + elif query_type == "filetype": + extensions_list: set[str] = set() + for media_cat in MediaCategories.ALL_CATEGORIES: + extensions_list = extensions_list | media_cat.extensions + completion_list = list( + map(lambda x: prefix + "filetype:" + x.replace(".", ""), extensions_list) + ) + + update_completion_list: bool = ( + completion_list != self.main_window.searchFieldCompletionList.stringList() + or self.main_window.searchFieldCompletionList == [] + ) + if update_completion_list: + self.main_window.searchFieldCompletionList.setStringList(completion_list) + def update_thumbs(self): """Update search thumbnails.""" # start_time = time.time() @@ -921,25 +1042,41 @@ class QtDriver(QObject): self.flow_container.layout().update() self.main_window.update() - for idx, (entry, item_thumb) in enumerate( - zip_longest(self.frame_content, self.item_thumbs) - ): + is_grid_thumb = True + # Show loading placeholder icons + for entry, item_thumb in zip_longest(self.frame_content, self.item_thumbs): if not entry: item_thumb.hide() continue - filepath = self.lib.library_dir / entry.path - item_thumb = self.item_thumbs[idx] item_thumb.set_mode(ItemType.ENTRY) item_thumb.set_item_id(entry) # TODO - show after item is rendered item_thumb.show() + is_loading = True self.thumb_job_queue.put( ( item_thumb.renderer.render, - (sys.float_info.max, "", base_size, ratio, True, True), + (sys.float_info.max, "", base_size, ratio, is_loading, is_grid_thumb), + ) + ) + + # Show rendered thumbnails + for idx, (entry, item_thumb) in enumerate( + zip_longest(self.frame_content, self.item_thumbs) + ): + if not entry: + continue + + filepath = self.lib.library_dir / entry.path + is_loading = False + + self.thumb_job_queue.put( + ( + item_thumb.renderer.render, + (time.time(), filepath, base_size, ratio, is_loading, is_grid_thumb), ) ) @@ -985,13 +1122,20 @@ class QtDriver(QObject): self.item_thumbs[grid_idx].refresh_badge(entry) def filter_items(self, filter: FilterState | None = None) -> None: + if not self.lib.library_dir: + logger.info("Library not loaded") + return assert self.lib.engine if filter: self.filter = dataclasses.replace(self.filter, **dataclasses.asdict(filter)) - self.main_window.statusbar.showMessage(f'Searching Library: "{self.filter.summary}"') + # inform user about running search + self.main_window.statusbar.showMessage("Searching Library...") self.main_window.statusbar.repaint() + + # search the library + start_time = time.time() results = self.lib.search_library(self.filter) @@ -999,17 +1143,11 @@ class QtDriver(QObject): logger.info("items to render", count=len(results)) end_time = time.time() - if self.filter.summary: - # fmt: off - self.main_window.statusbar.showMessage( - f"{results.total_count} Results Found for \"{self.filter.summary}\"" - f" ({format_timespan(end_time - start_time)})" - ) - # fmt: on - else: - self.main_window.statusbar.showMessage( - f"{results.total_count} Results ({format_timespan(end_time - start_time)})" - ) + + # inform user about completed search + self.main_window.statusbar.showMessage( + f"{results.total_count} Results Found ({format_timespan(end_time - start_time)})" + ) # update page content self.frame_content = results.items @@ -1021,14 +1159,6 @@ class QtDriver(QObject): self.pages_count, self.filter.page_index, emit=False ) - def set_search_type(self, mode: SearchMode = SearchMode.AND): - self.filter_items( - FilterState( - search_mode=mode, - path=self.main_window.searchField.text(), - ) - ) - def remove_recent_library(self, item_key: str): self.settings.beginGroup(SettingItems.LIBS_LIST) self.settings.remove(item_key) @@ -1053,7 +1183,7 @@ class QtDriver(QObject): all_libs_list = sorted(all_libs.items(), key=lambda item: item[0], reverse=True) # remove previously saved items - self.settings.clear() + self.settings.remove("") for item_key, item_value in all_libs_list[:item_limit]: self.settings.setValue(item_key, item_value) @@ -1061,23 +1191,43 @@ class QtDriver(QObject): self.settings.endGroup() self.settings.sync() - def open_library(self, path: Path | str): - """Opens a TagStudio library.""" + def open_library(self, path: Path) -> None: + """Open a TagStudio library.""" open_message: str = f'Opening Library "{str(path)}"...' self.main_window.landing_widget.set_status_label(open_message) self.main_window.statusbar.showMessage(open_message, 3) self.main_window.repaint() - self.lib.open_library(path) + open_status: LibraryStatus = self.lib.open_library(path) + + # Migration is required + if open_status.json_migration_req: + self.migration_modal = JsonMigrationModal(path) + self.migration_modal.migration_finished.connect( + lambda: self.init_library(path, self.lib.open_library(path)) + ) + self.main_window.landing_widget.set_status_label("") + self.migration_modal.paged_panel.show() + else: + self.init_library(path, open_status) + + def init_library(self, path: Path, open_status: LibraryStatus): + if not open_status.success: + self.show_error_message(open_status.message or "Error opening library.") + return open_status + + self.init_workers() self.filter.page_size = self.lib.prefs(LibraryPrefs.PAGE_SIZE) # TODO - make this call optional - self.add_new_files_callback() + if self.lib.entries_count < 10000: + self.add_new_files_callback() self.update_libs_list(path) title_text = f"{self.base_title} - Library '{self.lib.library_dir}'" self.main_window.setWindowTitle(title_text) + self.main_window.setAcceptDrops(True) self.selected.clear() self.preview_panel.update_widgets() @@ -1086,3 +1236,28 @@ class QtDriver(QObject): self.filter_items() self.main_window.toggle_landing_page(enabled=False) + return open_status + + def drop_event(self, event: QDropEvent): + if event.source() is self: + return + + if not event.mimeData().hasUrls(): + return + + urls = event.mimeData().urls() + logger.info("New items dragged in", urls=urls) + drop_import = DropImportModal(self) + drop_import.import_urls(urls) + + def drag_enter_event(self, event: QDragEnterEvent): + if event.mimeData().hasUrls(): + event.accept() + else: + event.ignore() + + def drag_move_event(self, event: QDragMoveEvent): + if event.mimeData().hasUrls(): + event.accept() + else: + event.ignore() diff --git a/tagstudio/src/qt/widgets/collage_icon.py b/tagstudio/src/qt/widgets/collage_icon.py index f3e1b364..e15cdd52 100644 --- a/tagstudio/src/qt/widgets/collage_icon.py +++ b/tagstudio/src/qt/widgets/collage_icon.py @@ -2,6 +2,7 @@ # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio +import math from pathlib import Path import cv2 @@ -12,9 +13,10 @@ from PySide6.QtCore import ( QObject, Signal, ) -from src.core.constants import DOC_TYPES, IMAGE_TYPES, VIDEO_TYPES from src.core.library import Library from src.core.library.alchemy.fields import _FieldID +from src.core.media_types import MediaCategories +from src.qt.helpers.file_tester import is_readable_video logger = structlog.get_logger(__name__) @@ -74,7 +76,8 @@ class CollageIconRenderer(QObject): color=self.get_file_color(filepath.suffix.lower()), ) - if filepath.suffix.lower() in IMAGE_TYPES: + ext: str = filepath.suffix.lower() + if MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_TYPES): try: with Image.open(str(self.lib.library_dir / entry.path)) as pic: if keep_aspect: @@ -84,23 +87,34 @@ class CollageIconRenderer(QObject): if data_tint_mode and color: pic = pic.convert(mode="RGB") pic = ImageChops.hard_light(pic, Image.new("RGB", size, color)) - # collage.paste(pic, (y*thumb_size, x*thumb_size)) self.rendered.emit(pic) - except DecompressionBombError: - logger.exception("One of the images was too big", entry=entry.path) - elif filepath.suffix.lower() in VIDEO_TYPES: - video = cv2.VideoCapture(str(filepath)) + except DecompressionBombError as e: + logger.info(f"[ERROR] One of the images was too big ({e})") + elif MediaCategories.is_ext_in_category( + ext, MediaCategories.VIDEO_TYPES + ) and is_readable_video(filepath): + video = cv2.VideoCapture(str(filepath), cv2.CAP_FFMPEG) video.set( cv2.CAP_PROP_POS_FRAMES, (video.get(cv2.CAP_PROP_FRAME_COUNT) // 2), ) success, frame = video.read() - if not success: - # Depending on the video format, compression, and frame - # count, seeking halfway does not work and the thumb - # must be pulled from the earliest available frame. - video.set(cv2.CAP_PROP_POS_FRAMES, 0) + # NOTE: Depending on the video format, compression, and + # frame count, seeking halfway does not work and the thumb + # must be pulled from the earliest available frame. + max_frame_seek: int = 10 + for i in range( + 0, + min( + max_frame_seek, + math.floor(video.get(cv2.CAP_PROP_FRAME_COUNT)), + ), + ): success, frame = video.read() + if not success: + video.set(cv2.CAP_PROP_POS_FRAMES, i) + else: + break frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) with Image.fromarray(frame, mode="RGB") as pic: if keep_aspect: @@ -109,7 +123,6 @@ class CollageIconRenderer(QObject): pic = pic.resize(size) if data_tint_mode and color: pic = ImageChops.hard_light(pic, Image.new("RGB", size, color)) - # collage.paste(pic, (y*thumb_size, x*thumb_size)) self.rendered.emit(pic) except (UnidentifiedImageError, FileNotFoundError): logger.error("Couldn't read entry", entry=entry.path) @@ -130,13 +143,13 @@ class CollageIconRenderer(QObject): self.done.emit() def get_file_color(self, ext: str): - if ext.lower().replace(".", "", 1) == "gif": + if ext.lower() == "gif": return "\033[93m" - if ext.lower().replace(".", "", 1) in IMAGE_TYPES: + if MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_TYPES): return "\033[37m" - elif ext.lower().replace(".", "", 1) in VIDEO_TYPES: + elif MediaCategories.is_ext_in_category(ext, MediaCategories.VIDEO_TYPES): return "\033[96m" - elif ext.lower().replace(".", "", 1) in DOC_TYPES: + elif MediaCategories.is_ext_in_category(ext, MediaCategories.PLAINTEXT_TYPES): return "\033[92m" else: return "\033[97m" diff --git a/tagstudio/src/qt/widgets/fields.py b/tagstudio/src/qt/widgets/fields.py index 2fb8d7fb..dfcc89d0 100644 --- a/tagstudio/src/qt/widgets/fields.py +++ b/tagstudio/src/qt/widgets/fields.py @@ -113,8 +113,7 @@ class FieldContainer(QWidget): self.copy_callback = callback self.copy_button.clicked.connect(callback) - if callback is not None: - self.copy_button.is_connected = True + self.copy_button.is_connected = callable(callback) def set_edit_callback(self, callback: Callable): if self.edit_button.is_connected: @@ -122,8 +121,7 @@ class FieldContainer(QWidget): self.edit_callback = callback self.edit_button.clicked.connect(callback) - if callback is not None: - self.edit_button.is_connected = True + self.edit_button.is_connected = callable(callback) def set_remove_callback(self, callback: Callable): if self.remove_button.is_connected: @@ -131,11 +129,14 @@ class FieldContainer(QWidget): self.remove_callback = callback self.remove_button.clicked.connect(callback) - self.remove_button.is_connected = True + self.remove_button.is_connected = callable(callback) def set_inner_widget(self, widget: "FieldWidget"): if self.field_layout.itemAt(0): - self.field_layout.itemAt(0).widget().deleteLater() + old: QWidget = self.field_layout.itemAt(0).widget() + self.field_layout.removeWidget(old) + old.deleteLater() + self.field_layout.addWidget(widget) def get_inner_widget(self): diff --git a/tagstudio/src/qt/widgets/item_thumb.py b/tagstudio/src/qt/widgets/item_thumb.py index 005cf1b0..9935fe4e 100644 --- a/tagstudio/src/qt/widgets/item_thumb.py +++ b/tagstudio/src/qt/widgets/item_thumb.py @@ -10,8 +10,8 @@ from typing import TYPE_CHECKING import structlog from PIL import Image, ImageQt -from PySide6.QtCore import QEvent, QSize, Qt -from PySide6.QtGui import QAction, QEnterEvent, QPixmap +from PySide6.QtCore import QEvent, QMimeData, QSize, Qt, QUrl +from PySide6.QtGui import QAction, QDrag, QEnterEvent, QPixmap from PySide6.QtWidgets import ( QBoxLayout, QCheckBox, @@ -21,17 +21,15 @@ from PySide6.QtWidgets import ( QWidget, ) from src.core.constants import ( - AUDIO_TYPES, - IMAGE_TYPES, TAG_ARCHIVED, TAG_FAVORITE, - VIDEO_TYPES, ) from src.core.library import Entry, ItemType, Library -from src.core.library.alchemy.enums import FilterState from src.core.library.alchemy.fields import _FieldID +from src.core.media_types import MediaCategories, MediaType from src.qt.flowlayout import FlowWidget from src.qt.helpers.file_opener import FileOpenerHelper +from src.qt.platform_strings import PlatformStrings from src.qt.widgets.thumb_button import ThumbButton from src.qt.widgets.thumb_renderer import ThumbRenderer @@ -88,6 +86,7 @@ class ItemThumb(FlowWidget): small_text_style = ( "background-color:rgba(0, 0, 0, 192);" + "color:#FFFFFF;" "font-family:Oxanium;" "font-weight:bold;" "font-size:12px;" @@ -100,6 +99,7 @@ class ItemThumb(FlowWidget): med_text_style = ( "background-color:rgba(0, 0, 0, 192);" + "color:#FFFFFF;" "font-family:Oxanium;" "font-weight:bold;" "font-size:18px;" @@ -110,6 +110,8 @@ class ItemThumb(FlowWidget): "padding-left: 1px;" ) + filename_style = "font-size:10px;" + def __init__( self, mode: ItemType, @@ -117,6 +119,7 @@ class ItemThumb(FlowWidget): driver: "QtDriver", thumb_size: tuple[int, int], grid_idx: int, + show_filename_label: bool = False, ): super().__init__() self.grid_idx = grid_idx @@ -125,9 +128,24 @@ class ItemThumb(FlowWidget): self.driver = driver self.item_id: int | None = None self.thumb_size: tuple[int, int] = thumb_size + self.show_filename_label: bool = show_filename_label + self.label_height = 12 + self.label_spacing = 4 self.setMinimumSize(*thumb_size) self.setMaximumSize(*thumb_size) + self.setMouseTracking(True) check_size = 24 + self.setFixedSize( + thumb_size[0], + thumb_size[1] + + ((self.label_height + self.label_spacing) if show_filename_label else 0), + ) + + self.thumb_container = QWidget() + self.base_layout = QVBoxLayout(self) + self.base_layout.setContentsMargins(0, 0, 0, 0) + self.base_layout.setSpacing(0) + self.setLayout(self.base_layout) # +----------+ # | ARC FAV| Top Right: Favorite & Archived Badges @@ -135,6 +153,8 @@ class ItemThumb(FlowWidget): # | | # |EXT #| Lower Left: File Type, Tag Group Icon, or Collation Icon # +----------+ Lower Right: Collation Count, Video Length, or Word Count + # + # Filename Underneath: (Optional) Filename # Thumbnail ============================================================ @@ -144,9 +164,9 @@ class ItemThumb(FlowWidget): # || || # |*--------*| # +----------+ - self.base_layout = QVBoxLayout(self) - self.base_layout.setObjectName("baseLayout") - self.base_layout.setContentsMargins(0, 0, 0, 0) + self.thumb_layout = QVBoxLayout(self.thumb_container) + self.thumb_layout.setObjectName("baseLayout") + self.thumb_layout.setContentsMargins(0, 0, 0, 0) # +----------+ # |[~~~~~~~~]| @@ -159,7 +179,7 @@ class ItemThumb(FlowWidget): self.top_layout.setContentsMargins(6, 6, 6, 6) self.top_container = QWidget() self.top_container.setLayout(self.top_layout) - self.base_layout.addWidget(self.top_container) + self.thumb_layout.addWidget(self.top_container) # +----------+ # |[~~~~~~~~]| @@ -167,7 +187,7 @@ class ItemThumb(FlowWidget): # | | | # | v | # +----------+ - self.base_layout.addStretch(2) + self.thumb_layout.addStretch(2) # +----------+ # |[~~~~~~~~]| @@ -180,25 +200,26 @@ class ItemThumb(FlowWidget): self.bottom_layout.setContentsMargins(6, 6, 6, 6) self.bottom_container = QWidget() self.bottom_container.setLayout(self.bottom_layout) - self.base_layout.addWidget(self.bottom_container) + self.thumb_layout.addWidget(self.bottom_container) - self.thumb_button = ThumbButton(self, thumb_size) + self.thumb_button = ThumbButton(self.thumb_container, thumb_size) self.renderer = ThumbRenderer() self.renderer.updated.connect( - lambda ts, i, s, ext: ( + lambda ts, i, s, fn, ext: ( self.update_thumb(ts, image=i), self.update_size(ts, size=s), + self.set_filename_text(fn), self.set_extension(ext), ) ) self.thumb_button.setFlat(True) - self.thumb_button.setLayout(self.base_layout) + self.thumb_button.setLayout(self.thumb_layout) self.thumb_button.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) self.opener = FileOpenerHelper("") open_file_action = QAction("Open file", self) open_file_action.triggered.connect(self.opener.open_file) - open_explorer_action = QAction("Open file in explorer", self) + open_explorer_action = QAction(PlatformStrings.open_file_str, self) open_explorer_action.triggered.connect(self.opener.open_explorer) self.thumb_button.addAction(open_file_action) self.thumb_button.addAction(open_explorer_action) @@ -284,6 +305,16 @@ class ItemThumb(FlowWidget): self.badges[badge_type] = badge self.cb_layout.addWidget(badge) + # Filename Label ======================================================= + self.file_label = QLabel(text="Filename") + self.file_label.setStyleSheet(ItemThumb.filename_style) + self.file_label.setMaximumHeight(self.label_height) + if not show_filename_label: + self.file_label.setHidden(True) + + self.base_layout.addWidget(self.thumb_container) + self.base_layout.addWidget(self.file_label) + self.set_mode(mode) @property @@ -297,11 +328,11 @@ class ItemThumb(FlowWidget): def set_mode(self, mode: ItemType | None) -> None: if mode is None: self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, on=True) - self.unsetCursor() + self.thumb_button.unsetCursor() self.thumb_button.setHidden(True) elif mode == ItemType.ENTRY and self.mode != ItemType.ENTRY: self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, on=False) - self.setCursor(Qt.CursorShape.PointingHandCursor) + self.thumb_button.setCursor(Qt.CursorShape.PointingHandCursor) self.thumb_button.setHidden(False) self.cb_container.setHidden(False) # Count Badge depends on file extension (video length, word count) @@ -311,7 +342,7 @@ class ItemThumb(FlowWidget): self.ext_badge.setHidden(True) elif mode == ItemType.COLLATION and self.mode != ItemType.COLLATION: self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, on=False) - self.setCursor(Qt.CursorShape.PointingHandCursor) + self.thumb_button.setCursor(Qt.CursorShape.PointingHandCursor) self.thumb_button.setHidden(False) self.cb_container.setHidden(True) self.ext_badge.setHidden(True) @@ -320,7 +351,7 @@ class ItemThumb(FlowWidget): self.item_type_badge.setHidden(False) elif mode == ItemType.TAG_GROUP and self.mode != ItemType.TAG_GROUP: self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, on=False) - self.setCursor(Qt.CursorShape.PointingHandCursor) + self.thumb_button.setCursor(Qt.CursorShape.PointingHandCursor) self.thumb_button.setHidden(False) self.ext_badge.setHidden(True) self.count_badge.setHidden(False) @@ -330,10 +361,26 @@ class ItemThumb(FlowWidget): def set_extension(self, ext: str) -> None: if ext and ext.startswith(".") is False: ext = "." + ext - if ext and ext not in IMAGE_TYPES or ext in [".gif", ".apng"]: + media_types: set[MediaType] = MediaCategories.get_types(ext) + if ( + ext + and not MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_TYPES) + or MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_RAW_TYPES) + or MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_VECTOR_TYPES) + or MediaCategories.is_ext_in_category(ext, MediaCategories.ADOBE_PHOTOSHOP_TYPES) + or ext + in [ + ".apng", + ".avif", + ".exr", + ".gif", + ".jxl", + ".webp", + ] + ): self.ext_badge.setHidden(False) self.ext_badge.setText(ext.upper()[1:]) - if ext in VIDEO_TYPES + AUDIO_TYPES: + if MediaType.VIDEO in media_types or MediaType.AUDIO in media_types: self.count_badge.setHidden(False) else: if self.mode == ItemType.ENTRY: @@ -349,14 +396,40 @@ class ItemThumb(FlowWidget): self.ext_badge.setHidden(True) self.count_badge.setHidden(True) + def set_filename_text(self, filename: Path | str | None): + self.file_label.setText(str(filename)) + + def set_filename_visibility(self, set_visible: bool): + """Toggle the visibility of the filename label. + + Args: + set_visible (bool): Show the filename, true or false. + """ + if set_visible: + if self.file_label.isHidden(): + self.file_label.setHidden(False) + self.setFixedHeight(self.thumb_size[1] + self.label_height + self.label_spacing) + else: + self.file_label.setHidden(True) + self.setFixedHeight(self.thumb_size[1]) + self.show_filename_label = set_visible + def update_thumb(self, timestamp: float, image: QPixmap | None = None): """Update attributes of a thumbnail element.""" if timestamp > ItemThumb.update_cutoff: self.thumb_button.setIcon(image if image else QPixmap()) def update_size(self, timestamp: float, size: QSize): - """Updates attributes of a thumbnail element.""" - if timestamp > ItemThumb.update_cutoff and self.thumb_button.iconSize != size: + """Updates attributes of a thumbnail element. + + Args: + timestamp (float | None): The UTC timestamp for when this call was + originally dispatched. Used to skip outdated jobs. + + size (QSize): The new thumbnail size to set. + """ + if timestamp > ItemThumb.update_cutoff: + self.thumb_size = size.toTuple() # type: ignore self.thumb_button.setIconSize(size) self.thumb_button.setMinimumSize(size) self.thumb_button.setMaximumSize(size) @@ -364,9 +437,9 @@ class ItemThumb(FlowWidget): def update_clickable(self, clickable: typing.Callable): """Updates attributes of a thumbnail element.""" if self.thumb_button.is_connected: - self.thumb_button.clicked.disconnect() + self.thumb_button.pressed.disconnect() if clickable: - self.thumb_button.clicked.connect(clickable) + self.thumb_button.pressed.connect(clickable) self.thumb_button.is_connected = True def refresh_badge(self, entry: Entry | None = None): @@ -436,9 +509,7 @@ class ItemThumb(FlowWidget): entry, toggle_value, tag_id, _FieldID.TAGS_META.name, create_field=True ) # update the entry - self.driver.frame_content[idx] = self.lib.search_library( - FilterState(id=entry.id) - ).items[0] + self.driver.frame_content[idx] = self.lib.get_entry_full(entry.id) self.driver.update_badges(update_items) @@ -466,3 +537,29 @@ class ItemThumb(FlowWidget): if self.driver.preview_panel.is_open: self.driver.preview_panel.update_widgets() + + def mouseMoveEvent(self, event): # noqa: N802 + if event.buttons() is not Qt.MouseButton.LeftButton: + return + + drag = QDrag(self.driver) + paths = [] + mimedata = QMimeData() + + selected_idxs = self.driver.selected + if self.grid_idx not in selected_idxs: + selected_idxs = [self.grid_idx] + + for grid_idx in selected_idxs: + id = self.driver.item_thumbs[grid_idx].item_id + entry = self.lib.get_entry(id) + if not entry: + continue + + url = QUrl.fromLocalFile(Path(self.lib.library_dir) / entry.path) + paths.append(url) + + mimedata.setUrls(paths) + drag.setMimeData(mimedata) + drag.exec(Qt.DropAction.CopyAction) + logger.info("dragged files to external program", thumbnail_indexs=selected_idxs) diff --git a/tagstudio/src/qt/widgets/media_player.py b/tagstudio/src/qt/widgets/media_player.py new file mode 100644 index 00000000..f2bb6f37 --- /dev/null +++ b/tagstudio/src/qt/widgets/media_player.py @@ -0,0 +1,209 @@ +# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +import logging +import typing +from pathlib import Path +from time import gmtime +from typing import Any + +from PySide6.QtCore import Qt, QUrl +from PySide6.QtGui import QIcon, QPixmap +from PySide6.QtMultimedia import QAudioOutput, QMediaDevices, QMediaPlayer +from PySide6.QtWidgets import ( + QGridLayout, + QHBoxLayout, + QLabel, + QPushButton, + QSizePolicy, + QSlider, + QWidget, +) + +if typing.TYPE_CHECKING: + from src.qt.ts_qt import QtDriver + + +class MediaPlayer(QWidget): + """A basic media player widget. + + Gives a basic control set to manage media playback. + """ + + def __init__(self, driver: "QtDriver") -> None: + super().__init__() + self.driver = driver + + self.setFixedHeight(50) + + self.filepath: Path | None = None + self.player = QMediaPlayer() + self.player.setAudioOutput(QAudioOutput(QMediaDevices().defaultAudioOutput(), self.player)) + + # Used to keep track of play state. + # It would be nice if we could use QMediaPlayer.PlaybackState, + # but this will always show StoppedState when changing + # tracks. Therefore, we wouldn't know if the previous + # state was paused or playing + self.is_paused = False + + # Subscribe to player events from MediaPlayer + self.player.positionChanged.connect(self.player_position_changed) + self.player.mediaStatusChanged.connect(self.media_status_changed) + self.player.playingChanged.connect(self.playing_changed) + self.player.audioOutput().mutedChanged.connect(self.muted_changed) + + # Media controls + self.base_layout = QGridLayout(self) + self.base_layout.setContentsMargins(0, 0, 0, 0) + self.base_layout.setSpacing(0) + + self.pslider = QSlider(self) + self.pslider.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + self.pslider.setTickPosition(QSlider.TickPosition.NoTicks) + self.pslider.setSingleStep(1) + self.pslider.setOrientation(Qt.Orientation.Horizontal) + + self.pslider.sliderReleased.connect(self.slider_released) + self.pslider.valueChanged.connect(self.slider_value_changed) + + self.media_btns_layout = QHBoxLayout() + + policy = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + + self.play_pause = QPushButton("", self) + self.play_pause.setFlat(True) + self.play_pause.setSizePolicy(policy) + self.play_pause.clicked.connect(self.toggle_pause) + + self.load_play_pause_icon(playing=False) + + self.media_btns_layout.addWidget(self.play_pause) + + self.mute = QPushButton("", self) + self.mute.setFlat(True) + self.mute.setSizePolicy(policy) + self.mute.clicked.connect(self.toggle_mute) + + self.load_mute_unmute_icon(muted=False) + + self.media_btns_layout.addWidget(self.mute) + + self.position_label = QLabel("0:00") + self.position_label.setAlignment(Qt.AlignmentFlag.AlignRight) + + self.base_layout.addWidget(self.pslider, 0, 0, 1, 2) + self.base_layout.addLayout(self.media_btns_layout, 1, 0) + self.base_layout.addWidget(self.position_label, 1, 1) + + def format_time(self, ms: int) -> str: + """Format the given time. + + Formats the given time in ms to a nicer format. + + Args: + ms: Time in ms + + Returns: + A formatted time: + + "1:43" + + The formatted time will only include the hour if + the provided time is at least 60 minutes. + """ + time = gmtime(ms / 1000) + return ( + f"{time.tm_hour}:{time.tm_min}:{time.tm_sec:02}" + if time.tm_hour > 0 + else f"{time.tm_min}:{time.tm_sec:02}" + ) + + def toggle_pause(self) -> None: + """Toggle the pause state of the media.""" + if self.player.isPlaying(): + self.player.pause() + self.is_paused = True + else: + self.player.play() + self.is_paused = False + + def toggle_mute(self) -> None: + """Toggle the mute state of the media.""" + if self.player.audioOutput().isMuted(): + self.player.audioOutput().setMuted(False) + else: + self.player.audioOutput().setMuted(True) + + def playing_changed(self, playing: bool) -> None: + self.load_play_pause_icon(playing) + + def muted_changed(self, muted: bool) -> None: + self.load_mute_unmute_icon(muted) + + def stop(self) -> None: + """Clear the filepath and stop the player.""" + self.filepath = None + self.player.stop() + + def play(self, filepath: Path) -> None: + """Set the source of the QMediaPlayer and play.""" + self.filepath = filepath + if not self.is_paused: + self.player.stop() + self.player.setSource(QUrl.fromLocalFile(self.filepath)) + self.player.play() + else: + self.player.setSource(QUrl.fromLocalFile(self.filepath)) + + def load_play_pause_icon(self, playing: bool) -> None: + icon = self.driver.rm.pause_icon if playing else self.driver.rm.play_icon + self.set_icon(self.play_pause, icon) + + def load_mute_unmute_icon(self, muted: bool) -> None: + icon = self.driver.rm.volume_mute_icon if muted else self.driver.rm.volume_icon + self.set_icon(self.mute, icon) + + def set_icon(self, btn: QPushButton, icon: Any) -> None: + pix_map = QPixmap() + if pix_map.loadFromData(icon): + btn.setIcon(QIcon(pix_map)) + else: + logging.error("failed to load svg file") + + def slider_value_changed(self, value: int) -> None: + current = self.format_time(value) + duration = self.format_time(self.player.duration()) + self.position_label.setText(f"{current} / {duration}") + + def slider_released(self) -> None: + was_playing = self.player.isPlaying() + self.player.setPosition(self.pslider.value()) + + # Setting position causes the player to start playing again. + # We should reset back to initial state. + if not was_playing: + self.player.pause() + + def player_position_changed(self, position: int) -> None: + if not self.pslider.isSliderDown(): + # User isn't using the slider, so update position in widgets. + self.pslider.setValue(position) + current = self.format_time(self.player.position()) + duration = self.format_time(self.player.duration()) + self.position_label.setText(f"{current} / {duration}") + + if self.player.duration() == position: + self.player.pause() + self.player.setPosition(0) + + def media_status_changed(self, status: QMediaPlayer.MediaStatus) -> None: + # We can only set the slider duration once we know the size of the media + if status == QMediaPlayer.MediaStatus.LoadedMedia and self.filepath is not None: + self.pslider.setMinimum(0) + self.pslider.setMaximum(self.player.duration()) + + current = self.format_time(self.player.position()) + duration = self.format_time(self.player.duration()) + self.position_label.setText(f"{current} / {duration}") diff --git a/tagstudio/src/qt/widgets/migration_modal.py b/tagstudio/src/qt/widgets/migration_modal.py new file mode 100644 index 00000000..a2978f56 --- /dev/null +++ b/tagstudio/src/qt/widgets/migration_modal.py @@ -0,0 +1,784 @@ +# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +from pathlib import Path + +import structlog +from PySide6.QtCore import QObject, Qt, QThreadPool, Signal +from PySide6.QtWidgets import ( + QApplication, + QGridLayout, + QHBoxLayout, + QLabel, + QMessageBox, + QProgressDialog, + QSizePolicy, + QVBoxLayout, + QWidget, +) +from sqlalchemy import and_, select +from sqlalchemy.orm import Session +from src.core.constants import TS_FOLDER_NAME +from src.core.enums import LibraryPrefs +from src.core.library.alchemy.enums import FieldTypeEnum, TagColor +from src.core.library.alchemy.fields import TagBoxField, _FieldID +from src.core.library.alchemy.joins import TagField, TagSubtag +from src.core.library.alchemy.library import Library as SqliteLibrary +from src.core.library.alchemy.models import Entry, Tag, TagAlias +from src.core.library.json.library import Library as JsonLibrary # type: ignore +from src.qt.helpers.custom_runnable import CustomRunnable +from src.qt.helpers.function_iterator import FunctionIterator +from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper +from src.qt.widgets.paged_panel.paged_body_wrapper import PagedBodyWrapper +from src.qt.widgets.paged_panel.paged_panel import PagedPanel +from src.qt.widgets.paged_panel.paged_panel_state import PagedPanelState + +logger = structlog.get_logger(__name__) + + +class JsonMigrationModal(QObject): + """A modal for data migration from v9.4 JSON to v9.5+ SQLite.""" + + migration_cancelled = Signal() + migration_finished = Signal() + + def __init__(self, path: Path): + super().__init__() + self.done: bool = False + self.path: Path = path + + self.stack: list[PagedPanelState] = [] + self.json_lib: JsonLibrary = None + self.sql_lib: SqliteLibrary = None + self.is_migration_initialized: bool = False + self.discrepancies: list[str] = [] + + self.title: str = f'Save Format Migration: "{self.path}"' + self.warning: str = "(!)" + + self.old_entry_count: int = 0 + self.old_tag_count: int = 0 + self.old_ext_count: int = 0 + self.old_ext_type: bool = None + + self.field_parity: bool = False + self.path_parity: bool = False + self.shorthand_parity: bool = False + self.subtag_parity: bool = False + self.alias_parity: bool = False + self.color_parity: bool = False + + self.init_page_info() + self.init_page_convert() + + self.paged_panel: PagedPanel = PagedPanel((700, 640), self.stack) + + def init_page_info(self) -> None: + """Initialize the migration info page.""" + body_wrapper: PagedBodyWrapper = PagedBodyWrapper() + body_label: QLabel = QLabel( + "Library save files created with TagStudio versions 9.4 and below will " + "need to be migrated to the new v9.5+ format." + "
" + "

What you need to know:

" + "
    " + "
  • Your existing library save file will NOT be deleted
  • " + "
  • Your personal files will NOT be deleted, moved, or modified
  • " + "
  • The new v9.5+ save format can not be opened in earlier versions of TagStudio
  • " + "
" + ) + body_label.setWordWrap(True) + body_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + body_wrapper.layout().addWidget(body_label) + body_wrapper.layout().setContentsMargins(0, 36, 0, 0) + + cancel_button: QPushButtonWrapper = QPushButtonWrapper("Cancel") + next_button: QPushButtonWrapper = QPushButtonWrapper("Continue") + cancel_button.clicked.connect(self.migration_cancelled.emit) + + self.stack.append( + PagedPanelState( + title=self.title, + body_wrapper=body_wrapper, + buttons=[cancel_button, 1, next_button], + connect_to_back=[cancel_button], + connect_to_next=[next_button], + ) + ) + + def init_page_convert(self) -> None: + """Initialize the migration conversion page.""" + self.body_wrapper_01: PagedBodyWrapper = PagedBodyWrapper() + body_container: QWidget = QWidget() + body_container_layout: QHBoxLayout = QHBoxLayout(body_container) + body_container_layout.setContentsMargins(0, 0, 0, 0) + + tab: str = " " + self.match_text: str = "Matched" + self.differ_text: str = "Discrepancy" + + entries_text: str = "Entries:" + tags_text: str = "Tags:" + shorthand_text: str = tab + "Shorthands:" + subtags_text: str = tab + "Parent Tags:" + aliases_text: str = tab + "Aliases:" + colors_text: str = tab + "Colors:" + ext_text: str = "File Extension List:" + ext_type_text: str = "Extension List Type:" + desc_text: str = ( + "
Start and preview the results of the library migration process. " + 'The converted library will not be used unless you click "Finish Migration". ' + "

" + 'Library data should either have matching values or a feature a "Matched" label. ' + 'Values that do not match will be displayed in red and feature a "(!)" ' + "symbol next to them." + "
" + "This process may take up to several minutes for larger libraries." + "
" + ) + path_parity_text: str = tab + "Paths:" + field_parity_text: str = tab + "Fields:" + + self.entries_row: int = 0 + self.path_row: int = 1 + self.fields_row: int = 2 + self.tags_row: int = 3 + self.shorthands_row: int = 4 + self.subtags_row: int = 5 + self.aliases_row: int = 6 + self.colors_row: int = 7 + self.ext_row: int = 8 + self.ext_type_row: int = 9 + + old_lib_container: QWidget = QWidget() + old_lib_layout: QVBoxLayout = QVBoxLayout(old_lib_container) + old_lib_title: QLabel = QLabel("

v9.4 Library

") + old_lib_title.setAlignment(Qt.AlignmentFlag.AlignCenter) + old_lib_layout.addWidget(old_lib_title) + + old_content_container: QWidget = QWidget() + self.old_content_layout: QGridLayout = QGridLayout(old_content_container) + self.old_content_layout.setContentsMargins(0, 0, 0, 0) + self.old_content_layout.setSpacing(3) + self.old_content_layout.addWidget(QLabel(entries_text), self.entries_row, 0) + self.old_content_layout.addWidget(QLabel(path_parity_text), self.path_row, 0) + self.old_content_layout.addWidget(QLabel(field_parity_text), self.fields_row, 0) + self.old_content_layout.addWidget(QLabel(tags_text), self.tags_row, 0) + self.old_content_layout.addWidget(QLabel(shorthand_text), self.shorthands_row, 0) + self.old_content_layout.addWidget(QLabel(subtags_text), self.subtags_row, 0) + self.old_content_layout.addWidget(QLabel(aliases_text), self.aliases_row, 0) + self.old_content_layout.addWidget(QLabel(colors_text), self.colors_row, 0) + self.old_content_layout.addWidget(QLabel(ext_text), self.ext_row, 0) + self.old_content_layout.addWidget(QLabel(ext_type_text), self.ext_type_row, 0) + + old_entry_count: QLabel = QLabel() + old_entry_count.setAlignment(Qt.AlignmentFlag.AlignRight) + old_path_value: QLabel = QLabel() + old_path_value.setAlignment(Qt.AlignmentFlag.AlignRight) + old_field_value: QLabel = QLabel() + old_field_value.setAlignment(Qt.AlignmentFlag.AlignRight) + old_tag_count: QLabel = QLabel() + old_tag_count.setAlignment(Qt.AlignmentFlag.AlignRight) + old_shorthand_count: QLabel = QLabel() + old_shorthand_count.setAlignment(Qt.AlignmentFlag.AlignRight) + old_subtag_value: QLabel = QLabel() + old_subtag_value.setAlignment(Qt.AlignmentFlag.AlignRight) + old_alias_value: QLabel = QLabel() + old_alias_value.setAlignment(Qt.AlignmentFlag.AlignRight) + old_color_value: QLabel = QLabel() + old_color_value.setAlignment(Qt.AlignmentFlag.AlignRight) + old_ext_count: QLabel = QLabel() + old_ext_count.setAlignment(Qt.AlignmentFlag.AlignRight) + old_ext_type: QLabel = QLabel() + old_ext_type.setAlignment(Qt.AlignmentFlag.AlignRight) + + self.old_content_layout.addWidget(old_entry_count, self.entries_row, 1) + self.old_content_layout.addWidget(old_path_value, self.path_row, 1) + self.old_content_layout.addWidget(old_field_value, self.fields_row, 1) + self.old_content_layout.addWidget(old_tag_count, self.tags_row, 1) + self.old_content_layout.addWidget(old_shorthand_count, self.shorthands_row, 1) + self.old_content_layout.addWidget(old_subtag_value, self.subtags_row, 1) + self.old_content_layout.addWidget(old_alias_value, self.aliases_row, 1) + self.old_content_layout.addWidget(old_color_value, self.colors_row, 1) + self.old_content_layout.addWidget(old_ext_count, self.ext_row, 1) + self.old_content_layout.addWidget(old_ext_type, self.ext_type_row, 1) + + self.old_content_layout.addWidget(QLabel(), self.path_row, 2) + self.old_content_layout.addWidget(QLabel(), self.fields_row, 2) + self.old_content_layout.addWidget(QLabel(), self.shorthands_row, 2) + self.old_content_layout.addWidget(QLabel(), self.subtags_row, 2) + self.old_content_layout.addWidget(QLabel(), self.aliases_row, 2) + self.old_content_layout.addWidget(QLabel(), self.colors_row, 2) + + old_lib_layout.addWidget(old_content_container) + + new_lib_container: QWidget = QWidget() + new_lib_layout: QVBoxLayout = QVBoxLayout(new_lib_container) + new_lib_title: QLabel = QLabel("

v9.5+ Library

") + new_lib_title.setAlignment(Qt.AlignmentFlag.AlignCenter) + new_lib_layout.addWidget(new_lib_title) + + new_content_container: QWidget = QWidget() + self.new_content_layout: QGridLayout = QGridLayout(new_content_container) + self.new_content_layout.setContentsMargins(0, 0, 0, 0) + self.new_content_layout.setSpacing(3) + self.new_content_layout.addWidget(QLabel(entries_text), self.entries_row, 0) + self.new_content_layout.addWidget(QLabel(path_parity_text), self.path_row, 0) + self.new_content_layout.addWidget(QLabel(field_parity_text), self.fields_row, 0) + self.new_content_layout.addWidget(QLabel(tags_text), self.tags_row, 0) + self.new_content_layout.addWidget(QLabel(shorthand_text), self.shorthands_row, 0) + self.new_content_layout.addWidget(QLabel(subtags_text), self.subtags_row, 0) + self.new_content_layout.addWidget(QLabel(aliases_text), self.aliases_row, 0) + self.new_content_layout.addWidget(QLabel(colors_text), self.colors_row, 0) + self.new_content_layout.addWidget(QLabel(ext_text), self.ext_row, 0) + self.new_content_layout.addWidget(QLabel(ext_type_text), self.ext_type_row, 0) + + new_entry_count: QLabel = QLabel() + new_entry_count.setAlignment(Qt.AlignmentFlag.AlignRight) + path_parity_value: QLabel = QLabel() + path_parity_value.setAlignment(Qt.AlignmentFlag.AlignRight) + field_parity_value: QLabel = QLabel() + field_parity_value.setAlignment(Qt.AlignmentFlag.AlignRight) + new_tag_count: QLabel = QLabel() + new_tag_count.setAlignment(Qt.AlignmentFlag.AlignRight) + new_shorthand_count: QLabel = QLabel() + new_shorthand_count.setAlignment(Qt.AlignmentFlag.AlignRight) + subtag_parity_value: QLabel = QLabel() + subtag_parity_value.setAlignment(Qt.AlignmentFlag.AlignRight) + alias_parity_value: QLabel = QLabel() + alias_parity_value.setAlignment(Qt.AlignmentFlag.AlignRight) + new_color_value: QLabel = QLabel() + new_color_value.setAlignment(Qt.AlignmentFlag.AlignRight) + new_ext_count: QLabel = QLabel() + new_ext_count.setAlignment(Qt.AlignmentFlag.AlignRight) + new_ext_type: QLabel = QLabel() + new_ext_type.setAlignment(Qt.AlignmentFlag.AlignRight) + + self.new_content_layout.addWidget(new_entry_count, self.entries_row, 1) + self.new_content_layout.addWidget(path_parity_value, self.path_row, 1) + self.new_content_layout.addWidget(field_parity_value, self.fields_row, 1) + self.new_content_layout.addWidget(new_tag_count, self.tags_row, 1) + self.new_content_layout.addWidget(new_shorthand_count, self.shorthands_row, 1) + self.new_content_layout.addWidget(subtag_parity_value, self.subtags_row, 1) + self.new_content_layout.addWidget(alias_parity_value, self.aliases_row, 1) + self.new_content_layout.addWidget(new_color_value, self.colors_row, 1) + self.new_content_layout.addWidget(new_ext_count, self.ext_row, 1) + self.new_content_layout.addWidget(new_ext_type, self.ext_type_row, 1) + + self.new_content_layout.addWidget(QLabel(), self.entries_row, 2) + self.new_content_layout.addWidget(QLabel(), self.path_row, 2) + self.new_content_layout.addWidget(QLabel(), self.fields_row, 2) + self.new_content_layout.addWidget(QLabel(), self.shorthands_row, 2) + self.new_content_layout.addWidget(QLabel(), self.tags_row, 2) + self.new_content_layout.addWidget(QLabel(), self.subtags_row, 2) + self.new_content_layout.addWidget(QLabel(), self.aliases_row, 2) + self.new_content_layout.addWidget(QLabel(), self.colors_row, 2) + self.new_content_layout.addWidget(QLabel(), self.ext_row, 2) + self.new_content_layout.addWidget(QLabel(), self.ext_type_row, 2) + + new_lib_layout.addWidget(new_content_container) + + desc_label = QLabel(desc_text) + desc_label.setWordWrap(True) + + body_container_layout.addStretch(2) + body_container_layout.addWidget(old_lib_container) + body_container_layout.addStretch(1) + body_container_layout.addWidget(new_lib_container) + body_container_layout.addStretch(2) + self.body_wrapper_01.layout().addWidget(body_container) + self.body_wrapper_01.layout().addWidget(desc_label) + self.body_wrapper_01.layout().setSpacing(12) + + back_button: QPushButtonWrapper = QPushButtonWrapper("Back") + start_button: QPushButtonWrapper = QPushButtonWrapper("Start and Preview") + start_button.setMinimumWidth(120) + start_button.clicked.connect(self.migrate) + start_button.clicked.connect(lambda: finish_button.setDisabled(False)) + start_button.clicked.connect(lambda: start_button.setDisabled(True)) + finish_button: QPushButtonWrapper = QPushButtonWrapper("Finish Migration") + finish_button.setMinimumWidth(120) + finish_button.setDisabled(True) + finish_button.clicked.connect(self.finish_migration) + finish_button.clicked.connect(self.migration_finished.emit) + + self.stack.append( + PagedPanelState( + title=self.title, + body_wrapper=self.body_wrapper_01, + buttons=[back_button, 1, start_button, 1, finish_button], + connect_to_back=[back_button], + connect_to_next=[finish_button], + ) + ) + + def migrate(self, skip_ui: bool = False): + """Open and migrate the JSON library to SQLite.""" + if not self.is_migration_initialized: + self.paged_panel.update_frame() + self.paged_panel.update() + + # Open the JSON Library + self.json_lib = JsonLibrary() + self.json_lib.open_library(self.path) + + # Update JSON UI + self.update_json_entry_count(len(self.json_lib.entries)) + self.update_json_tag_count(len(self.json_lib.tags)) + self.update_json_ext_count(len(self.json_lib.ext_list)) + self.update_json_ext_type(self.json_lib.is_exclude_list) + + self.migration_progress(skip_ui=skip_ui) + self.is_migration_initialized = True + + def migration_progress(self, skip_ui: bool = False): + """Initialize the progress bar and iterator for the library migration.""" + pb = QProgressDialog( + labelText="", + cancelButtonText="", + minimum=0, + maximum=0, + ) + pb.setCancelButton(None) + self.body_wrapper_01.layout().addWidget(pb) + + iterator = FunctionIterator(self.migration_iterator) + iterator.value.connect( + lambda x: ( + pb.setLabelText(f"

{x}

"), + self.update_sql_value_ui(show_msg_box=False) + if x == "Checking for Parity..." + else (), + self.update_parity_ui() if x == "Checking for Parity..." else (), + ) + ) + r = CustomRunnable(iterator.run) + r.done.connect( + lambda: ( + self.update_sql_value_ui(show_msg_box=not skip_ui), + pb.setMinimum(1), + pb.setValue(1), + ) + ) + QThreadPool.globalInstance().start(r) + + def migration_iterator(self): + """Iterate over the library migration process.""" + try: + # Convert JSON Library to SQLite + yield "Creating SQL Database Tables..." + self.sql_lib = SqliteLibrary() + self.temp_path: Path = ( + self.json_lib.library_dir / TS_FOLDER_NAME / "migration_ts_library.sqlite" + ) + self.sql_lib.storage_path = self.temp_path + if self.temp_path.exists(): + logger.info('Temporary migration file "temp_path" already exists. Removing...') + self.temp_path.unlink() + self.sql_lib.open_sqlite_library( + self.json_lib.library_dir, is_new=True, add_default_data=False + ) + yield f"Migrating {len(self.json_lib.entries):,d} File Entries..." + self.sql_lib.migrate_json_to_sqlite(self.json_lib) + yield "Checking for Parity..." + check_set = set() + check_set.add(self.check_field_parity()) + check_set.add(self.check_path_parity()) + check_set.add(self.check_shorthand_parity()) + check_set.add(self.check_subtag_parity()) + check_set.add(self.check_alias_parity()) + check_set.add(self.check_color_parity()) + self.update_parity_ui() + if False not in check_set: + yield "Migration Complete!" + else: + yield "Migration Complete, Discrepancies Found" + self.done = True + + except Exception as e: + yield f"Error: {type(e).__name__}" + self.done = True + + def update_parity_ui(self): + """Update all parity values UI.""" + self.update_parity_value(self.fields_row, self.field_parity) + self.update_parity_value(self.path_row, self.path_parity) + self.update_parity_value(self.shorthands_row, self.shorthand_parity) + self.update_parity_value(self.subtags_row, self.subtag_parity) + self.update_parity_value(self.aliases_row, self.alias_parity) + self.update_parity_value(self.colors_row, self.color_parity) + self.sql_lib.close() + + def update_sql_value_ui(self, show_msg_box: bool = True): + """Update the SQL value count UI.""" + self.update_sql_value( + self.entries_row, + self.sql_lib.entries_count, + self.old_entry_count, + ) + self.update_sql_value( + self.tags_row, + len(self.sql_lib.tags), + self.old_tag_count, + ) + self.update_sql_value( + self.ext_row, + len(self.sql_lib.prefs(LibraryPrefs.EXTENSION_LIST)), + self.old_ext_count, + ) + self.update_sql_value( + self.ext_type_row, + self.sql_lib.prefs(LibraryPrefs.IS_EXCLUDE_LIST), + self.old_ext_type, + ) + logger.info("Parity check complete!") + if self.discrepancies: + logger.warning("Discrepancies found:") + logger.warning("\n".join(self.discrepancies)) + QApplication.beep() + if not show_msg_box: + return + msg_box = QMessageBox() + msg_box.setWindowTitle("Library Discrepancies Found") + msg_box.setText( + "Discrepancies were found between the original and converted library formats. " + "Please review and choose to whether continue with the migration or to cancel." + ) + msg_box.setDetailedText("\n".join(self.discrepancies)) + msg_box.setIcon(QMessageBox.Icon.Warning) + msg_box.exec() + + def finish_migration(self): + """Finish the migration upon user approval.""" + final_name = self.json_lib.library_dir / TS_FOLDER_NAME / SqliteLibrary.SQL_FILENAME + if self.temp_path.exists(): + self.temp_path.rename(final_name) + + def update_json_entry_count(self, value: int): + self.old_entry_count = value + label: QLabel = self.old_content_layout.itemAtPosition(self.entries_row, 1).widget() # type:ignore + label.setText(self.color_value_default(value)) + + def update_json_tag_count(self, value: int): + self.old_tag_count = value + label: QLabel = self.old_content_layout.itemAtPosition(self.tags_row, 1).widget() # type:ignore + label.setText(self.color_value_default(value)) + + def update_json_ext_count(self, value: int): + self.old_ext_count = value + label: QLabel = self.old_content_layout.itemAtPosition(self.ext_row, 1).widget() # type:ignore + label.setText(self.color_value_default(value)) + + def update_json_ext_type(self, value: bool): + self.old_ext_type = value + label: QLabel = self.old_content_layout.itemAtPosition(self.ext_type_row, 1).widget() # type:ignore + label.setText(self.color_value_default(value)) + + def update_sql_value(self, row: int, value: int | bool, old_value: int | bool): + label: QLabel = self.new_content_layout.itemAtPosition(row, 1).widget() # type:ignore + warning_icon: QLabel = self.new_content_layout.itemAtPosition(row, 2).widget() # type:ignore + label.setText(self.color_value_conditional(old_value, value)) + warning_icon.setText("" if old_value == value else self.warning) + + def update_parity_value(self, row: int, value: bool): + result: str = self.match_text if value else self.differ_text + old_label: QLabel = self.old_content_layout.itemAtPosition(row, 1).widget() # type:ignore + new_label: QLabel = self.new_content_layout.itemAtPosition(row, 1).widget() # type:ignore + old_warning_icon: QLabel = self.old_content_layout.itemAtPosition(row, 2).widget() # type:ignore + new_warning_icon: QLabel = self.new_content_layout.itemAtPosition(row, 2).widget() # type:ignore + old_label.setText(self.color_value_conditional(self.match_text, result)) + new_label.setText(self.color_value_conditional(self.match_text, result)) + old_warning_icon.setText("" if value else self.warning) + new_warning_icon.setText("" if value else self.warning) + + def color_value_default(self, value: int) -> str: + """Apply the default color to a value.""" + return str(f"{value}") + + def color_value_conditional(self, old_value: int | str, new_value: int | str) -> str: + """Apply a conditional color to a value.""" + red: str = "#e22c3c" + green: str = "#28bb48" + color = green if old_value == new_value else red + return str(f"{new_value}") + + def check_field_parity(self) -> bool: + """Check if all JSON field data matches the new SQL field data.""" + + def sanitize_field(session, entry: Entry, value, type, type_key): + if type is FieldTypeEnum.TAGS: + tags = list( + session.scalars( + select(Tag.id) + .join(TagField) + .join(TagBoxField) + .where( + and_( + TagBoxField.entry_id == entry.id, + TagBoxField.id == TagField.field_id, + TagBoxField.type_key == type_key, + ) + ) + ) + ) + + return set(tags) if tags else None + else: + return value if value else None + + def sanitize_json_field(value): + if isinstance(value, list): + return set(value) if value else None + else: + return value if value else None + + with Session(self.sql_lib.engine) as session: + for json_entry in self.json_lib.entries: + sql_fields: list[tuple] = [] + json_fields: list[tuple] = [] + + sql_entry: Entry = session.scalar( + select(Entry).where(Entry.id == json_entry.id + 1) + ) + if not sql_entry: + logger.info( + "[Field Comparison]", + message=f"NEW (SQL): SQL Entry ID mismatch: {json_entry.id+1}", + ) + self.discrepancies.append( + f"[Field Comparison]:\nNEW (SQL): SQL Entry ID not found: {json_entry.id+1}" + ) + self.field_parity = False + return self.field_parity + + for sf in sql_entry.fields: + sql_fields.append( + ( + sql_entry.id, + sf.type.key, + sanitize_field(session, sql_entry, sf.value, sf.type.type, sf.type_key), + ) + ) + sql_fields.sort() + + # NOTE: The JSON database allowed for separate tag fields of the same type with + # different values. The SQL database does not, and instead merges these values + # across all instances of that field on an entry. + # TODO: ROADMAP: "Tag Categories" will merge all field tags onto the entry. + # All visual separation from there will be data-driven from the tag itself. + meta_tags_count: int = 0 + content_tags_count: int = 0 + tags_count: int = 0 + merged_meta_tags: set[int] = set() + merged_content_tags: set[int] = set() + merged_tags: set[int] = set() + for jf in json_entry.fields: + key: str = self.sql_lib.get_field_name_from_id(list(jf.keys())[0]).name + value = sanitize_json_field(list(jf.values())[0]) + + if key == _FieldID.TAGS_META.name: + meta_tags_count += 1 + merged_meta_tags = merged_meta_tags.union(value or []) + elif key == _FieldID.TAGS_CONTENT.name: + content_tags_count += 1 + merged_content_tags = merged_content_tags.union(value or []) + elif key == _FieldID.TAGS.name: + tags_count += 1 + merged_tags = merged_tags.union(value or []) + else: + # JSON IDs start at 0 instead of 1 + json_fields.append((json_entry.id + 1, key, value)) + + if meta_tags_count: + for _ in range(0, meta_tags_count): + json_fields.append( + ( + json_entry.id + 1, + _FieldID.TAGS_META.name, + merged_meta_tags if merged_meta_tags else None, + ) + ) + if content_tags_count: + for _ in range(0, content_tags_count): + json_fields.append( + ( + json_entry.id + 1, + _FieldID.TAGS_CONTENT.name, + merged_content_tags if merged_content_tags else None, + ) + ) + if tags_count: + for _ in range(0, tags_count): + json_fields.append( + ( + json_entry.id + 1, + _FieldID.TAGS.name, + merged_tags if merged_tags else None, + ) + ) + json_fields.sort() + + if not ( + json_fields is not None + and sql_fields is not None + and (json_fields == sql_fields) + ): + self.discrepancies.append( + f"[Field Comparison]:\nOLD (JSON):{json_fields}\nNEW (SQL):{sql_fields}" + ) + self.field_parity = False + return self.field_parity + + logger.info( + "[Field Comparison]", + fields="\n".join([str(x) for x in zip(json_fields, sql_fields)]), + ) + + self.field_parity = True + return self.field_parity + + def check_path_parity(self) -> bool: + """Check if all JSON file paths match the new SQL paths.""" + with Session(self.sql_lib.engine) as session: + json_paths: list = sorted([x.path / x.filename for x in self.json_lib.entries]) + sql_paths: list = sorted(list(session.scalars(select(Entry.path)))) + self.path_parity = ( + json_paths is not None and sql_paths is not None and (json_paths == sql_paths) + ) + return self.path_parity + + def check_subtag_parity(self) -> bool: + """Check if all JSON subtags match the new SQL subtags.""" + sql_subtags: set[int] = None + json_subtags: set[int] = None + + with Session(self.sql_lib.engine) as session: + for tag in self.sql_lib.tags: + tag_id = tag.id # Tag IDs start at 0 + sql_subtags = set( + session.scalars(select(TagSubtag.child_id).where(TagSubtag.parent_id == tag.id)) + ) + # JSON tags allowed self-parenting; SQL tags no longer allow this. + json_subtags = set(self.json_lib.get_tag(tag_id).subtag_ids).difference( + set([self.json_lib.get_tag(tag_id).id]) + ) + + logger.info( + "[Subtag Parity]", + tag_id=tag_id, + json_subtags=json_subtags, + sql_subtags=sql_subtags, + ) + + if not ( + sql_subtags is not None + and json_subtags is not None + and (sql_subtags == json_subtags) + ): + self.discrepancies.append( + f"[Subtag Parity]:\nOLD (JSON):{json_subtags}\nNEW (SQL):{sql_subtags}" + ) + self.subtag_parity = False + return self.subtag_parity + + self.subtag_parity = True + return self.subtag_parity + + def check_ext_type(self) -> bool: + return self.json_lib.is_exclude_list == self.sql_lib.prefs(LibraryPrefs.IS_EXCLUDE_LIST) + + def check_alias_parity(self) -> bool: + """Check if all JSON aliases match the new SQL aliases.""" + sql_aliases: set[str] = None + json_aliases: set[str] = None + + with Session(self.sql_lib.engine) as session: + for tag in self.sql_lib.tags: + tag_id = tag.id # Tag IDs start at 0 + sql_aliases = set( + session.scalars(select(TagAlias.name).where(TagAlias.tag_id == tag.id)) + ) + json_aliases = set([x for x in self.json_lib.get_tag(tag_id).aliases if x]) + + logger.info( + "[Alias Parity]", + tag_id=tag_id, + json_aliases=json_aliases, + sql_aliases=sql_aliases, + ) + if not ( + sql_aliases is not None + and json_aliases is not None + and (sql_aliases == json_aliases) + ): + self.discrepancies.append( + f"[Alias Parity]:\nOLD (JSON):{json_aliases}\nNEW (SQL):{sql_aliases}" + ) + self.alias_parity = False + return self.alias_parity + + self.alias_parity = True + return self.alias_parity + + def check_shorthand_parity(self) -> bool: + """Check if all JSON shorthands match the new SQL shorthands.""" + sql_shorthand: str = None + json_shorthand: str = None + + for tag in self.sql_lib.tags: + tag_id = tag.id # Tag IDs start at 0 + sql_shorthand = tag.shorthand + json_shorthand = self.json_lib.get_tag(tag_id).shorthand + + logger.info( + "[Shorthand Parity]", + tag_id=tag_id, + json_shorthand=json_shorthand, + sql_shorthand=sql_shorthand, + ) + + if not ( + sql_shorthand is not None + and json_shorthand is not None + and (sql_shorthand == json_shorthand) + ): + self.discrepancies.append( + f"[Shorthand Parity]:\nOLD (JSON):{json_shorthand}\nNEW (SQL):{sql_shorthand}" + ) + self.shorthand_parity = False + return self.shorthand_parity + + self.shorthand_parity = True + return self.shorthand_parity + + def check_color_parity(self) -> bool: + """Check if all JSON tag colors match the new SQL tag colors.""" + sql_color: str = None + json_color: str = None + + for tag in self.sql_lib.tags: + tag_id = tag.id # Tag IDs start at 0 + sql_color = tag.color.name + json_color = ( + TagColor.get_color_from_str(self.json_lib.get_tag(tag_id).color).name + if self.json_lib.get_tag(tag_id).color != "" + else TagColor.DEFAULT.name + ) + + logger.info( + "[Color Parity]", + tag_id=tag_id, + json_color=json_color, + sql_color=sql_color, + ) + + if not (sql_color is not None and json_color is not None and (sql_color == json_color)): + self.discrepancies.append( + f"[Color Parity]:\nOLD (JSON):{json_color}\nNEW (SQL):{sql_color}" + ) + self.color_parity = False + return self.color_parity + + self.color_parity = True + return self.color_parity diff --git a/tagstudio/src/qt/widgets/paged_panel/paged_body_wrapper.py b/tagstudio/src/qt/widgets/paged_panel/paged_body_wrapper.py new file mode 100644 index 00000000..7e2e87f2 --- /dev/null +++ b/tagstudio/src/qt/widgets/paged_panel/paged_body_wrapper.py @@ -0,0 +1,20 @@ +# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QVBoxLayout, + QWidget, +) + + +class PagedBodyWrapper(QWidget): + """A state object for paged panels.""" + + def __init__(self): + super().__init__() + layout: QVBoxLayout = QVBoxLayout(self) + layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) diff --git a/tagstudio/src/qt/widgets/paged_panel/paged_panel.py b/tagstudio/src/qt/widgets/paged_panel/paged_panel.py new file mode 100644 index 00000000..de84d078 --- /dev/null +++ b/tagstudio/src/qt/widgets/paged_panel/paged_panel.py @@ -0,0 +1,112 @@ +# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +import structlog +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QHBoxLayout, + QLabel, + QVBoxLayout, + QWidget, +) +from src.qt.widgets.paged_panel.paged_panel_state import PagedPanelState + +logger = structlog.get_logger(__name__) + + +class PagedPanel(QWidget): + """A paginated modal panel.""" + + def __init__(self, size: tuple[int, int], stack: list[PagedPanelState]): + super().__init__() + + self._stack: list[PagedPanelState] = stack + self._index: int = 0 + + self.setMinimumSize(*size) + self.setWindowModality(Qt.WindowModality.ApplicationModal) + + self.root_layout = QVBoxLayout(self) + self.root_layout.setObjectName("baseLayout") + self.root_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.root_layout.setContentsMargins(0, 0, 0, 0) + + self.content_container = QWidget() + self.content_layout = QVBoxLayout(self.content_container) + self.content_layout.setContentsMargins(12, 12, 12, 12) + + self.title_label = QLabel() + self.title_label.setObjectName("fieldTitle") + self.title_label.setWordWrap(True) + self.title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + self.body_container = QWidget() + self.body_container.setObjectName("bodyContainer") + self.body_layout = QVBoxLayout(self.body_container) + self.body_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.body_layout.setContentsMargins(0, 0, 0, 0) + self.body_layout.setSpacing(0) + + self.button_nav_container = QWidget() + self.button_nav_layout = QHBoxLayout(self.button_nav_container) + + self.root_layout.addWidget(self.content_container) + self.content_layout.addWidget(self.title_label) + self.content_layout.addWidget(self.body_container) + self.content_layout.addStretch(1) + self.root_layout.addWidget(self.button_nav_container) + + self.init_connections() + self.update_frame() + + def init_connections(self): + """Initialize button navigation connections.""" + for frame in self._stack: + for button in frame.connect_to_back: + button.clicked.connect(self.back) + for button in frame.connect_to_next: + button.clicked.connect(self.next) + + def back(self): + """Navigate backward in the state stack. Close if out of bounds.""" + if self._index > 0: + self._index = self._index - 1 + self.update_frame() + else: + self.close() + + def next(self): + """Navigate forward in the state stack. Close if out of bounds.""" + if self._index < len(self._stack) - 1: + self._index = self._index + 1 + self.update_frame() + else: + self.close() + + def update_frame(self): + """Update the widgets with the current frame's content.""" + frame: PagedPanelState = self._stack[self._index] + + # Update Title + self.setWindowTitle(frame.title) + self.title_label.setText(f"

{frame.title}

") + + # Update Body Widget + if self.body_layout.itemAt(0): + self.body_layout.itemAt(0).widget().setHidden(True) + self.body_layout.removeWidget(self.body_layout.itemAt(0).widget()) + self.body_layout.addWidget(frame.body_wrapper) + self.body_layout.itemAt(0).widget().setHidden(False) + + # Update Button Widgets + while self.button_nav_layout.count(): + if _ := self.button_nav_layout.takeAt(0).widget(): + _.setHidden(True) + + for item in frame.buttons: + if isinstance(item, QWidget): + self.button_nav_layout.addWidget(item) + item.setHidden(False) + elif isinstance(item, int): + self.button_nav_layout.addStretch(item) diff --git a/tagstudio/src/qt/widgets/paged_panel/paged_panel_state.py b/tagstudio/src/qt/widgets/paged_panel/paged_panel_state.py new file mode 100644 index 00000000..849dcbc7 --- /dev/null +++ b/tagstudio/src/qt/widgets/paged_panel/paged_panel_state.py @@ -0,0 +1,25 @@ +# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + + +from PySide6.QtWidgets import QPushButton +from src.qt.widgets.paged_panel.paged_body_wrapper import PagedBodyWrapper + + +class PagedPanelState: + """A state object for paged panels.""" + + def __init__( + self, + title: str, + body_wrapper: PagedBodyWrapper, + buttons: list[QPushButton | int], + connect_to_back=list[QPushButton], + connect_to_next=list[QPushButton], + ): + self.title: str = title + self.body_wrapper: PagedBodyWrapper = body_wrapper + self.buttons: list[QPushButton | int] = buttons + self.connect_to_back: list[QPushButton] = connect_to_back + self.connect_to_next: list[QPushButton] = connect_to_next diff --git a/tagstudio/src/qt/widgets/panel.py b/tagstudio/src/qt/widgets/panel.py old mode 100644 new mode 100755 index 8c403a66..b164acc9 --- a/tagstudio/src/qt/widgets/panel.py +++ b/tagstudio/src/qt/widgets/panel.py @@ -54,6 +54,7 @@ class PanelModal(QWidget): self.done_button.clicked.connect(self.hide) if done_callback: self.done_button.clicked.connect(done_callback) + self.widget.panel_done_button = self.done_button self.button_layout.addWidget(self.done_button) if save_callback or has_save: @@ -62,6 +63,7 @@ class PanelModal(QWidget): self.cancel_button.clicked.connect(self.hide) self.cancel_button.clicked.connect(widget.reset) # self.cancel_button.clicked.connect(cancel_callback) + self.widget.panel_cancel_button = self.cancel_button self.button_layout.addWidget(self.cancel_button) self.save_button = QPushButton() @@ -69,6 +71,7 @@ class PanelModal(QWidget): self.save_button.setAutoDefault(True) self.save_button.clicked.connect(self.hide) self.save_button.clicked.connect(self.saved.emit) + self.widget.panel_save_button = self.save_button if done_callback: self.save_button.clicked.connect(done_callback) @@ -88,11 +91,18 @@ class PanelModal(QWidget): self.root_layout.setStretch(1, 2) self.root_layout.addWidget(self.button_container) + def closeEvent(self, event): # noqa: N802 + self.done_button.click() + event.accept() + class PanelWidget(QWidget): """Used for widgets that go in a modal panel, ex. for editing or searching.""" done = Signal() + panel_save_button: QPushButton | None = None + panel_cancel_button: QPushButton | None = None + panel_done_button: QPushButton | None = None def __init__(self): super().__init__() diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index f595644f..7e8e0c8b 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -1,6 +1,10 @@ # Copyright (C) 2024 Travis Abendshien (CyanVoxel). # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +import io +import os +import platform import sys import time import typing @@ -12,10 +16,10 @@ import cv2 import rawpy import structlog from humanfriendly import format_size -from PIL import Image, UnidentifiedImageError +from PIL import Image, ImageFont, UnidentifiedImageError from PIL.Image import DecompressionBombError -from PySide6.QtCore import QSize, Qt, Signal -from PySide6.QtGui import QAction, QResizeEvent +from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt, Signal +from PySide6.QtGui import QAction, QGuiApplication, QMovie, QResizeEvent from PySide6.QtWidgets import ( QFrame, QHBoxLayout, @@ -28,9 +32,10 @@ from PySide6.QtWidgets import ( QVBoxLayout, QWidget, ) -from src.core.constants import IMAGE_TYPES, RAW_IMAGE_TYPES, TS_FOLDER_NAME, VIDEO_TYPES +from src.core.constants import ( + TS_FOLDER_NAME, +) from src.core.enums import SettingItems, Theme -from src.core.library.alchemy.enums import FilterState from src.core.library.alchemy.fields import ( BaseField, DatetimeField, @@ -40,10 +45,15 @@ from src.core.library.alchemy.fields import ( _FieldID, ) from src.core.library.alchemy.library import Library +from src.core.media_types import MediaCategories from src.qt.helpers.file_opener import FileOpenerHelper, FileOpenerLabel, open_file +from src.qt.helpers.file_tester import is_readable_video from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper +from src.qt.helpers.rounded_pixmap_style import RoundedPixmapStyle from src.qt.modals.add_field import AddFieldModal +from src.qt.platform_strings import PlatformStrings from src.qt.widgets.fields import FieldContainer +from src.qt.widgets.media_player import MediaPlayer from src.qt.widgets.panel import PanelModal from src.qt.widgets.tag_box import TagBoxWidget from src.qt.widgets.text import TextWidget @@ -58,16 +68,6 @@ if typing.TYPE_CHECKING: logger = structlog.get_logger(__name__) -def update_selected_entry(driver: "QtDriver"): - for grid_idx in driver.selected: - entry = driver.frame_content[grid_idx] - # reload entry - results = driver.lib.search_library(FilterState(id=entry.id)) - logger.info("found item", entries=len(results), grid_idx=grid_idx, lookup_id=entry.id) - assert results, f"Entry not found: {entry.id}" - driver.frame_content[grid_idx] = next(results) - - class PreviewPanel(QWidget): """The Preview Panel Widget.""" @@ -80,8 +80,6 @@ class PreviewPanel(QWidget): self.driver: QtDriver = driver self.initialized = False self.is_open: bool = False - # self.filepath = None - # self.item = None # DEPRECATED, USE self.selected self.common_fields: list = [] self.mixed_fields: list = [] self.selected: list[int] = [] # New way of tracking items @@ -91,20 +89,55 @@ class PreviewPanel(QWidget): self.img_button_size: tuple[int, int] = (266, 266) self.image_ratio: float = 1.0 + self.label_bg_color = ( + Theme.COLOR_BG_DARK.value + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else Theme.COLOR_DARK_LABEL.value + ) + self.panel_bg_color = ( + Theme.COLOR_BG_DARK.value + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else Theme.COLOR_BG_LIGHT.value + ) + self.image_container = QWidget() image_layout = QHBoxLayout(self.image_container) image_layout.setContentsMargins(0, 0, 0, 0) + file_label_style = "font-size: 12px" + properties_style = ( + f"background-color:{self.label_bg_color};" + "color:#FFFFFF;" + "font-family:Oxanium;" + "font-weight:bold;" + "font-size:12px;" + "border-radius:3px;" + "padding-top: 4px;" + "padding-right: 1px;" + "padding-bottom: 1px;" + "padding-left: 1px;" + ) + date_style = "font-size:12px;" + self.open_file_action = QAction("Open file", self) - self.open_explorer_action = QAction("Open file in explorer", self) + self.open_explorer_action = QAction(PlatformStrings.open_file_str, self) self.preview_img = QPushButtonWrapper() self.preview_img.setMinimumSize(*self.img_button_size) self.preview_img.setFlat(True) self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) - self.preview_img.addAction(self.open_file_action) self.preview_img.addAction(self.open_explorer_action) + + self.preview_gif = QLabel() + self.preview_gif.setMinimumSize(*self.img_button_size) + self.preview_gif.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) + self.preview_gif.setCursor(Qt.CursorShape.ArrowCursor) + self.preview_gif.addAction(self.open_file_action) + self.preview_gif.addAction(self.open_explorer_action) + self.preview_gif.hide() + self.gif_buffer: QBuffer = QBuffer() + self.preview_vid = VideoPlayer(driver) self.preview_vid.hide() self.thumb_renderer = ThumbRenderer() @@ -122,33 +155,38 @@ class PreviewPanel(QWidget): ) ) + self.media_player = MediaPlayer(driver) + self.media_player.hide() + image_layout.addWidget(self.preview_img) image_layout.setAlignment(self.preview_img, Qt.AlignmentFlag.AlignCenter) + image_layout.addWidget(self.preview_gif) + image_layout.setAlignment(self.preview_gif, Qt.AlignmentFlag.AlignCenter) image_layout.addWidget(self.preview_vid) image_layout.setAlignment(self.preview_vid, Qt.AlignmentFlag.AlignCenter) self.image_container.setMinimumSize(*self.img_button_size) - self.file_label = FileOpenerLabel("Filename") + self.file_label = FileOpenerLabel() + self.file_label.setObjectName("filenameLabel") + self.file_label.setTextFormat(Qt.TextFormat.RichText) self.file_label.setWordWrap(True) self.file_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) - self.file_label.setStyleSheet("font-weight: bold; font-size: 12px") + self.file_label.setStyleSheet(file_label_style) - self.dimensions_label = QLabel("Dimensions") + self.date_created_label = QLabel() + self.date_created_label.setObjectName("dateCreatedLabel") + self.date_created_label.setAlignment(Qt.AlignmentFlag.AlignLeft) + self.date_created_label.setTextFormat(Qt.TextFormat.RichText) + self.date_created_label.setStyleSheet(date_style) + + self.date_modified_label = QLabel() + self.date_modified_label.setObjectName("dateModifiedLabel") + self.date_modified_label.setAlignment(Qt.AlignmentFlag.AlignLeft) + self.date_modified_label.setTextFormat(Qt.TextFormat.RichText) + self.date_modified_label.setStyleSheet(date_style) + + self.dimensions_label = QLabel() + self.dimensions_label.setObjectName("dimensionsLabel") self.dimensions_label.setWordWrap(True) - # self.dim_label.setTextInteractionFlags( - # Qt.TextInteractionFlag.TextSelectableByMouse) - - properties_style = ( - f"background-color:{Theme.COLOR_BG.value};" - f"font-family:Oxanium;" - f"font-weight:bold;" - f"font-size:12px;" - f"border-radius:6px;" - f"padding-top: 4px;" - f"padding-right: 1px;" - f"padding-bottom: 1px;" - f"padding-left: 1px;" - ) - self.dimensions_label.setStyleSheet(properties_style) self.scroll_layout = QVBoxLayout() @@ -175,15 +213,24 @@ class PreviewPanel(QWidget): # background and NOT the scroll container background, so that the # rounded corners are maintained when scrolling. I was unable to # find the right trick to only select that particular element. + scroll_area.setStyleSheet( "QWidget#entryScrollContainer{" - f"background: {Theme.COLOR_BG.value};" + f"background:{self.panel_bg_color};" "border-radius:6px;" "}" ) scroll_area.setWidget(scroll_container) + date_container = QWidget() + date_layout = QVBoxLayout(date_container) + date_layout.setContentsMargins(0, 2, 0, 0) + date_layout.setSpacing(0) + date_layout.addWidget(self.date_created_label) + date_layout.addWidget(self.date_modified_label) + info_layout.addWidget(self.file_label) + info_layout.addWidget(date_container) info_layout.addWidget(self.dimensions_label) info_layout.addWidget(scroll_area) @@ -219,6 +266,7 @@ class PreviewPanel(QWidget): ) splitter.addWidget(self.image_container) + splitter.addWidget(self.media_player) splitter.addWidget(info_section) splitter.addWidget(self.libs_flow_container) splitter.setStretchFactor(1, 2) @@ -243,6 +291,17 @@ class PreviewPanel(QWidget): root_layout.setContentsMargins(0, 0, 0, 0) root_layout.addWidget(splitter) + def update_selected_entry(self, driver: "QtDriver"): + for grid_idx in driver.selected: + entry = driver.frame_content[grid_idx] + result = self.lib.get_entry_full(entry.id) + logger.info( + "found item", + grid_idx=grid_idx, + lookup_id=entry.id, + ) + self.driver.frame_content[grid_idx] = result + def remove_field_prompt(self, name: str) -> str: return f'Are you sure you want to remove field "{name}"?' @@ -326,11 +385,11 @@ class PreviewPanel(QWidget): return lambda: self.driver.open_library(Path(path)) button.clicked.connect(open_library_button_clicked(full_val)) - set_button_style(button) - button_remove = QPushButton("➖") + set_button_style(button, ["padding-left: 6px;", "text-align: left;"]) + button_remove = QPushButton("—") button_remove.setCursor(Qt.CursorShape.PointingHandCursor) - button_remove.setFixedWidth(30) - set_button_style(button_remove) + button_remove.setFixedWidth(24) + set_button_style(button_remove, ["font-weight:bold;", "text-align:center;"]) def remove_recent_library_clicked(key: str): return lambda: ( @@ -364,19 +423,14 @@ class PreviewPanel(QWidget): def update_image_size(self, size: tuple[int, int], ratio: float = None): if ratio: self.set_image_ratio(ratio) - # self.img_button_size = size - # logging.info(f'') - # self.preview_img.setMinimumSize(64,64) adj_width: float = size[0] adj_height: float = size[1] # Landscape if self.image_ratio > 1: - # logging.info('Landscape') adj_height = size[0] * (1 / self.image_ratio) # Portrait elif self.image_ratio <= 1: - # logging.info('Portrait') adj_width = size[1] * self.image_ratio if adj_width > size[0]: @@ -386,11 +440,6 @@ class PreviewPanel(QWidget): adj_width = adj_width * (size[1] / adj_height) adj_height = size[1] - # adj_width = min(adj_width, self.image_container.size().width()) - # adj_height = min(adj_width, self.image_container.size().height()) - - # self.preview_img.setMinimumSize(s) - # self.preview_img.setMaximumSize(s_max) adj_size = QSize(int(adj_width), int(adj_height)) self.img_button_size = (int(adj_width), int(adj_height)) self.preview_img.setMaximumSize(adj_size) @@ -398,7 +447,14 @@ class PreviewPanel(QWidget): self.preview_vid.resize_video(adj_size) self.preview_vid.setMaximumSize(adj_size) self.preview_vid.setMinimumSize(adj_size) - # self.preview_img.setMinimumSize(adj_size) + self.preview_gif.setMaximumSize(adj_size) + self.preview_gif.setMinimumSize(adj_size) + proxy_style = RoundedPixmapStyle(radius=8) + self.preview_gif.setStyle(proxy_style) + self.preview_vid.setStyle(proxy_style) + m = self.preview_gif.movie() + if m: + m.setScaledSize(adj_size) def place_add_field_button(self): self.scroll_layout.addWidget(self.afb_container) @@ -410,11 +466,7 @@ class PreviewPanel(QWidget): self.add_field_button.clicked.disconnect() self.add_field_modal.done.connect( - lambda items: ( - self.add_field_to_selected(items), - update_selected_entry(self.driver), - self.update_widgets(), - ) + lambda f: (self.add_field_to_selected(f), self.update_widgets()) ) self.add_field_modal.is_connected = True self.add_field_button.clicked.connect(self.add_field_modal.show) @@ -430,6 +482,32 @@ class PreviewPanel(QWidget): field_id=field_item.data(Qt.ItemDataRole.UserRole), ) + def update_date_label(self, filepath: Path | None = None) -> None: + """Update the "Date Created" and "Date Modified" file property labels.""" + if filepath and filepath.is_file(): + created: dt = None + if platform.system() == "Windows" or platform.system() == "Darwin": + created = dt.fromtimestamp(filepath.stat().st_birthtime) # type: ignore[attr-defined, unused-ignore] + else: + created = dt.fromtimestamp(filepath.stat().st_ctime) + modified: dt = dt.fromtimestamp(filepath.stat().st_mtime) + self.date_created_label.setText( + f"Date Created: {dt.strftime(created, "%a, %x, %X")}" + ) + self.date_modified_label.setText( + f"Date Modified: {dt.strftime(modified, "%a, %x, %X")}" + ) + self.date_created_label.setHidden(False) + self.date_modified_label.setHidden(False) + elif filepath: + self.date_created_label.setText("Date Created: N/A") + self.date_modified_label.setText("Date Modified: N/A") + self.date_created_label.setHidden(False) + self.date_modified_label.setHidden(False) + else: + self.date_created_label.setHidden(True) + self.date_modified_label.setHidden(True) + def update_widgets(self) -> bool: """Render the panel widgets with the newest data from the Library.""" logger.info("update_widgets", selected=self.driver.selected) @@ -442,11 +520,12 @@ class PreviewPanel(QWidget): if not self.driver.selected: if self.selected or not self.initialized: - self.file_label.setText("No Items Selected") + self.file_label.setText("No Items Selected") self.file_label.set_file_path("") self.file_label.setCursor(Qt.CursorShape.ArrowCursor) self.dimensions_label.setText("") + self.update_date_label() self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu) self.preview_img.setCursor(Qt.CursorShape.ArrowCursor) @@ -466,6 +545,9 @@ class PreviewPanel(QWidget): self.preview_img.show() self.preview_vid.stop() self.preview_vid.hide() + self.media_player.hide() + self.media_player.stop() + self.preview_gif.hide() self.selected = list(self.driver.selected) self.add_field_button.setHidden(True) @@ -480,14 +562,13 @@ class PreviewPanel(QWidget): # TODO - Entry reload is maybe not necessary for grid_idx in self.driver.selected: entry = self.driver.frame_content[grid_idx] - results = self.lib.search_library(FilterState(id=entry.id)) + result = self.lib.get_entry_full(entry.id) logger.info( "found item", - entries=len(results.items), grid_idx=grid_idx, lookup_id=entry.id, ) - self.driver.frame_content[grid_idx] = results[0] + self.driver.frame_content[grid_idx] = result if len(self.driver.selected) == 1: # 1 Selected Entry @@ -497,6 +578,9 @@ class PreviewPanel(QWidget): self.preview_img.show() self.preview_vid.stop() self.preview_vid.hide() + self.media_player.stop() + self.media_player.hide() + self.preview_gif.hide() # If a new selection is made, update the thumbnail and filepath. if not self.selected or self.selected != self.driver.selected: @@ -510,7 +594,15 @@ class PreviewPanel(QWidget): ratio, update_on_ratio_change=True, ) - self.file_label.setText("\u200b".join(str(filepath))) + file_str: str = "" + separator: str = f"{os.path.sep}" # Gray + for i, part in enumerate(filepath.parts): + part_ = part.strip(os.path.sep) + if i != len(filepath.parts) - 1: + file_str += f"{"\u200b".join(part_)}{separator}" + else: + file_str += f"
{"\u200b".join(part_)}" + self.file_label.setText(file_str) self.file_label.setCursor(Qt.CursorShape.PointingHandCursor) self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) @@ -520,12 +612,49 @@ class PreviewPanel(QWidget): self.open_file_action.triggered.connect(self.opener.open_file) self.open_explorer_action.triggered.connect(self.opener.open_explorer) - # TODO: Do this somewhere else, this is just here temporarily. + # TODO: Do this all somewhere else, this is just here temporarily. + ext: str = filepath.suffix.lower() try: + if MediaCategories.is_ext_in_category( + ext, MediaCategories.IMAGE_ANIMATED_TYPES, mime_fallback=True + ): + if self.preview_gif.movie(): + self.preview_gif.movie().stop() + self.gif_buffer.close() + + image: Image.Image = Image.open(filepath) + anim_image: Image.Image = image + image_bytes_io: io.BytesIO = io.BytesIO() + anim_image.save( + image_bytes_io, + "GIF", + lossless=True, + save_all=True, + loop=0, + disposal=2, + ) + image_bytes_io.seek(0) + ba: bytes = image_bytes_io.read() + + self.gif_buffer.setData(ba) + movie = QMovie(self.gif_buffer, QByteArray()) + self.preview_gif.setMovie(movie) + movie.start() + + self.resizeEvent( + QResizeEvent( + QSize(image.width, image.height), + QSize(image.width, image.height), + ) + ) + self.preview_img.hide() + self.preview_vid.hide() + self.preview_gif.show() + image = None - if filepath.suffix.lower() in IMAGE_TYPES: + if MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_RASTER_TYPES): image = Image.open(str(filepath)) - elif filepath.suffix.lower() in RAW_IMAGE_TYPES: + elif MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_RAW_TYPES): try: with rawpy.imread(str(filepath)) as raw: rgb = raw.postprocess() @@ -535,11 +664,17 @@ class PreviewPanel(QWidget): rawpy._rawpy.LibRawFileUnsupportedError, ): pass - elif filepath.suffix.lower() in VIDEO_TYPES: - video = cv2.VideoCapture(str(filepath)) - if video.get(cv2.CAP_PROP_FRAME_COUNT) <= 0: - raise cv2.error("File is invalid or has 0 frames") - video.set(cv2.CAP_PROP_POS_FRAMES, 0) + elif MediaCategories.is_ext_in_category(ext, MediaCategories.AUDIO_TYPES): + self.media_player.show() + self.media_player.play(filepath) + elif MediaCategories.is_ext_in_category( + ext, MediaCategories.VIDEO_TYPES + ) and is_readable_video(filepath): + video = cv2.VideoCapture(str(filepath), cv2.CAP_FFMPEG) + video.set( + cv2.CAP_PROP_POS_FRAMES, + (video.get(cv2.CAP_PROP_FRAME_COUNT) // 2), + ) success, frame = video.read() frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) image = Image.fromarray(frame) @@ -555,35 +690,60 @@ class PreviewPanel(QWidget): self.preview_vid.show() # Stats for specific file types are displayed here. - if image and filepath.suffix.lower() in ( - IMAGE_TYPES + VIDEO_TYPES + RAW_IMAGE_TYPES + if image and ( + MediaCategories.is_ext_in_category( + ext, MediaCategories.IMAGE_RASTER_TYPES, mime_fallback=True + ) + or MediaCategories.is_ext_in_category( + ext, MediaCategories.VIDEO_TYPES, mime_fallback=True + ) + or MediaCategories.is_ext_in_category( + ext, MediaCategories.IMAGE_RAW_TYPES, mime_fallback=True + ) ): self.dimensions_label.setText( - f"{filepath.suffix.upper()[1:]}" - f" • {format_size(filepath.stat().st_size)}\n{image.width} " - f"x {image.height} px" + f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}\n" + f"{image.width} x {image.height} px" ) + elif MediaCategories.is_ext_in_category( + ext, MediaCategories.FONT_TYPES, mime_fallback=True + ): + try: + font = ImageFont.truetype(filepath) + self.dimensions_label.setText( + f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}\n" + f"{font.getname()[0]} ({font.getname()[1]}) " + ) + except OSError: + self.dimensions_label.setText( + f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}" + ) + logger.info( + f"[PreviewPanel][ERROR] Couldn't read font file: {filepath}" + ) else: + self.dimensions_label.setText(f"{ext.upper()[1:]}") self.dimensions_label.setText( - f"{filepath.suffix.upper()[1:]}" - f" • {format_size(filepath.stat().st_size)}" + f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}" ) + self.update_date_label(filepath) if not filepath.is_file(): raise FileNotFoundError except (FileNotFoundError, cv2.error) as e: - self.dimensions_label.setText(f"{filepath.suffix.upper()}") - logger.error("Couldn't Render thumbnail", filepath=filepath, error=e) - + self.dimensions_label.setText(f"{ext.upper()[1:]}") + logger.error("Couldn't render thumbnail", filepath=filepath, error=e) + self.update_date_label() except ( UnidentifiedImageError, DecompressionBombError, ) as e: self.dimensions_label.setText( - f"{filepath.suffix.upper()[1:]} • {format_size(filepath.stat().st_size)}" + f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}" ) - logger.error("Couldn't Render thumbnail", filepath=filepath, error=e) + logger.error("Couldn't render thumbnail", filepath=filepath, error=e) + self.update_date_label(filepath) if self.preview_img.is_connected: self.preview_img.clicked.disconnect() @@ -610,10 +770,14 @@ class PreviewPanel(QWidget): # Multiple Selected Items elif len(self.driver.selected) > 1: self.preview_img.show() + self.preview_gif.hide() self.preview_vid.stop() self.preview_vid.hide() + self.media_player.stop() + self.media_player.hide() + self.update_date_label() if self.selected != self.driver.selected: - self.file_label.setText(f"{len(self.driver.selected)} Items Selected") + self.file_label.setText(f"{len(self.driver.selected)} Items Selected") self.file_label.setCursor(Qt.CursorShape.ArrowCursor) self.file_label.set_file_path("") self.dimensions_label.setText("") @@ -708,10 +872,6 @@ class PreviewPanel(QWidget): else: container = self.containers[index] - container.set_copy_callback(None) - container.set_edit_callback(None) - container.set_remove_callback(None) - if isinstance(field, TagBoxField): container.set_title(field.type.name) container.set_inline(False) @@ -729,10 +889,6 @@ class PreviewPanel(QWidget): logger.error("Failed to disconnect inner_container.updated") else: - logger.info( - "inner_container is not instance of TagBoxWidget", - container=inner_container, - ) inner_container = TagBoxWidget( field, title, @@ -753,7 +909,7 @@ class PreviewPanel(QWidget): prompt=self.remove_field_prompt(field.type.name), callback=lambda: ( self.remove_field(field), - update_selected_entry(self.driver), + self.update_selected_entry(self.driver), # reload entry and its fields self.update_widgets(), ), diff --git a/tagstudio/src/qt/widgets/progress.py b/tagstudio/src/qt/widgets/progress.py index 8adfb094..f9247ebd 100644 --- a/tagstudio/src/qt/widgets/progress.py +++ b/tagstudio/src/qt/widgets/progress.py @@ -2,11 +2,12 @@ # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio +from typing import Callable, Optional -from typing import Optional - -from PySide6.QtCore import Qt +from PySide6.QtCore import Qt, QThreadPool from PySide6.QtWidgets import QProgressDialog, QVBoxLayout, QWidget +from src.qt.helpers.custom_runnable import CustomRunnable +from src.qt.helpers.function_iterator import FunctionIterator class ProgressWidget(QWidget): @@ -39,3 +40,26 @@ class ProgressWidget(QWidget): def update_progress(self, value: int): self.pb.setValue(value) + + def _update_progress_unknown_iterable(self, value): + if hasattr(value, "__getitem__"): + self.update_progress(value[0] + 1) + else: + self.update_progress(value + 1) + + def from_iterable_function( + self, function: Callable, update_label_callback: Callable | None, *done_callbacks + ): + """Display the progress widget from a threaded iterable function.""" + iterator = FunctionIterator(function) + iterator.value.connect(lambda x: self._update_progress_unknown_iterable(x)) + if update_label_callback: + iterator.value.connect(lambda x: self.update_label(update_label_callback(x))) + + self.show() + + r = CustomRunnable(lambda: iterator.run()) + r.done.connect( + lambda: (self.hide(), self.deleteLater(), [callback() for callback in done_callbacks]) + ) + QThreadPool.globalInstance().start(r) diff --git a/tagstudio/src/qt/widgets/tag.py b/tagstudio/src/qt/widgets/tag.py index ae26b342..2d4cc7ce 100644 --- a/tagstudio/src/qt/widgets/tag.py +++ b/tagstudio/src/qt/widgets/tag.py @@ -9,12 +9,88 @@ from types import FunctionType from PIL import Image from PySide6.QtCore import QEvent, Qt, Signal -from PySide6.QtGui import QAction, QEnterEvent -from PySide6.QtWidgets import QHBoxLayout, QPushButton, QVBoxLayout, QWidget +from PySide6.QtGui import QAction, QEnterEvent, QFontMetrics +from PySide6.QtWidgets import ( + QHBoxLayout, + QLineEdit, + QPushButton, + QVBoxLayout, + QWidget, +) from src.core.library import Tag +from src.core.library.alchemy.enums import TagColor from src.core.palette import ColorType, get_tag_color +class TagAliasWidget(QWidget): + on_remove = Signal() + + def __init__( + self, + id: int | None = 0, + alias: str | None = None, + on_remove_callback=None, + ) -> None: + super().__init__() + + self.id = id + + # if on_click_callback: + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.base_layout = QHBoxLayout(self) + self.base_layout.setObjectName("baseLayout") + self.base_layout.setContentsMargins(0, 0, 0, 0) + + self.on_remove.connect(on_remove_callback) + + self.text_field = QLineEdit(self) + self.text_field.textChanged.connect(self._adjust_width) + + if alias is not None: + self.text_field.setText(alias) + else: + self.text_field.setText("") + + self._adjust_width() + + self.remove_button = QPushButton(self) + self.remove_button.setFlat(True) + self.remove_button.setText("–") + self.remove_button.setHidden(False) + self.remove_button.setStyleSheet( + f"color: {get_tag_color(ColorType.PRIMARY, TagColor.DEFAULT)};" + f"background: {get_tag_color(ColorType.TEXT, TagColor.DEFAULT)};" + f"font-weight: 800;" + f"border-radius: 4px;" + f"border-width:0;" + f"padding-bottom: 4px;" + f"font-size: 14px" + ) + self.remove_button.setMinimumSize(19, 19) + self.remove_button.setMaximumSize(19, 19) + self.remove_button.clicked.connect(self.on_remove.emit) + + self.base_layout.addWidget(self.remove_button) + self.base_layout.addWidget(self.text_field) + + def _adjust_width(self): + text = self.text_field.text() or self.text_field.placeholderText() + font_metrics = QFontMetrics(self.text_field.font()) + text_width = font_metrics.horizontalAdvance(text) + 10 # Add padding + + # Set the minimum width of the QLineEdit + self.text_field.setMinimumWidth(text_width) + self.text_field.adjustSize() + + def enterEvent(self, event: QEnterEvent) -> None: # noqa: N802 + self.update() + return super().enterEvent(event) + + def leaveEvent(self, event: QEvent) -> None: # noqa: N802 + self.update() + return super().leaveEvent(event) + + class TagWidget(QWidget): edit_icon_128: Image.Image = Image.open( str(Path(__file__).parents[3] / "resources/qt/images/edit_icon_128.png") diff --git a/tagstudio/src/qt/widgets/tag_box.py b/tagstudio/src/qt/widgets/tag_box.py old mode 100644 new mode 100755 index c24a3519..a26ec33a --- a/tagstudio/src/qt/widgets/tag_box.py +++ b/tagstudio/src/qt/widgets/tag_box.py @@ -89,17 +89,18 @@ class TagBoxWidget(FieldWidget): self.field = field def set_tags(self, tags: typing.Iterable[Tag]): + tags_ = sorted(list(tags), key=lambda tag: tag.name) is_recycled = False while self.base_layout.itemAt(0) and self.base_layout.itemAt(1): self.base_layout.takeAt(0).widget().deleteLater() is_recycled = True - for tag in tags: + for tag in tags_: tag_widget = TagWidget(tag, has_edit=True, has_remove=True) tag_widget.on_click.connect( lambda tag_id=tag.id: ( self.driver.main_window.searchField.setText(f"tag_id:{tag_id}"), - self.driver.filter_items(FilterState(tag_id=tag_id)), + self.driver.filter_items(FilterState.from_tag_id(tag_id)), ) ) @@ -138,7 +139,9 @@ class TagBoxWidget(FieldWidget): self.edit_modal.saved.connect( lambda: self.driver.lib.update_tag( build_tag_panel.build_tag(), - subtag_ids=build_tag_panel.subtags, + subtag_ids=set(build_tag_panel.subtag_ids), + alias_names=set(build_tag_panel.alias_names), + alias_ids=set(build_tag_panel.alias_ids), ) ) self.edit_modal.show() diff --git a/tagstudio/src/qt/widgets/thumb_button.py b/tagstudio/src/qt/widgets/thumb_button.py index e56408b7..cf8dae37 100644 --- a/tagstudio/src/qt/widgets/thumb_button.py +++ b/tagstudio/src/qt/widgets/thumb_button.py @@ -5,19 +5,51 @@ from PySide6 import QtCore from PySide6.QtCore import QEvent -from PySide6.QtGui import QColor, QEnterEvent, QPainter, QPainterPath, QPaintEvent, QPen +from PySide6.QtGui import ( + QColor, + QEnterEvent, + QPainter, + QPainterPath, + QPaintEvent, + QPalette, + QPen, +) from PySide6.QtWidgets import QWidget from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper class ThumbButton(QPushButtonWrapper): - def __init__(self, parent: QWidget, thumb_size: tuple[int, int]) -> None: + def __init__(self, parent: QWidget, thumb_size: tuple[int, int]) -> None: # noqa: N802 super().__init__(parent) self.thumb_size: tuple[int, int] = thumb_size self.hovered = False self.selected = False - # self.clicked.connect(lambda checked: self.set_selected(True)) + self.select_color: QColor = QPalette.color( + self.palette(), + QPalette.ColorGroup.Active, + QPalette.ColorRole.Accent, + ) + + self.select_color_faded: QColor = QColor(self.select_color) + self.select_color_faded.setHsl( + self.select_color_faded.hslHue(), + self.select_color_faded.hslSaturation(), + max(self.select_color_faded.lightness(), 127), + 127, + ) + + self.hover_color: QColor = QPalette.color( + self.palette(), + QPalette.ColorGroup.Active, + QPalette.ColorRole.Accent, + ) + self.hover_color.setHsl( + self.hover_color.hslHue(), + self.hover_color.hslSaturation(), + min(self.hover_color.lightness() + 80, 255), + self.hover_color.alpha(), + ) def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 super().paintEvent(event) @@ -25,7 +57,6 @@ class ThumbButton(QPushButtonWrapper): painter = QPainter() painter.begin(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) - # painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Source) path = QPainterPath() width = 3 radius = 6 @@ -40,32 +71,24 @@ class ThumbButton(QPushButtonWrapper): radius, ) - # color = QColor('#bb4ff0') if self.selected else QColor('#55bbf6') - # pen = QPen(color, width) - # painter.setPen(pen) - # # brush.setColor(fill) - # painter.drawPath(path) - if self.selected: painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_HardLight) - color = QColor("#bb4ff0") - color.setAlphaF(0.5) - pen = QPen(color, width) + pen = QPen(self.select_color_faded, width) painter.setPen(pen) - painter.fillPath(path, color) + painter.fillPath(path, self.select_color_faded) painter.drawPath(path) painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Source) - color = QColor("#bb4ff0") if not self.hovered else QColor("#55bbf6") + color: QColor = self.select_color if not self.hovered else self.hover_color pen = QPen(color, width) painter.setPen(pen) painter.drawPath(path) elif self.hovered: painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Source) - color = QColor("#55bbf6") - pen = QPen(color, width) + pen = QPen(self.hover_color, width) painter.setPen(pen) painter.drawPath(path) + painter.end() def enterEvent(self, event: QEnterEvent) -> None: # noqa: N802 @@ -78,6 +101,6 @@ class ThumbButton(QPushButtonWrapper): self.repaint() return super().leaveEvent(event) - def set_selected(self, value: bool) -> None: + def set_selected(self, value: bool) -> None: # noqa: N802 self.selected = value self.repaint() diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index ff9a99b0..45550a84 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -4,14 +4,22 @@ import math +import struct +import zipfile +from copy import deepcopy +from io import BytesIO from pathlib import Path import cv2 +import numpy as np import rawpy import structlog +from mutagen import MutagenError, flac, id3, mp4 from PIL import ( Image, + ImageChops, ImageDraw, + ImageEnhance, ImageFile, ImageFont, ImageOps, @@ -20,68 +28,972 @@ from PIL import ( ) from PIL.Image import DecompressionBombError from pillow_heif import register_avif_opener, register_heif_opener -from PySide6.QtCore import QObject, QSize, Signal -from PySide6.QtGui import QPixmap -from src.core.constants import ( - IMAGE_TYPES, - PLAINTEXT_TYPES, - RAW_IMAGE_TYPES, - VIDEO_TYPES, +from pydub import exceptions +from PySide6.QtCore import ( + QBuffer, + QFile, + QFileDevice, + QIODeviceBase, + QObject, + QSize, + QSizeF, + Qt, + Signal, ) +from PySide6.QtGui import QGuiApplication, QImage, QPainter, QPixmap +from PySide6.QtPdf import QPdfDocument, QPdfDocumentRenderOptions +from PySide6.QtSvg import QSvgRenderer +from src.core.constants import FONT_SAMPLE_SIZES, FONT_SAMPLE_TEXT +from src.core.exceptions import NoRendererError +from src.core.media_types import MediaCategories, MediaType +from src.core.palette import ColorType, UiColor, get_ui_color from src.core.utils.encoding import detect_char_encoding -from src.qt.helpers.gradient import four_corner_gradient_background +from src.qt.helpers.blender_thumbnailer import blend_thumb +from src.qt.helpers.color_overlay import theme_fg_overlay +from src.qt.helpers.file_tester import is_readable_video +from src.qt.helpers.gradient import four_corner_gradient +from src.qt.helpers.image_effects import replace_transparent_pixels +from src.qt.helpers.text_wrapper import wrap_full_text +from src.qt.helpers.vendored.pydub.audio_segment import ( # type: ignore + _AudioSegment as AudioSegment, +) +from src.qt.resource_manager import ResourceManager +from vtf2img import Parser ImageFile.LOAD_TRUNCATED_IMAGES = True - logger = structlog.get_logger(__name__) - register_heif_opener() register_avif_opener() class ThumbRenderer(QObject): - # finished = Signal() - updated = Signal(float, QPixmap, QSize, str) + """A class for rendering image and file thumbnails.""" + + rm: ResourceManager = ResourceManager() + updated = Signal(float, QPixmap, QSize, str, str) updated_ratio = Signal(float) - # updatedImage = Signal(QPixmap) - # updatedSize = Signal(QSize) - thumb_mask_512: Image.Image = Image.open( - Path(__file__).parents[3] / "resources/qt/images/thumb_mask_512.png" - ) - thumb_mask_512.load() + def __init__(self) -> None: + """Initialize the class.""" + super().__init__() - thumb_mask_hl_512: Image.Image = Image.open( - Path(__file__).parents[3] / "resources/qt/images/thumb_mask_hl_512.png" - ) - thumb_mask_hl_512.load() + # Cached thumbnail elements. + # Key: Size + Pixel Ratio Tuple + Radius Scale + # (Ex. (512, 512, 1.25, 4)) + self.thumb_masks: dict = {} + self.raised_edges: dict = {} - thumb_loading_512: Image.Image = Image.open( - Path(__file__).parents[3] / "resources/qt/images/thumb_loading_512.png" - ) - thumb_loading_512.load() + # Key: ("name", UiColor, 512, 512, 1.25) + self.icons: dict = {} - thumb_broken_512: Image.Image = Image.open( - Path(__file__).parents[3] / "resources/qt/images/thumb_broken_512.png" - ) - thumb_broken_512.load() + def _get_resource_id(self, url: Path) -> str: + """Return the name of the icon resource to use for a file type. - thumb_file_default_512: Image.Image = Image.open( - Path(__file__).parents[3] / "resources/qt/images/thumb_file_default_512.png" - ) - thumb_file_default_512.load() + Special terms will return special resources. - # thumb_debug: Image.Image = Image.open(Path( - # f'{Path(__file__).parents[2]}/resources/qt/images/temp.jpg')) - # thumb_debug.load() + Args: + url (Path): The file url to assess. "$LOADING" will return the loading graphic. + """ + ext = url.suffix.lower() + types: set[MediaType] = MediaCategories.get_types(ext, mime_fallback=True) - # TODO: Make dynamic font sized given different pixel ratios - font_pixel_ratio: float = 1 - ext_font = ImageFont.truetype( - Path(__file__).parents[3] / "resources/qt/fonts/Oxanium-Bold.ttf", - math.floor(12 * font_pixel_ratio), - ) + # Loop though the specific (non-IANA) categories and return the string + # name of the first matching category found. + for cat in MediaCategories.ALL_CATEGORIES: + if not cat.is_iana and cat.media_type in types: + return cat.media_type.value + + # If the type is broader (IANA registered) then search those types. + for cat in MediaCategories.ALL_CATEGORIES: + if cat.is_iana and cat.media_type in types: + return cat.media_type.value + + return "file_generic" + + def _get_mask( + self, size: tuple[int, int], pixel_ratio: float, scale_radius: bool = False + ) -> Image.Image: + """Return a thumbnail mask given a size, pixel ratio, and radius scaling option. + + If one is not already cached, a new one will be rendered. + + Args: + size (tuple[int, int]): The size of the graphic. + pixel_ratio (float): The screen pixel ratio. + scale_radius (bool): Option to scale the radius up (Used for Preview Panel). + """ + thumb_scale: int = 512 + radius_scale: float = 1 + if scale_radius: + radius_scale = max(size[0], size[1]) / thumb_scale + + item: Image.Image = self.thumb_masks.get((*size, pixel_ratio, radius_scale)) + if not item: + item = self._render_mask(size, pixel_ratio, radius_scale) + self.thumb_masks[(*size, pixel_ratio, radius_scale)] = item + return item + + def _get_edge( + self, size: tuple[int, int], pixel_ratio: float + ) -> tuple[Image.Image, Image.Image]: + """Return a thumbnail edge given a size, pixel ratio, and radius scaling option. + + If one is not already cached, a new one will be rendered. + + Args: + size (tuple[int, int]): The size of the graphic. + pixel_ratio (float): The screen pixel ratio. + """ + item: tuple[Image.Image, Image.Image] = self.raised_edges.get((*size, pixel_ratio)) + if not item: + item = self._render_edge(size, pixel_ratio) + self.raised_edges[(*size, pixel_ratio)] = item + return item + + def _get_icon( + self, name: str, color: UiColor, size: tuple[int, int], pixel_ratio: float = 1.0 + ) -> Image.Image: + """Return an icon given a size, pixel ratio, and radius scaling option. + + Args: + name (str): The name of the icon resource. "thumb_loading" will not draw a border. + color (str): The color to use for the icon. + size (tuple[int,int]): The size of the icon. + pixel_ratio (float): The screen pixel ratio. + """ + draw_border: bool = True + if name == "thumb_loading": + draw_border = False + + item: Image.Image = self.icons.get((name, color, *size, pixel_ratio)) + if not item: + item_flat: Image.Image = self._render_icon(name, color, size, pixel_ratio, draw_border) + edge: tuple[Image.Image, Image.Image] = self._get_edge(size, pixel_ratio) + item = self._apply_edge(item_flat, edge, faded=True) + self.icons[(name, color, *size, pixel_ratio)] = item + return item + + def _render_mask( + self, size: tuple[int, int], pixel_ratio: float, radius_scale: float = 1 + ) -> Image.Image: + """Render a thumbnail mask graphic. + + Args: + size (tuple[int,int]): The size of the graphic. + pixel_ratio (float): The screen pixel ratio. + radius_scale (float): The scale factor of the border radius (Used by Preview Panel). + """ + smooth_factor: int = 2 + radius_factor: int = 8 + + im: Image.Image = Image.new( + mode="L", + size=tuple([d * smooth_factor for d in size]), # type: ignore + color="black", + ) + draw = ImageDraw.Draw(im) + draw.rounded_rectangle( + (0, 0) + tuple([d - 1 for d in im.size]), + radius=math.ceil(radius_factor * smooth_factor * pixel_ratio * radius_scale), + fill="white", + ) + im = im.resize( + size, + resample=Image.Resampling.BILINEAR, + ) + return im + + def _render_edge( + self, size: tuple[int, int], pixel_ratio: float + ) -> tuple[Image.Image, Image.Image]: + """Render a thumbnail edge graphic. + + Args: + size (tuple[int,int]): The size of the graphic. + pixel_ratio (float): The screen pixel ratio. + """ + smooth_factor: int = 2 + radius_factor: int = 8 + width: int = math.floor(pixel_ratio * 2) + + # Highlight + im_hl: Image.Image = Image.new( + mode="RGBA", + size=tuple([d * smooth_factor for d in size]), # type: ignore + color="#00000000", + ) + draw = ImageDraw.Draw(im_hl) + draw.rounded_rectangle( + (width, width) + tuple([d - (width + 1) for d in im_hl.size]), + radius=math.ceil((radius_factor * smooth_factor * pixel_ratio) - (pixel_ratio * 3)), + fill=None, + outline="white", + width=width, + ) + im_hl = im_hl.resize( + size, + resample=Image.Resampling.BILINEAR, + ) + + # Shadow + im_sh: Image.Image = Image.new( + mode="RGBA", + size=tuple([d * smooth_factor for d in size]), # type: ignore + color="#00000000", + ) + draw = ImageDraw.Draw(im_sh) + draw.rounded_rectangle( + (0, 0) + tuple([d - 1 for d in im_sh.size]), + radius=math.ceil(radius_factor * smooth_factor * pixel_ratio), + fill=None, + outline="black", + width=width, + ) + im_sh = im_sh.resize( + size, + resample=Image.Resampling.BILINEAR, + ) + + return (im_hl, im_sh) + + def _render_icon( + self, + name: str, + color: UiColor, + size: tuple[int, int], + pixel_ratio: float, + draw_border: bool = True, + ) -> Image.Image: + """Render a thumbnail icon. + + Args: + name (str): The name of the icon resource. + color (UiColor): The color to use for the icon. + size (tuple[int,int]): The size of the icon. + pixel_ratio (float): The screen pixel ratio. + draw_border (bool): Option to draw a border. + """ + border_factor: int = 5 + smooth_factor: int = math.ceil(2 * pixel_ratio) + radius_factor: int = 8 + icon_ratio: float = 1.75 + + # Create larger blank image based on smooth_factor + im: Image.Image = Image.new( + "RGBA", + size=tuple([d * smooth_factor for d in size]), # type: ignore + color="#00000000", + ) + + # Create solid background color + bg: Image.Image = Image.new( + "RGB", + size=tuple([d * smooth_factor for d in size]), # type: ignore + color="#000000", + ) + + # Paste background color with rounded rectangle mask onto blank image + im.paste( + bg, + (0, 0), + mask=self._get_mask( + tuple([d * smooth_factor for d in size]), # type: ignore + (pixel_ratio * smooth_factor), + ), + ) + + # Draw rounded rectangle border + if draw_border: + draw = ImageDraw.Draw(im) + draw.rounded_rectangle( + (0, 0) + tuple([d - 1 for d in im.size]), + radius=math.ceil( + (radius_factor * smooth_factor * pixel_ratio) + (pixel_ratio * 1.5) + ), + fill="black", + outline="#FF0000", + width=math.floor( + (border_factor * smooth_factor * pixel_ratio) - (pixel_ratio * 1.5) + ), + ) + + # Resize image to final size + im = im.resize( + size, + resample=Image.Resampling.BILINEAR, + ) + fg: Image.Image = Image.new( + "RGB", + size=size, + color="#00FF00", + ) + + # Get icon by name + icon: Image.Image = self.rm.get(name) + if not icon: + icon = self.rm.get("file_generic") + if not icon: + icon = Image.new(mode="RGBA", size=(32, 32), color="magenta") + + # Resize icon to fit icon_ratio + icon = icon.resize((math.ceil(size[0] // icon_ratio), math.ceil(size[1] // icon_ratio))) + + # Paste icon centered + im.paste( + im=fg.resize((math.ceil(size[0] // icon_ratio), math.ceil(size[1] // icon_ratio))), + box=( + math.ceil((size[0] - (size[0] // icon_ratio)) // 2), + math.ceil((size[1] - (size[1] // icon_ratio)) // 2), + ), + mask=icon.getchannel(3), + ) + + # Apply color overlay + im = self._apply_overlay_color( + im, + color, + ) + + return im + + def _apply_overlay_color(self, image: Image.Image, color: UiColor) -> Image.Image: + """Apply a color overlay effect to an image based on its color channel data. + + Red channel for foreground, green channel for outline, none for background. + + Args: + image (Image.Image): The image to apply an overlay to. + color (UiColor): The name of the ColorType color to use. + """ + bg_color: str = ( + get_ui_color(ColorType.DARK_ACCENT, color) + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else get_ui_color(ColorType.PRIMARY, color) + ) + fg_color: str = ( + get_ui_color(ColorType.PRIMARY, color) + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else get_ui_color(ColorType.LIGHT_ACCENT, color) + ) + ol_color: str = ( + get_ui_color(ColorType.BORDER, color) + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else get_ui_color(ColorType.LIGHT_ACCENT, color) + ) + + bg: Image.Image = Image.new(image.mode, image.size, color=bg_color) + fg: Image.Image = Image.new(image.mode, image.size, color=fg_color) + ol: Image.Image = Image.new(image.mode, image.size, color=ol_color) + + bg.paste(fg, (0, 0), mask=image.getchannel(0)) + bg.paste(ol, (0, 0), mask=image.getchannel(1)) + + if image.mode == "RGBA": + alpha_bg: Image.Image = bg.copy() + alpha_bg.convert("RGBA") + alpha_bg.putalpha(0) + alpha_bg.paste(bg, (0, 0), mask=image.getchannel(3)) + bg = alpha_bg + + return bg + + def _apply_edge( + self, + image: Image.Image, + edge: tuple[Image.Image, Image.Image], + faded: bool = False, + ): + """Apply a given edge effect to an image. + + Args: + image (Image.Image): The image to apply the edge to. + edge (tuple[Image.Image, Image.Image]): The edge images to apply. + Item 0 is the inner highlight, and item 1 is the outer shadow. + faded (bool): Whether or not to apply a faded version of the edge. + Used for light themes. + """ + opacity: float = 1.0 if not faded else 0.8 + shade_reduction: float = ( + 0 if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark else 0.3 + ) + im: Image.Image = image + im_hl, im_sh = deepcopy(edge) + + # Configure and apply a soft light overlay. + # This makes up the bulk of the effect. + im_hl.putalpha(ImageEnhance.Brightness(im_hl.getchannel(3)).enhance(opacity)) + im.paste(ImageChops.soft_light(im, im_hl), mask=im_hl.getchannel(3)) + + # Configure and apply a normal shading overlay. + # This helps with contrast. + im_sh.putalpha( + ImageEnhance.Brightness(im_sh.getchannel(3)).enhance(max(0, opacity - shade_reduction)) + ) + im.paste(im_sh, mask=im_sh.getchannel(3)) + + return im + + def _audio_album_thumb(self, filepath: Path, ext: str) -> Image.Image | None: + """Return an album cover thumb from an audio file if a cover is present. + + Args: + filepath (Path): The path of the file. + ext (str): The file extension (with leading "."). + """ + image: Image.Image = None + try: + if not filepath.is_file(): + raise FileNotFoundError + + artwork = None + if ext in [".mp3"]: + id3_tags: id3.ID3 = id3.ID3(filepath) + id3_covers: list = id3_tags.getall("APIC") + if id3_covers: + artwork = Image.open(BytesIO(id3_covers[0].data)) + elif ext in [".flac"]: + flac_tags: flac.FLAC = flac.FLAC(filepath) + flac_covers: list = flac_tags.pictures + if flac_covers: + artwork = Image.open(BytesIO(flac_covers[0].data)) + elif ext in [".mp4", ".m4a", ".aac"]: + mp4_tags: mp4.MP4 = mp4.MP4(filepath) + mp4_covers: list = mp4_tags.get("covr") + if mp4_covers: + artwork = Image.open(BytesIO(mp4_covers[0])) + if artwork: + image = artwork + except ( + mp4.MP4MetadataError, + mp4.MP4StreamInfoError, + id3.ID3NoHeaderError, + MutagenError, + ) as e: + logger.error("Couldn't read album artwork", path=filepath, error=type(e).__name__) + return image + + def _audio_waveform_thumb( + self, filepath: Path, ext: str, size: int, pixel_ratio: float + ) -> Image.Image | None: + """Render a waveform image from an audio file. + + Args: + filepath (Path): The path of the file. + ext (str): The file extension (with leading "."). + size (tuple[int,int]): The size of the thumbnail. + pixel_ratio (float): The screen pixel ratio. + """ + # BASE_SCALE used for drawing on a larger image and resampling down + # to provide an antialiased effect. + base_scale: int = 2 + samples_per_bar: int = 3 + size_scaled: int = size * base_scale + allow_small_min: bool = False + im: Image.Image = None + + try: + bar_count: int = min(math.floor((size // pixel_ratio) / 5), 64) + audio: AudioSegment = AudioSegment.from_file(filepath, ext[1:]) + data = np.fromstring(audio._data, np.int16) # type: ignore + data_indices = np.linspace(1, len(data), num=bar_count * samples_per_bar) + bar_margin: float = ((size_scaled / (bar_count * 3)) * base_scale) / 2 + line_width: float = ((size_scaled - bar_margin) / (bar_count * 3)) * base_scale + bar_height: float = (size_scaled) - (size_scaled // bar_margin) + + count: int = 0 + maximum_item: int = 0 + max_array: list = [] + highest_line: int = 0 + + for i in range(-1, len(data_indices)): + d = data[math.ceil(data_indices[i]) - 1] + if count < samples_per_bar: + count = count + 1 + if abs(d) > maximum_item: + maximum_item = abs(d) + else: + max_array.append(maximum_item) + + if maximum_item > highest_line: + highest_line = maximum_item + + maximum_item = 0 + count = 1 + + line_ratio = max(highest_line / bar_height, 1) + + im = Image.new("RGB", (size_scaled, size_scaled), color="#000000") + draw = ImageDraw.Draw(im) + + current_x = bar_margin + for item in max_array: + item_height = item / line_ratio + + # If small minimums are not allowed, raise all values + # smaller than the line width to the same value. + if not allow_small_min: + item_height = max(item_height, line_width) + + current_y = (bar_height - item_height + (size_scaled // bar_margin)) // 2 + + draw.rounded_rectangle( + ( + current_x, + current_y, + (current_x + line_width), + (current_y + item_height), + ), + radius=100 * base_scale, + fill=("#FF0000"), + outline=("#FFFF00"), + width=max(math.ceil(line_width / 6), base_scale), + ) + + current_x = current_x + line_width + bar_margin + + im.resize((size, size), Image.Resampling.BILINEAR) + + except exceptions.CouldntDecodeError as e: + logger.error("Couldn't render waveform", path=filepath.name, error=type(e).__name__) + + return im + + def _blender(self, filepath: Path) -> Image.Image: + """Get an emended thumbnail from a Blender file, if a thumbnail is present. + + Args: + filepath (Path): The path of the file. + """ + bg_color: str = ( + "#1e1e1e" + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else "#FFFFFF" + ) + im: Image.Image = None + try: + blend_image = blend_thumb(str(filepath)) + + bg = Image.new("RGB", blend_image.size, color=bg_color) + bg.paste(blend_image, mask=blend_image.getchannel(3)) + im = bg + + except ( + AttributeError, + UnidentifiedImageError, + TypeError, + ) as e: + if str(e) == "expected string or buffer": + logger.info( + f"[ThumbRenderer][BLENDER][INFO] {filepath.name} " + f"Doesn't have an embedded thumbnail. ({type(e).__name__})" + ) + + else: + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) + return im + + def _source_engine(self, filepath: Path) -> Image.Image: + """This is a function to convert the VTF (Valve Texture Format) files to thumbnails. + + It works using the VTF2IMG library for PILLOW. + """ + parser = Parser(filepath) + im: Image.Image = None + try: + im = parser.get_image() + + except ( + AttributeError, + UnidentifiedImageError, + TypeError, + struct.error, + ) as e: + if str(e) == "expected string or buffer": + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) + + else: + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) + return im + + @classmethod + def _open_doc_thumb(cls, filepath: Path) -> Image.Image: + """Extract and render a thumbnail for an OpenDocument file. + + Args: + filepath (Path): The path of the file. + """ + file_path_within_zip = "Thumbnails/thumbnail.png" + im: Image.Image = None + with zipfile.ZipFile(filepath, "r") as zip_file: + # Check if the file exists in the zip + if file_path_within_zip in zip_file.namelist(): + # Read the specific file into memory + file_data = zip_file.read(file_path_within_zip) + thumb_im = Image.open(BytesIO(file_data)) + if thumb_im: + im = Image.new("RGB", thumb_im.size, color="#1e1e1e") + im.paste(thumb_im) + else: + logger.error("Couldn't render thumbnail", filepath=filepath) + + return im + + @classmethod + def _epub_cover(cls, filepath: Path) -> Image.Image: + """Extracts and returns the first image found in the ePub file at the given filepath. + + Args: + filepath (Path): The path to the ePub file. + + Returns: + Image: The first image found in the ePub file, or None by default. + """ + im: Image.Image = None + try: + with zipfile.ZipFile(filepath, "r") as zip_file: + for file_name in zip_file.namelist(): + if file_name.lower().endswith( + (".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg") + ): + image_data = zip_file.read(file_name) + im = Image.open(BytesIO(image_data)) + except Exception as e: + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) + + return im + + def _font_short_thumb(self, filepath: Path, size: int) -> Image.Image: + """Render a small font preview ("Aa") thumbnail from a font file. + + Args: + filepath (Path): The path of the file. + size (tuple[int,int]): The size of the thumbnail. + """ + im: Image.Image = None + try: + bg = Image.new("RGB", (size, size), color="#000000") + raw = Image.new("RGB", (size * 3, size * 3), color="#000000") + draw = ImageDraw.Draw(raw) + font = ImageFont.truetype(filepath, size=size) + # NOTE: While a stroke effect is desired, the text + # method only allows for outer strokes, which looks + # a bit weird when rendering fonts. + draw.text( + (size // 8, size // 8), + "Aa", + font=font, + fill="#FF0000", + # stroke_width=math.ceil(size / 96), + # stroke_fill="#FFFF00", + ) + # NOTE: Change to getchannel(1) if using an outline. + data = np.asarray(raw.getchannel(0)) + + m, n = data.shape[:2] + col: np.ndarray = data.any(0) + row: np.ndarray = data.any(1) + cropped_data = np.asarray(raw)[ + row.argmax() : m - row[::-1].argmax(), + col.argmax() : n - col[::-1].argmax(), + ] + cropped_im: Image.Image = Image.fromarray(cropped_data, "RGB") + + margin: int = math.ceil(size // 16) + + orig_x, orig_y = cropped_im.size + new_x, new_y = (size, size) + if orig_x > orig_y: + new_x = size + new_y = math.ceil(size * (orig_y / orig_x)) + elif orig_y > orig_x: + new_y = size + new_x = math.ceil(size * (orig_x / orig_y)) + + cropped_im = cropped_im.resize( + size=(new_x - (margin * 2), new_y - (margin * 2)), + resample=Image.Resampling.BILINEAR, + ) + bg.paste( + cropped_im, + box=(margin, margin + ((size - new_y) // 2)), + ) + im = self._apply_overlay_color(bg, UiColor.PURPLE) + except OSError as e: + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) + return im + + def _font_long_thumb(self, filepath: Path, size: int) -> Image.Image: + """Render a large font preview ("Alphabet") thumbnail from a font file. + + Args: + filepath (Path): The path of the file. + size (tuple[int,int]): The size of the thumbnail. + """ + # Scale the sample font sizes to the preview image + # resolution,assuming the sizes are tuned for 256px. + im: Image.Image = None + try: + scaled_sizes: list[int] = [math.floor(x * (size / 256)) for x in FONT_SAMPLE_SIZES] + bg = Image.new("RGBA", (size, size), color="#00000000") + draw = ImageDraw.Draw(bg) + lines_of_padding = 2 + y_offset = 0 + + for font_size in scaled_sizes: + font = ImageFont.truetype(filepath, size=font_size) + text_wrapped: str = wrap_full_text( + FONT_SAMPLE_TEXT, font=font, width=size, draw=draw + ) + draw.multiline_text((0, y_offset), text_wrapped, font=font) + y_offset += (len(text_wrapped.split("\n")) + lines_of_padding) * draw.textbbox( + (0, 0), "A", font=font + )[-1] + im = theme_fg_overlay(bg, use_alpha=False) + except OSError as e: + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) + return im + + def _image_raw_thumb(self, filepath: Path) -> Image.Image: + """Render a thumbnail for a RAW image type. + + Args: + filepath (Path): The path of the file. + """ + im: Image.Image = None + try: + with rawpy.imread(str(filepath)) as raw: + rgb = raw.postprocess() + im = Image.frombytes( + "RGB", + (rgb.shape[1], rgb.shape[0]), + rgb, + decoder_name="raw", + ) + except ( + DecompressionBombError, + rawpy._rawpy.LibRawIOError, + rawpy._rawpy.LibRawFileUnsupportedError, + ) as e: + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) + return im + + def _image_thumb(self, filepath: Path) -> Image.Image: + """Render a thumbnail for a standard image type. + + Args: + filepath (Path): The path of the file. + """ + im: Image.Image = None + try: + im = Image.open(filepath) + if im.mode != "RGB" and im.mode != "RGBA": + im = im.convert(mode="RGBA") + if im.mode == "RGBA": + new_bg = Image.new("RGB", im.size, color="#1e1e1e") + new_bg.paste(im, mask=im.getchannel(3)) + im = new_bg + im = ImageOps.exif_transpose(im) + except ( + UnidentifiedImageError, + DecompressionBombError, + ) as e: + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) + return im + + @classmethod + def _image_vector_thumb(cls, filepath: Path, size: int) -> Image.Image: + """Render a thumbnail for a vector image, such as SVG. + + Args: + filepath (Path): The path of the file. + size (tuple[int,int]): The size of the thumbnail. + """ + im: Image.Image = None + # Create an image to draw the svg to and a painter to do the drawing + q_image: QImage = QImage(size, size, QImage.Format.Format_ARGB32) + q_image.fill("#1e1e1e") + + # Create an svg renderer, then render to the painter + svg: QSvgRenderer = QSvgRenderer(str(filepath)) + + if not svg.isValid(): + raise UnidentifiedImageError + + painter: QPainter = QPainter(q_image) + svg.setAspectRatioMode(Qt.AspectRatioMode.KeepAspectRatio) + svg.render(painter) + painter.end() + + # Write the image to a buffer as png + buffer: QBuffer = QBuffer() + buffer.open(QBuffer.OpenModeFlag.ReadWrite) + q_image.save(buffer, "PNG") # type: ignore[call-overload] + + # Load the image from the buffer + im = Image.new("RGB", (size, size), color="#1e1e1e") + im.paste(Image.open(BytesIO(buffer.data().data()))) + im = im.convert(mode="RGB") + + buffer.close() + return im + + def _model_stl_thumb(self, filepath: Path, size: int) -> Image.Image: + """Render a thumbnail for an STL file. + + Args: + filepath (Path): The path of the file. + size (tuple[int,int]): The size of the icon. + """ + # TODO: Implement. + # The following commented code describes a method for rendering via + # matplotlib. + # This implementation did not play nice with multithreading. + im: Image.Image = None + # # Create a new plot + # matplotlib.use('agg') + # figure = plt.figure() + # axes = figure.add_subplot(projection='3d') + + # # Load the STL files and add the vectors to the plot + # your_mesh = mesh.Mesh.from_file(_filepath) + + # poly_collection = mplot3d.art3d.Poly3DCollection(your_mesh.vectors) + # poly_collection.set_color((0,0,1)) # play with color + # scale = your_mesh.points.flatten() + # axes.auto_scale_xyz(scale, scale, scale) + # axes.add_collection3d(poly_collection) + # # plt.show() + # img_buf = io.BytesIO() + # plt.savefig(img_buf, format='png') + # im = Image.open(img_buf) + + return im + + @classmethod + def _pdf_thumb(cls, filepath: Path, size: int) -> Image.Image: + """Render a thumbnail for a PDF file. + + filepath (Path): The path of the file. + size (int): The size of the icon. + """ + im: Image.Image = None + + file: QFile = QFile(filepath) + success: bool = file.open( + QIODeviceBase.OpenModeFlag.ReadOnly, QFileDevice.Permission.ReadUser + ) + if not success: + logger.error("Couldn't render thumbnail", filepath=filepath) + return im + document: QPdfDocument = QPdfDocument() + document.load(file) + # Transform page_size in points to pixels with proper aspect ratio + page_size: QSizeF = document.pagePointSize(0) + ratio_hw: float = page_size.height() / page_size.width() + if ratio_hw >= 1: + page_size *= size / page_size.height() + else: + page_size *= size / page_size.width() + # Enlarge image for antialiasing + scale_factor = 2.5 + page_size *= scale_factor + # Render image with no anti-aliasing for speed + render_options: QPdfDocumentRenderOptions = QPdfDocumentRenderOptions() + render_options.setRenderFlags( + QPdfDocumentRenderOptions.RenderFlag.TextAliased + | QPdfDocumentRenderOptions.RenderFlag.ImageAliased + | QPdfDocumentRenderOptions.RenderFlag.PathAliased + ) + # Convert QImage to PIL Image + q_image: QImage = document.render(0, page_size.toSize(), render_options) + buffer: QBuffer = QBuffer() + buffer.open(QBuffer.OpenModeFlag.ReadWrite) + try: + q_image.save(buffer, "PNG") # type: ignore[call-overload] + im = Image.open(BytesIO(buffer.buffer().data())) + finally: + buffer.close() + # Replace transparent pixels with white (otherwise Background defaults to transparent) + return replace_transparent_pixels(im) + + def _text_thumb(self, filepath: Path) -> Image.Image: + """Render a thumbnail for a plaintext file. + + Args: + filepath (Path): The path of the file. + """ + im: Image.Image = None + + bg_color: str = ( + "#1e1e1e" + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else "#FFFFFF" + ) + fg_color: str = ( + "#FFFFFF" + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else "#111111" + ) + + try: + encoding = detect_char_encoding(filepath) + with open(filepath, encoding=encoding) as text_file: + text = text_file.read(256) + bg = Image.new("RGB", (256, 256), color=bg_color) + draw = ImageDraw.Draw(bg) + draw.text((16, 16), text, fill=fg_color) + im = bg + except ( + UnidentifiedImageError, + cv2.error, + DecompressionBombError, + UnicodeDecodeError, + OSError, + ) as e: + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) + return im + + def _video_thumb(self, filepath: Path) -> Image.Image: + """Render a thumbnail for a video file. + + Args: + filepath (Path): The path of the file. + """ + im: Image.Image = None + try: + if is_readable_video(filepath): + video = cv2.VideoCapture(str(filepath), cv2.CAP_FFMPEG) + # TODO: Move this check to is_readable_video() + if video.get(cv2.CAP_PROP_FRAME_COUNT) <= 0: + raise cv2.error("File is invalid or has 0 frames") + video.set( + cv2.CAP_PROP_POS_FRAMES, + (video.get(cv2.CAP_PROP_FRAME_COUNT) // 2), + ) + # NOTE: Depending on the video format, compression, and + # frame count, seeking halfway does not work and the thumb + # must be pulled from the earliest available frame. + max_frame_seek: int = 10 + for i in range( + 0, + min(max_frame_seek, math.floor(video.get(cv2.CAP_PROP_FRAME_COUNT))), + ): + success, frame = video.read() + if not success: + video.set(cv2.CAP_PROP_POS_FRAMES, i) + else: + break + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + im = Image.fromarray(frame) + except ( + UnidentifiedImageError, + cv2.error, + DecompressionBombError, + OSError, + ) as e: + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) + return im def render( self, @@ -90,29 +1002,64 @@ class ThumbRenderer(QObject): base_size: tuple[int, int], pixel_ratio: float, is_loading: bool = False, - gradient: bool = False, + is_grid_thumb: bool = False, update_on_ratio_change: bool = False, ): - """Internal renderer. Render an entry/element thumbnail for the GUI.""" - logger.debug("rendering thumbnail", path=filepath) + """Render a thumbnail or preview image. + Args: + timestamp (float): The timestamp for which this this job was dispatched. + filepath (str | Path): The path of the file to render a thumbnail for. + base_size (tuple[int,int]): The unmodified base size of the thumbnail. + pixel_ratio (float): The screen pixel ratio. + is_loading (bool): Is this a loading graphic? + is_grid_thumb (bool): Is this a thumbnail for the thumbnail grid? + Or else the Preview Pane? + update_on_ratio_change (bool): Should an updated ratio signal be sent? + + """ + adj_size = math.ceil(max(base_size[0], base_size[1]) * pixel_ratio) image: Image.Image = None pixmap: QPixmap = None final: Image.Image = None _filepath: Path = Path(filepath) resampling_method = Image.Resampling.BILINEAR - if ThumbRenderer.font_pixel_ratio != pixel_ratio: - ThumbRenderer.font_pixel_ratio = pixel_ratio - ThumbRenderer.ext_font = ImageFont.truetype( - Path(__file__).parents[3] / "resources/qt/fonts/Oxanium-Bold.ttf", - math.floor(12 * ThumbRenderer.font_pixel_ratio), - ) - adj_size = math.ceil(max(base_size[0], base_size[1]) * pixel_ratio) - if is_loading: - final = ThumbRenderer.thumb_loading_512.resize( - (adj_size, adj_size), resample=Image.Resampling.BILINEAR + theme_color: UiColor = ( + UiColor.THEME_LIGHT + if QGuiApplication.styleHints().colorScheme() == Qt.ColorScheme.Light + else UiColor.THEME_DARK + ) + + # Initialize "Loading" thumbnail + loading_thumb: Image.Image = self._get_icon( + "thumb_loading", theme_color, (adj_size, adj_size), pixel_ratio + ) + + def render_default() -> Image.Image: + if update_on_ratio_change: + self.updated_ratio.emit(1) + im = self._get_icon( + name=self._get_resource_id(_filepath), + color=theme_color, + size=(adj_size, adj_size), + pixel_ratio=pixel_ratio, ) + return im + + def render_unlinked() -> Image.Image: + if update_on_ratio_change: + self.updated_ratio.emit(1) + im = self._get_icon( + name="broken_link_icon", + color=UiColor.RED, + size=(adj_size, adj_size), + pixel_ratio=pixel_ratio, + ) + return im + + if is_loading: + final = loading_thumb.resize((adj_size, adj_size), resample=Image.Resampling.BILINEAR) qim = ImageQt.ImageQt(final) pixmap = QPixmap.fromImage(qim) pixmap.setDevicePixelRatio(pixel_ratio) @@ -120,93 +1067,84 @@ class ThumbRenderer(QObject): self.updated_ratio.emit(1) elif _filepath: try: + # Missing Files ================================================ + if not _filepath.exists(): + raise FileNotFoundError + ext: str = _filepath.suffix.lower() # Images ======================================================= - if _filepath.suffix.lower() in IMAGE_TYPES: - try: - image = Image.open(_filepath) - if image.mode != "RGB" and image.mode != "RGBA": - image = image.convert(mode="RGBA") - if image.mode == "RGBA": - new_bg = Image.new("RGB", image.size, color="#1e1e1e") - new_bg.paste(image, mask=image.getchannel(3)) - image = new_bg - - image = ImageOps.exif_transpose(image) - except DecompressionBombError as e: - logger.error("Couldn't Render thumbnail", filepath=filepath, error=e) - - elif _filepath.suffix.lower() in RAW_IMAGE_TYPES: - try: - with rawpy.imread(str(_filepath)) as raw: - rgb = raw.postprocess() - image = Image.frombytes( - "RGB", - (rgb.shape[1], rgb.shape[0]), - rgb, - decoder_name="raw", - ) - except DecompressionBombError as e: - logger.error("Couldn't Render thumbnail", filepath=filepath, error=e) - - except ( - rawpy._rawpy.LibRawIOError, - rawpy._rawpy.LibRawFileUnsupportedError, - ) as e: - logger.error("Couldn't Render thumbnail", filepath=filepath, error=e) - + if MediaCategories.is_ext_in_category( + ext, MediaCategories.IMAGE_TYPES, mime_fallback=True + ): + # Raw Images ----------------------------------------------- + if MediaCategories.is_ext_in_category( + ext, MediaCategories.IMAGE_RAW_TYPES, mime_fallback=True + ): + image = self._image_raw_thumb(_filepath) + # Vector Images -------------------------------------------- + elif MediaCategories.is_ext_in_category( + ext, MediaCategories.IMAGE_VECTOR_TYPES, mime_fallback=True + ): + image = self._image_vector_thumb(_filepath, adj_size) + # Normal Images -------------------------------------------- + else: + image = self._image_thumb(_filepath) # Videos ======================================================= - elif _filepath.suffix.lower() in VIDEO_TYPES: - video = cv2.VideoCapture(str(_filepath)) - frame_count = video.get(cv2.CAP_PROP_FRAME_COUNT) - if frame_count <= 0: - raise cv2.error("File is invalid or has 0 frames") - video.set(cv2.CAP_PROP_POS_FRAMES, frame_count // 2) - success, frame = video.read() - if not success: - # Depending on the video format, compression, and frame - # count, seeking halfway does not work and the thumb - # must be pulled from the earliest available frame. - video.set(cv2.CAP_PROP_POS_FRAMES, 0) - success, frame = video.read() - frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - image = Image.fromarray(frame) - + elif MediaCategories.is_ext_in_category( + ext, MediaCategories.VIDEO_TYPES, mime_fallback=True + ): + image = self._video_thumb(_filepath) + # OpenDocument/OpenOffice ====================================== + elif MediaCategories.is_ext_in_category( + ext, MediaCategories.OPEN_DOCUMENT_TYPES, mime_fallback=True + ): + image = self._open_doc_thumb(_filepath) # Plain Text =================================================== - elif _filepath.suffix.lower() in PLAINTEXT_TYPES: - encoding = detect_char_encoding(_filepath) - with open(_filepath, encoding=encoding) as text_file: - text = text_file.read(256) - bg = Image.new("RGB", (256, 256), color="#1e1e1e") - draw = ImageDraw.Draw(bg) - draw.text((16, 16), text, file=(255, 255, 255)) - image = bg - # 3D =========================================================== - # elif extension == 'stl': - # # Create a new plot - # matplotlib.use('agg') - # figure = plt.figure() - # axes = figure.add_subplot(projection='3d') - - # # Load the STL files and add the vectors to the plot - # your_mesh = mesh.Mesh.from_file(_filepath) - - # poly_collection = mplot3d.art3d.Poly3DCollection(your_mesh.vectors) - # poly_collection.set_color((0,0,1)) # play with color - # scale = your_mesh.points.flatten() - # axes.auto_scale_xyz(scale, scale, scale) - # axes.add_collection3d(poly_collection) - # # plt.show() - # img_buf = io.BytesIO() - # plt.savefig(img_buf, format='png') - # image = Image.open(img_buf) + elif MediaCategories.is_ext_in_category( + ext, MediaCategories.PLAINTEXT_TYPES, mime_fallback=True + ): + image = self._text_thumb(_filepath) + # Fonts ======================================================== + elif MediaCategories.is_ext_in_category( + ext, MediaCategories.FONT_TYPES, mime_fallback=True + ): + if is_grid_thumb: + # Short (Aa) Preview + image = self._font_short_thumb(_filepath, adj_size) + else: + # Large (Full Alphabet) Preview + image = self._font_long_thumb(_filepath, adj_size) + # Audio ======================================================== + elif MediaCategories.is_ext_in_category( + ext, MediaCategories.AUDIO_TYPES, mime_fallback=True + ): + image = self._audio_album_thumb(_filepath, ext) + if image is None: + image = self._audio_waveform_thumb(_filepath, ext, adj_size, pixel_ratio) + if image is not None: + image = self._apply_overlay_color(image, UiColor.GREEN) + # Ebooks ======================================================= + elif MediaCategories.is_ext_in_category( + ext, MediaCategories.EBOOK_TYPES, mime_fallback=True + ): + image = self._epub_cover(_filepath) + # Blender ====================================================== + elif MediaCategories.is_ext_in_category( + ext, MediaCategories.BLENDER_TYPES, mime_fallback=True + ): + image = self._blender(_filepath) + # PDF ========================================================== + elif MediaCategories.is_ext_in_category( + ext, MediaCategories.PDF_TYPES, mime_fallback=True + ): + image = self._pdf_thumb(_filepath, adj_size) + # VTF ========================================================== + elif MediaCategories.is_ext_in_category( + ext, MediaCategories.SOURCE_ENGINE_TYPES, mime_fallback=True + ): + image = self._source_engine(_filepath) # No Rendered Thumbnail ======================================== - else: - image = ThumbRenderer.thumb_file_default_512.resize( - (adj_size, adj_size), resample=Image.Resampling.BILINEAR - ) - if not image: - raise UnidentifiedImageError + raise NoRendererError orig_x, orig_y = image.size new_x, new_y = (adj_size, adj_size) @@ -227,48 +1165,34 @@ class ThumbRenderer(QObject): else Image.Resampling.BILINEAR ) image = image.resize((new_x, new_y), resample=resampling_method) - if gradient: - mask: Image.Image = ThumbRenderer.thumb_mask_512.resize( - (adj_size, adj_size), resample=Image.Resampling.BILINEAR - ).getchannel(3) - hl: Image.Image = ThumbRenderer.thumb_mask_hl_512.resize( - (adj_size, adj_size), resample=Image.Resampling.BILINEAR + mask: Image.Image = None + if is_grid_thumb: + mask = self._get_mask((adj_size, adj_size), pixel_ratio) + edge: tuple[Image.Image, Image.Image] = self._get_edge( + (adj_size, adj_size), pixel_ratio + ) + final = self._apply_edge( + four_corner_gradient(image, (adj_size, adj_size), mask), + edge, ) - final = four_corner_gradient_background(image, adj_size, mask, hl) else: - scalar = 4 - rec: Image.Image = Image.new( - "RGB", - tuple([d * scalar for d in image.size]), # type: ignore - "black", - ) - draw = ImageDraw.Draw(rec) - draw.rounded_rectangle( - (0, 0) + rec.size, - (base_size[0] // 32) * scalar * pixel_ratio, - fill="red", - ) - rec = rec.resize( - tuple([d // scalar for d in rec.size]), - resample=Image.Resampling.BILINEAR, - ) + mask = self._get_mask(image.size, pixel_ratio, scale_radius=True) final = Image.new("RGBA", image.size, (0, 0, 0, 0)) - final.paste(image, mask=rec.getchannel(0)) + final.paste(image, mask=mask.getchannel(0)) + + except FileNotFoundError: + final = render_unlinked() except ( UnidentifiedImageError, - FileNotFoundError, - cv2.error, DecompressionBombError, - UnicodeDecodeError, + ValueError, + ChildProcessError, ) as e: - if e is not UnicodeDecodeError: - logger.error("Couldn't Render thumbnail", filepath=filepath, error=e) + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) + final = render_default() + except NoRendererError: + final = render_default() - if update_on_ratio_change: - self.updated_ratio.emit(1) - final = ThumbRenderer.thumb_broken_512.resize( - (adj_size, adj_size), resample=resampling_method - ) qim = ImageQt.ImageQt(final) if image: image.close() @@ -283,8 +1207,15 @@ class ThumbRenderer(QObject): math.ceil(adj_size / pixel_ratio), math.ceil(final.size[1] / pixel_ratio), ), + str(_filepath.name), _filepath.suffix.lower(), ) else: - self.updated.emit(timestamp, QPixmap(), QSize(*base_size), _filepath.suffix.lower()) + self.updated.emit( + timestamp, + QPixmap(), + QSize(*base_size), + str(_filepath.name), + _filepath.suffix.lower(), + ) diff --git a/tagstudio/src/qt/widgets/video_player.py b/tagstudio/src/qt/widgets/video_player.py index 0d5928f5..2b4434b1 100644 --- a/tagstudio/src/qt/widgets/video_player.py +++ b/tagstudio/src/qt/widgets/video_player.py @@ -30,6 +30,7 @@ from PySide6.QtSvgWidgets import QSvgWidget from PySide6.QtWidgets import QGraphicsScene, QGraphicsView from src.core.enums import SettingItems from src.qt.helpers.file_opener import FileOpenerHelper +from src.qt.platform_strings import PlatformStrings if typing.TYPE_CHECKING: from src.qt.ts_qt import QtDriver @@ -41,6 +42,7 @@ class VideoPlayer(QGraphicsView): video_preview = None play_pause = None mute_button = None + filepath: str | None def __init__(self, driver: "QtDriver") -> None: super().__init__() @@ -74,6 +76,8 @@ class VideoPlayer(QGraphicsView): self.scene().addItem(self.video_preview) self.video_preview.setAcceptedMouseButtons(Qt.MouseButton.LeftButton) + self.setStyleSheet("border-style:solid;border-width:0px;") + # Set up the video tint. self.video_tint = self.scene().addRect( 0, @@ -115,14 +119,16 @@ class VideoPlayer(QGraphicsView): autoplay_action.setCheckable(True) self.addAction(autoplay_action) autoplay_action.setChecked( - bool(self.driver.settings.value(SettingItems.AUTOPLAY, defaultValue=True, type=bool)) + self.driver.settings.value(SettingItems.AUTOPLAY, defaultValue=True, type=bool) # type: ignore ) autoplay_action.triggered.connect(lambda: self.toggle_autoplay()) self.autoplay = autoplay_action open_file_action = QAction("Open file", self) open_file_action.triggered.connect(self.opener.open_file) - open_explorer_action = QAction("Open file in explorer", self) + + open_explorer_action = QAction(PlatformStrings.open_file_str, self) + open_explorer_action.triggered.connect(self.opener.open_explorer) self.addAction(open_file_action) self.addAction(open_explorer_action) diff --git a/tagstudio/tests/conftest.py b/tagstudio/tests/conftest.py index a7bff9bf..7a59d05e 100644 --- a/tagstudio/tests/conftest.py +++ b/tagstudio/tests/conftest.py @@ -21,6 +21,37 @@ def cwd(): return CWD +@pytest.fixture +def file_mediatypes_library(): + lib = Library() + + status = lib.open_library(pathlib.Path(""), ":memory:") + assert status.success + + entry1 = Entry( + folder=lib.folder, + path=pathlib.Path("foo.png"), + fields=lib.default_fields, + ) + + entry2 = Entry( + folder=lib.folder, + path=pathlib.Path("bar.png"), + fields=lib.default_fields, + ) + + entry3 = Entry( + folder=lib.folder, + path=pathlib.Path("baz.apng"), + fields=lib.default_fields, + ) + + assert lib.add_entries([entry1, entry2, entry3]) + assert len(lib.tags) == 2 + + return lib + + @pytest.fixture def library(request): # when no param is passed, use the default @@ -32,8 +63,8 @@ def library(request): library_path = request.param lib = Library() - lib.open_library(library_path, ":memory:") - assert lib.folder + status = lib.open_library(pathlib.Path(library_path), ":memory:") + assert status.success tag = Tag( name="foo", @@ -86,13 +117,20 @@ def library(request): yield lib +@pytest.fixture +def search_library() -> Library: + lib = Library() + lib.open_library(pathlib.Path(CWD / "fixtures" / "search_library")) + return lib + + @pytest.fixture def entry_min(library): yield next(library.get_entries()) @pytest.fixture -def entry_full(library): +def entry_full(library: Library): yield next(library.get_entries(with_joins=True)) diff --git a/tagstudio/tests/fixtures/json_library/.TagStudio/ts_library.json b/tagstudio/tests/fixtures/json_library/.TagStudio/ts_library.json new file mode 100644 index 00000000..7a1adf94 --- /dev/null +++ b/tagstudio/tests/fixtures/json_library/.TagStudio/ts_library.json @@ -0,0 +1 @@ +{"ts-version":"9.4.1","ext_list":[".json",".xmp",".aae",".rar",".sqlite"],"is_exclude_list":true,"tags":[{"id":0,"name":"Archived JSON","aliases":["Archive"],"color":"Red"},{"id":1,"name":"Favorite JSON","aliases":["Favorited","Favorites"],"color":"Orange"},{"id":1000,"name":"Parent","aliases":[""],"subtag_ids":[1000]},{"id":1001,"name":"Default","aliases":[""]},{"id":1002,"name":"Black","aliases":[""],"subtag_ids":[1001],"color":"black"},{"id":1003,"name":"Dark Gray","aliases":[""],"color":"dark gray"},{"id":1004,"name":"Gray","aliases":["Grey"],"color":"gray"},{"id":1005,"name":"Light Gray","shorthand":"LG","aliases":["Light Grey"],"color":"light gray"},{"id":1006,"name":"White","aliases":[""],"color":"white"},{"id":1007,"name":"Light Pink","shorthand":"LP","aliases":[""],"color":"light pink"},{"id":1008,"name":"Pink","aliases":[""],"color":"pink"},{"id":1009,"name":"Red","aliases":[""],"color":"red"},{"id":1010,"name":"Red Orange","aliases":["𝓗𝓮𝓵𝓵𝓸"],"subtag_ids":[1009,1011],"color":"red orange"},{"id":1011,"name":"Orange","aliases":["Alias with a slash n newline\\nHopefully this isn't on a newline","Alias with \\\\ double backslashes","Alias with / a forward slash"],"color":"orange"},{"id":1012,"name":"Yellow Orange","shorthand":"YO","aliases":[""],"subtag_ids":[1013,1011],"color":"yellow orange"},{"id":1013,"name":"Yellow","aliases":[""],"color":"yellow"},{"id":1014,"name":"Lime","aliases":[""],"color":"lime"},{"id":1015,"name":"Light Green","shorthand":"LG","aliases":[""],"color":"light green"},{"id":1016,"name":"Mint","aliases":[""],"color":"mint"},{"id":1017,"name":"Green","aliases":[""],"color":"green"},{"id":1018,"name":"Teal","aliases":[""],"color":"teal"},{"id":1019,"name":"Cyan","aliases":[""],"color":"cyan"},{"id":1020,"name":"Light Blue","shorthand":"LB","aliases":[""],"subtag_ids":[1021,1006],"color":"light blue"},{"id":1021,"name":"Blue","aliases":[""],"color":"blue"},{"id":1022,"name":"Blue Violet","shorthand":"BV","aliases":[""],"color":"blue violet"},{"id":1023,"name":"Violet","aliases":[""],"color":"violet"},{"id":1024,"name":"Purple","aliases":[""],"subtag_ids":[1009,1021],"color":"purple"},{"id":1025,"name":"Lavender","aliases":[""],"color":"lavender"},{"id":1026,"name":"Berry","aliases":[""],"color":"berry"},{"id":1027,"name":"Magenta","aliases":[""],"color":"magenta"},{"id":1028,"name":"Salmon","aliases":[""],"color":"salmon"},{"id":1029,"name":"Auburn","aliases":[""],"color":"auburn"},{"id":1030,"name":"Dark Brown","aliases":[""],"color":"dark brown"},{"id":1031,"name":"Brown","aliases":[""],"color":"brown"},{"id":1032,"name":"Light Brown","aliases":[""],"color":"light brown"},{"id":1033,"name":"Blonde","aliases":[""],"color":"blonde"},{"id":1034,"name":"Peach","aliases":[""],"color":"peach"},{"id":1035,"name":"Warm Gray","aliases":["Warm Grey"],"color":"warm gray"},{"id":1036,"name":"Cool Gray","aliases":["Cool Grey"],"color":"cool gray"},{"id":1037,"name":"Olive","aliases":["Olive Drab"],"color":"olive"},{"id":1038,"name":"Duplicate","aliases":[""],"color":"white"},{"id":1039,"name":"Duplicate","aliases":[""],"color":"black"},{"id":1040,"name":"Duplicate","aliases":[""],"color":"gray"},{"id":1041,"name":"Child","aliases":[""],"subtag_ids":[1000]},{"id":2000,"name":"Jump to ID 2000","shorthand":"2000","aliases":[""]}],"collations":[],"fields":[],"macros":[],"entries":[{"id":0,"filename":"Sway.mobi","path":"windows_folder\\Books","fields":[{"8":[]},{"6":[]},{"7":[]},{"0":""},{"2":""},{"1":""},{"3":""},{"4":""},{"5":""},{"27":""},{"28":""},{"29":""},{"30":""}]},{"id":1,"filename":"Around the World in 28 Languages.mobi","path":"posix_folder\\Books","fields":[{"0":"This is a title"},{"6":[1000]},{"8":[1]},{"0":"Title 2 🂹"},{"0":"B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿"}]},{"id":2,"filename":"sample.epub","path":"Books","fields":[{"0":"Test\\nDon't newline"},{"1":"Test\\tDon't tab"}]},{"id":3,"filename":"sample - Copy (15).odt","path":"OpenDocument","fields":[{"0":"🈩"},{"1":"𝓅𝓇𝑒𝓉𝓉𝓎"},{"0":"⒲⒪⒭⒦"},{"0":"ʤʤʤʤʤʤʤʤʤʤʤʤ"},{"0":"ဪ"}]},{"id":4,"filename":"sample - Copy (14).odt","path":"OpenDocument","fields":[{"0":"مرحباً بالفوكسل السماوي"},{"4":"مرحباً بالفوكسل السماوي\nمرحباً بالفوكسل السماوي\nمرحباً بالفوكسل السماوي\nمرحباً بالفوكسل السماوي"}]},{"id":5,"filename":"sample - Copy (13).odt","path":"OpenDocument","fields":[{"4":"Всім привіт, сьогодні ми проводимо тест tagstudio"}]},{"id":6,"filename":"sample - Copy (12).odt","path":"OpenDocument","fields":[{"1":"𝓗𝓮𝓵𝓵𝓸 𝓮𝓿𝓮𝓻𝔂𝓫𝓸𝓭𝔂 𝓽𝓸𝓭𝓪𝔂 𝔀𝓮 𝓪𝓻𝓮 𝓭𝓸𝓲𝓷𝓰 𝓪 𝓽𝓪𝓰𝓼𝓽𝓾𝓭𝓲𝓸 𝓽𝓮𝓼𝓽"}]},{"id":7,"filename":"sample - Copy (11).odt","path":"OpenDocument","fields":[{"0":"なこに (nakonicafe)"},{"0":"☠ jared ☠"},{"4":"𝓗𝓮𝓵𝓵𝓸 𝓮𝓿𝓮𝓻𝔂𝓫𝓸𝓭𝔂 𝓽𝓸𝓭𝓪𝔂 𝔀𝓮 𝓪𝓻𝓮 𝓭𝓸𝓲𝓷𝓰 𝓪 𝓽𝓪𝓰𝓼𝓽𝓾𝓭𝓲𝓸 𝓽𝓮𝓼𝓽"},{"4":"☠ jared ☠"}]},{"id":8,"filename":"sample - Copy (10).odt","path":"OpenDocument","fields":[{"8":[0]},{"0":"This is a title underneath a Meta Tags"},{"4":"This is a Description underneath a Title"},{"7":[1021,1022,1023]}]},{"id":9,"filename":"sample - Copy (9).odt","path":"OpenDocument","fields":[{"8":[1]},{"8":[]}]},{"id":10,"filename":"sample - Copy (8).odt","path":"OpenDocument","fields":[{"8":[1]},{"7":[]},{"7":[]}]},{"id":20,"filename":"9lfqvtp2.bmp","path":".","fields":[{"0":"This is a Title"},{"2":"This is an Artist"},{"3":"This is a URL"},{"4":"This is line 1 of 2 of this Description.\nThis is line 2 of 2 of this Description."},{"5":"This is line 1 of 2 of these Notes.\nThis is line 2 of 2 of these Notes."},{"6":[1021,1009,1013]},{"7":[1022,1010,1012]},{"8":[0,1]},{"21":"This is a Source"},{"27":"This is a Publisher"},{"1":"This is an Author"},{"28":"This is a Guest Artist"},{"29":"This is a Composer"},{"30":"This is line 1 of 20 of a comments box.\nThis is line 2 of 20 of a comments box.\nThis is line 3 of 20 of a comments box.\nThis is line 4 of 20 of a comments box.\nThis is line 5 of 20 of a comments box.\nThis is line 6 of 20 of a comments box.\nThis is line 7 of 20 of a comments box.\nThis is line 8 of 20 of a comments box.\nThis is line 9 of 20 of a comments box.\nThis is line 10 of 20 of a comments box.\nThis is line 11 of 20 of a comments box.\nThis is line 12 of 20 of a comments box.\nThis is line 13 of 20 of a comments box.\nThis is line 14 of 20 of a comments box.\nThis is line 15 of 20 of a comments box.\nThis is line 16 of 20 of a comments box.\nThis is line 17 of 20 of a comments box.\nThis is line 18 of 20 of a comments box.\nThis is line 19 of 20 of a comments box.\nThis is line 20 of 20 of a comments box."}]},{"id":25,"filename":"u6wt6d6o.bmp","path":".","fields":[{"4":"This is a description box.\nThis is a description box.\nThis is a description box.\nThis is a description box.\nThis is a description box.\nThis is a description box.\nThis is a description box.\nThank you."},{"1":"Author, for the heck of it."}]},{"id":30,"filename":"empty.png","path":"."}]} \ No newline at end of file diff --git a/tagstudio/tests/fixtures/sample.epub b/tagstudio/tests/fixtures/sample.epub new file mode 100644 index 00000000..b625b67b Binary files /dev/null and b/tagstudio/tests/fixtures/sample.epub differ diff --git a/tagstudio/tests/fixtures/sample.ods b/tagstudio/tests/fixtures/sample.ods new file mode 100644 index 00000000..ecc97b12 Binary files /dev/null and b/tagstudio/tests/fixtures/sample.ods differ diff --git a/tagstudio/tests/fixtures/sample.odt b/tagstudio/tests/fixtures/sample.odt new file mode 100644 index 00000000..4cb6f2f1 Binary files /dev/null and b/tagstudio/tests/fixtures/sample.odt differ diff --git a/tagstudio/tests/fixtures/sample.pdf b/tagstudio/tests/fixtures/sample.pdf new file mode 100644 index 00000000..0293578a Binary files /dev/null and b/tagstudio/tests/fixtures/sample.pdf differ diff --git a/tagstudio/tests/fixtures/sample.svg b/tagstudio/tests/fixtures/sample.svg new file mode 100644 index 00000000..99c924a8 --- /dev/null +++ b/tagstudio/tests/fixtures/sample.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/tagstudio/tests/fixtures/search_library/.TagStudio/ts_library.json b/tagstudio/tests/fixtures/search_library/.TagStudio/ts_library.json new file mode 100644 index 00000000..ff3fb742 --- /dev/null +++ b/tagstudio/tests/fixtures/search_library/.TagStudio/ts_library.json @@ -0,0 +1 @@ +{"ts-version":"9.4.2","ext_list":[".json",".xmp",".aae",".txt"],"is_exclude_list":true,"tags":[{"id":0,"name":"Archived","aliases":["Archive"],"color":"Red"},{"id":1,"name":"Favorite","aliases":["Favorited","Favorites"],"color":"Yellow"},{"id":1000,"name":"Parent","aliases":[""],"subtag_ids":[1000]},{"id":1001,"name":"Default","aliases":[""]},{"id":1002,"name":"Black","aliases":[""],"subtag_ids":[1040],"color":"black"},{"id":1003,"name":"Dark Gray","aliases":["Dark Grey"],"subtag_ids":[1040,1002,1004],"color":"dark gray"},{"id":1004,"name":"Gray","aliases":["Grey"],"subtag_ids":[1040,1002,1006],"color":"gray"},{"id":1005,"name":"Light Gray","aliases":["Light Grey"],"subtag_ids":[1040,1006,1004],"color":"light gray"},{"id":1006,"name":"White","aliases":[""],"subtag_ids":[1040],"color":"white"},{"id":1007,"name":"Light Pink","aliases":[""],"subtag_ids":[1040,1009,1006],"color":"light pink"},{"id":1008,"name":"Pink","aliases":[""],"subtag_ids":[1040,1006,1009],"color":"pink"},{"id":1009,"name":"Red","aliases":[""],"subtag_ids":[1040],"color":"red"},{"id":1010,"name":"Red Orange","aliases":[""],"subtag_ids":[1040,1009,1011],"color":"red orange"},{"id":1011,"name":"Orange","aliases":[""],"subtag_ids":[1040,1009,1013],"color":"orange"},{"id":1012,"name":"Yellow Orange","aliases":[""],"subtag_ids":[1040,1011],"color":"yellow orange"},{"id":1013,"name":"Yellow","aliases":[""],"subtag_ids":[1040],"color":"yellow"},{"id":1014,"name":"Lime","aliases":[""],"subtag_ids":[1040,1017,1006],"color":"lime"},{"id":1015,"name":"Light Green","aliases":[""],"color":"light green"},{"id":1016,"name":"Mint","aliases":[""],"subtag_ids":[1040,1017,1019],"color":"mint"},{"id":1017,"name":"Green","aliases":[""],"subtag_ids":[1040,1021,1013],"color":"green"},{"id":1018,"name":"Teal","aliases":[""],"subtag_ids":[1040,1017,1021],"color":"teal"},{"id":1019,"name":"Cyan","aliases":[""],"subtag_ids":[1040,1017,1021],"color":"cyan"},{"id":1020,"name":"Light Blue","aliases":[""],"subtag_ids":[1040,1021,1006],"color":"light blue"},{"id":1021,"name":"Blue","aliases":[""],"subtag_ids":[1040],"color":"blue"},{"id":1022,"name":"Blue Violet","aliases":[""],"subtag_ids":[1040,1021,1023],"color":"blue violet"},{"id":1023,"name":"Violet","aliases":[""],"subtag_ids":[1040,1009,1021],"color":"violet"},{"id":1024,"name":"Purple","aliases":[""],"subtag_ids":[1040,1009,1021],"color":"purple"},{"id":1025,"name":"Lavender","aliases":[""],"subtag_ids":[1040,1024,1006],"color":"lavender"},{"id":1026,"name":"Berry","aliases":[""],"color":"berry"},{"id":1027,"name":"Magenta","aliases":[""],"color":"magenta"},{"id":1028,"name":"Salmon","aliases":[""],"color":"salmon"},{"id":1029,"name":"Auburn","aliases":[""],"color":"auburn"},{"id":1030,"name":"Dark Brown","aliases":[""],"color":"dark brown"},{"id":1031,"name":"Brown","aliases":[""],"color":"brown"},{"id":1032,"name":"Light Brown","aliases":[""],"color":"light brown"},{"id":1033,"name":"Blonde","aliases":[""],"color":"blonde"},{"id":1034,"name":"Peach","aliases":[""],"color":"peach"},{"id":1035,"name":"Warm Gray","aliases":["Warm Grey"],"subtag_ids":[1040,1004,1011],"color":"warm gray"},{"id":1036,"name":"Cool Gray","aliases":["Cool Grey"],"subtag_ids":[1040,1004,1021],"color":"cool gray"},{"id":1037,"name":"Olive","aliases":[""],"subtag_ids":[1040,1017,1004],"color":"olive"},{"id":1038,"name":"Square","aliases":[""],"subtag_ids":[1039]},{"id":1039,"name":"Shape","aliases":[""]},{"id":1040,"name":"Color","aliases":[""]},{"id":1041,"name":"Circle","aliases":[""],"subtag_ids":[1039,1042]},{"id":1042,"name":"Ellipse","aliases":[""],"subtag_ids":[1039,1043]},{"id":1043,"name":"Round","aliases":[""]}],"collations":[],"fields":[],"macros":[],"entries":[{"id":0,"filename":"red.jpg","path":"inherit colors shapes"},{"id":1,"filename":"red_square.jpg","path":"inherit colors shapes","fields":[{"6":[1009,1038]}]},{"id":2,"filename":"red_circle.jpg","path":"inherit colors shapes","fields":[{"6":[1041,1009]}]},{"id":3,"filename":"blue_circle.jpg","path":"inherit colors shapes","fields":[{"6":[1021,1041]}]},{"id":4,"filename":"blue_square.jpg","path":"inherit colors shapes","fields":[{"6":[1021,1038]}]},{"id":5,"filename":"blue.jpg","path":"inherit colors shapes","fields":[{"6":[1021]}]},{"id":10,"filename":"green_circle.png","path":"inherit colors shapes","fields":[{"6":[1041,1017]}]},{"id":11,"filename":"green.png","path":"inherit colors shapes","fields":[{"6":[1017]}]},{"id":12,"filename":"green_square.png","path":"inherit colors shapes","fields":[{"6":[1017,1038]}]},{"id":13,"filename":"yellow_circle.png","path":"inherit colors shapes","fields":[{"6":[1041,1013]}]},{"id":14,"filename":"yellow_square.png","path":"inherit colors shapes","fields":[{"6":[1038,1013]}]},{"id":15,"filename":"yellow.png","path":"inherit colors shapes","fields":[{"6":[1013]}]},{"id":16,"filename":"square.png","path":"inherit colors shapes","fields":[{"6":[1038]}]},{"id":17,"filename":"circle.png","path":"inherit colors shapes","fields":[{"6":[1041]}]},{"id":18,"filename":"shape.png","path":"inherit colors shapes","fields":[{"6":[1039]}]},{"id":19,"filename":"orange_circle.png","path":"inherit colors shapes","fields":[{"6":[1041,1011]}]},{"id":20,"filename":"orange_square.png","path":"inherit colors shapes","fields":[{"6":[1011,1038]}]},{"id":21,"filename":"orange.png","path":"inherit colors shapes","fields":[{"6":[1011]}]},{"id":22,"filename":"yellow_ellipse.png","path":"inherit colors shapes","fields":[{"6":[1042,1013]}]},{"id":23,"filename":"ellipse.png","path":"inherit colors shapes","fields":[{"6":[1042]}]},{"id":24,"filename":"red_ellipse.png","path":"inherit colors shapes","fields":[{"6":[1042,1009]}]},{"id":25,"filename":"blue_ellipse.png","path":"inherit colors shapes","fields":[{"6":[1021,1042]}]},{"id":26,"filename":"green_ellipse.png","path":"inherit colors shapes","fields":[{"6":[1042,1017]}]},{"id":27,"filename":"orange_ellipse.png","path":"inherit colors shapes","fields":[{"6":[1042,1011]}]},{"id":30,"filename":"r_circle_b_square.png","path":"comp colors shapes","fields":[{"6":[1021,1041,1009,1038]}]},{"id":31,"filename":"r_circle_g_square.png","path":"comp colors shapes","fields":[{"6":[1041,1017,1009,1038]}]},{"id":32,"filename":"r_circle_y_square.png","path":"comp colors shapes","fields":[{"6":[1041,1009,1038,1013]}]},{"id":33,"filename":"r_circle_o_square.png","path":"comp colors shapes","fields":[{"6":[1041,1011,1009,1038]}]},{"id":34,"filename":"r_circle_r_square.png","path":"comp colors shapes","fields":[{"6":[1041,1009,1038]}]}]} \ No newline at end of file diff --git a/tagstudio/tests/fixtures/search_library/.TagStudio/ts_library.sqlite b/tagstudio/tests/fixtures/search_library/.TagStudio/ts_library.sqlite new file mode 100644 index 00000000..449f380b Binary files /dev/null and b/tagstudio/tests/fixtures/search_library/.TagStudio/ts_library.sqlite differ diff --git a/tagstudio/tests/fixtures/search_library/comp colors shapes/r_circle_b_square.png b/tagstudio/tests/fixtures/search_library/comp colors shapes/r_circle_b_square.png new file mode 100644 index 00000000..a25817da Binary files /dev/null and b/tagstudio/tests/fixtures/search_library/comp colors shapes/r_circle_b_square.png differ diff --git a/tagstudio/tests/fixtures/search_library/comp colors shapes/r_circle_g_square.png b/tagstudio/tests/fixtures/search_library/comp colors shapes/r_circle_g_square.png new file mode 100644 index 00000000..0df75c52 Binary files /dev/null and b/tagstudio/tests/fixtures/search_library/comp colors shapes/r_circle_g_square.png differ diff --git a/tagstudio/tests/fixtures/search_library/comp colors shapes/r_circle_o_square.png b/tagstudio/tests/fixtures/search_library/comp colors shapes/r_circle_o_square.png new file mode 100644 index 00000000..d4999109 Binary files /dev/null and b/tagstudio/tests/fixtures/search_library/comp colors shapes/r_circle_o_square.png differ diff --git a/tagstudio/tests/fixtures/search_library/comp colors shapes/r_circle_r_square.png b/tagstudio/tests/fixtures/search_library/comp colors shapes/r_circle_r_square.png new file mode 100644 index 00000000..5a3b5ee9 Binary files /dev/null and b/tagstudio/tests/fixtures/search_library/comp colors shapes/r_circle_r_square.png differ diff --git a/tagstudio/tests/fixtures/search_library/comp colors shapes/r_circle_y_square.png b/tagstudio/tests/fixtures/search_library/comp colors shapes/r_circle_y_square.png new file mode 100644 index 00000000..17027918 Binary files /dev/null and b/tagstudio/tests/fixtures/search_library/comp colors shapes/r_circle_y_square.png differ diff --git a/tagstudio/tests/fixtures/search_library/inherit colors shapes/blue.jpg b/tagstudio/tests/fixtures/search_library/inherit colors shapes/blue.jpg new file mode 100644 index 00000000..0b208114 Binary files /dev/null and b/tagstudio/tests/fixtures/search_library/inherit colors shapes/blue.jpg differ diff --git a/tagstudio/tests/fixtures/search_library/inherit colors shapes/blue_circle.jpg b/tagstudio/tests/fixtures/search_library/inherit colors shapes/blue_circle.jpg new file mode 100644 index 00000000..eacd6db0 Binary files /dev/null and b/tagstudio/tests/fixtures/search_library/inherit colors shapes/blue_circle.jpg differ diff --git a/tagstudio/tests/fixtures/search_library/inherit colors shapes/blue_ellipse.png b/tagstudio/tests/fixtures/search_library/inherit colors shapes/blue_ellipse.png new file mode 100644 index 00000000..e73e9171 Binary files /dev/null and b/tagstudio/tests/fixtures/search_library/inherit colors shapes/blue_ellipse.png differ diff --git a/tagstudio/tests/fixtures/search_library/inherit colors shapes/blue_square.jpg b/tagstudio/tests/fixtures/search_library/inherit colors shapes/blue_square.jpg new file mode 100644 index 00000000..d5589a7c Binary files /dev/null and b/tagstudio/tests/fixtures/search_library/inherit colors shapes/blue_square.jpg differ diff --git a/tagstudio/tests/fixtures/search_library/inherit colors shapes/circle.png b/tagstudio/tests/fixtures/search_library/inherit colors shapes/circle.png new file mode 100644 index 00000000..722f9f36 Binary files /dev/null and b/tagstudio/tests/fixtures/search_library/inherit colors shapes/circle.png differ diff --git a/tagstudio/tests/fixtures/search_library/inherit colors shapes/ellipse.png b/tagstudio/tests/fixtures/search_library/inherit colors shapes/ellipse.png new file mode 100644 index 00000000..43f903e7 Binary files /dev/null and b/tagstudio/tests/fixtures/search_library/inherit colors shapes/ellipse.png differ diff --git a/tagstudio/tests/fixtures/search_library/inherit colors shapes/green.png b/tagstudio/tests/fixtures/search_library/inherit colors shapes/green.png new file mode 100644 index 00000000..96020e58 Binary files /dev/null and b/tagstudio/tests/fixtures/search_library/inherit colors shapes/green.png differ diff --git a/tagstudio/tests/fixtures/search_library/inherit colors shapes/green_circle.png b/tagstudio/tests/fixtures/search_library/inherit colors shapes/green_circle.png new file mode 100644 index 00000000..faaf1f19 Binary files /dev/null and b/tagstudio/tests/fixtures/search_library/inherit colors shapes/green_circle.png differ diff --git a/tagstudio/tests/fixtures/search_library/inherit colors shapes/green_ellipse.png b/tagstudio/tests/fixtures/search_library/inherit colors shapes/green_ellipse.png new file mode 100644 index 00000000..b2274485 Binary files /dev/null and b/tagstudio/tests/fixtures/search_library/inherit colors shapes/green_ellipse.png differ diff --git a/tagstudio/tests/fixtures/search_library/inherit colors shapes/green_square.png b/tagstudio/tests/fixtures/search_library/inherit colors shapes/green_square.png new file mode 100644 index 00000000..5080b3df Binary files /dev/null and b/tagstudio/tests/fixtures/search_library/inherit colors shapes/green_square.png differ diff --git a/tagstudio/tests/fixtures/search_library/inherit colors shapes/orange.png b/tagstudio/tests/fixtures/search_library/inherit colors shapes/orange.png new file mode 100644 index 00000000..54c038c5 Binary files /dev/null and b/tagstudio/tests/fixtures/search_library/inherit colors shapes/orange.png differ diff --git a/tagstudio/tests/fixtures/search_library/inherit colors shapes/orange_circle.png b/tagstudio/tests/fixtures/search_library/inherit colors shapes/orange_circle.png new file mode 100644 index 00000000..780eddbb Binary files /dev/null and b/tagstudio/tests/fixtures/search_library/inherit colors shapes/orange_circle.png differ diff --git a/tagstudio/tests/fixtures/search_library/inherit colors shapes/orange_ellipse.png b/tagstudio/tests/fixtures/search_library/inherit colors shapes/orange_ellipse.png new file mode 100644 index 00000000..7496518b Binary files /dev/null and b/tagstudio/tests/fixtures/search_library/inherit colors shapes/orange_ellipse.png differ diff --git a/tagstudio/tests/fixtures/search_library/inherit colors shapes/orange_square.png b/tagstudio/tests/fixtures/search_library/inherit colors shapes/orange_square.png new file mode 100644 index 00000000..8929649d Binary files /dev/null and b/tagstudio/tests/fixtures/search_library/inherit colors shapes/orange_square.png differ diff --git a/tagstudio/tests/fixtures/search_library/inherit colors shapes/red.jpg b/tagstudio/tests/fixtures/search_library/inherit colors shapes/red.jpg new file mode 100644 index 00000000..a86fe62c Binary files /dev/null and b/tagstudio/tests/fixtures/search_library/inherit colors shapes/red.jpg differ diff --git a/tagstudio/tests/fixtures/search_library/inherit colors shapes/red_circle.jpg b/tagstudio/tests/fixtures/search_library/inherit colors shapes/red_circle.jpg new file mode 100644 index 00000000..4edd09ce Binary files /dev/null and b/tagstudio/tests/fixtures/search_library/inherit colors shapes/red_circle.jpg differ diff --git a/tagstudio/tests/fixtures/search_library/inherit colors shapes/red_ellipse.png b/tagstudio/tests/fixtures/search_library/inherit colors shapes/red_ellipse.png new file mode 100644 index 00000000..98ec65e8 Binary files /dev/null and b/tagstudio/tests/fixtures/search_library/inherit colors shapes/red_ellipse.png differ diff --git a/tagstudio/tests/fixtures/search_library/inherit colors shapes/red_square.jpg b/tagstudio/tests/fixtures/search_library/inherit colors shapes/red_square.jpg new file mode 100644 index 00000000..c2c6568c Binary files /dev/null and b/tagstudio/tests/fixtures/search_library/inherit colors shapes/red_square.jpg differ diff --git a/tagstudio/tests/fixtures/search_library/inherit colors shapes/shape.png b/tagstudio/tests/fixtures/search_library/inherit colors shapes/shape.png new file mode 100644 index 00000000..be99c250 Binary files /dev/null and b/tagstudio/tests/fixtures/search_library/inherit colors shapes/shape.png differ diff --git a/tagstudio/tests/fixtures/search_library/inherit colors shapes/square.png b/tagstudio/tests/fixtures/search_library/inherit colors shapes/square.png new file mode 100644 index 00000000..dd6014c4 Binary files /dev/null and b/tagstudio/tests/fixtures/search_library/inherit colors shapes/square.png differ diff --git a/tagstudio/tests/fixtures/search_library/inherit colors shapes/yellow.png b/tagstudio/tests/fixtures/search_library/inherit colors shapes/yellow.png new file mode 100644 index 00000000..38f2434e Binary files /dev/null and b/tagstudio/tests/fixtures/search_library/inherit colors shapes/yellow.png differ diff --git a/tagstudio/tests/fixtures/search_library/inherit colors shapes/yellow_circle.png b/tagstudio/tests/fixtures/search_library/inherit colors shapes/yellow_circle.png new file mode 100644 index 00000000..5d4666dd Binary files /dev/null and b/tagstudio/tests/fixtures/search_library/inherit colors shapes/yellow_circle.png differ diff --git a/tagstudio/tests/fixtures/search_library/inherit colors shapes/yellow_ellipse.png b/tagstudio/tests/fixtures/search_library/inherit colors shapes/yellow_ellipse.png new file mode 100644 index 00000000..c1950355 Binary files /dev/null and b/tagstudio/tests/fixtures/search_library/inherit colors shapes/yellow_ellipse.png differ diff --git a/tagstudio/tests/fixtures/search_library/inherit colors shapes/yellow_square.png b/tagstudio/tests/fixtures/search_library/inherit colors shapes/yellow_square.png new file mode 100644 index 00000000..3080357d Binary files /dev/null and b/tagstudio/tests/fixtures/search_library/inherit colors shapes/yellow_square.png differ diff --git a/tagstudio/tests/macros/test_missing_files.py b/tagstudio/tests/macros/test_missing_files.py index e90c0077..213aa18a 100644 --- a/tagstudio/tests/macros/test_missing_files.py +++ b/tagstudio/tests/macros/test_missing_files.py @@ -26,5 +26,5 @@ def test_refresh_missing_files(library: Library): assert list(registry.fix_missing_files()) == [1, 2] # `bar.md` should be relinked to new correct path - results = library.search_library(FilterState(path="bar.md")) + results = library.search_library(FilterState.from_path("bar.md")) assert results[0].path == pathlib.Path("bar.md") diff --git a/tagstudio/tests/macros/test_refresh_dir.py b/tagstudio/tests/macros/test_refresh_dir.py index e0b1e8b6..a4d3e808 100644 --- a/tagstudio/tests/macros/test_refresh_dir.py +++ b/tagstudio/tests/macros/test_refresh_dir.py @@ -2,7 +2,7 @@ import pathlib from tempfile import TemporaryDirectory import pytest -from src.core.constants import LibraryPrefs +from src.core.enums import LibraryPrefs from src.core.utils.refresh_dir import RefreshDirTracker CWD = pathlib.Path(__file__).parent @@ -15,10 +15,11 @@ def test_refresh_new_files(library, exclude_mode): library.set_prefs(LibraryPrefs.IS_EXCLUDE_LIST, exclude_mode) library.set_prefs(LibraryPrefs.EXTENSION_LIST, [".md"]) registry = RefreshDirTracker(library=library) + library.included_files.clear() (library.library_dir / "FOO.MD").touch() # When - assert not list(registry.refresh_dir(library.library_dir)) + assert len(list(registry.refresh_dir(library.library_dir))) == 1 # Then assert registry.files_not_in_library == [pathlib.Path("FOO.MD")] diff --git a/tagstudio/tests/macros/test_sidecar.py b/tagstudio/tests/macros/test_sidecar.py index 700169f4..2bea7ba8 100644 --- a/tagstudio/tests/macros/test_sidecar.py +++ b/tagstudio/tests/macros/test_sidecar.py @@ -12,7 +12,7 @@ def test_sidecar_macro(qt_driver, library, cwd, entry_full): entry_full.path = Path("newgrounds/foo.txt") fixture = cwd / "fixtures/sidecar_newgrounds.json" - dst = library.library_dir / "newgrounds" / (entry_full.path.stem + ".json") + dst = library.library_dir / "newgrounds" / (entry_full.path.name + ".json") dst.parent.mkdir() shutil.copy(fixture, dst) diff --git a/tagstudio/tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.epub-_epub_cover].png b/tagstudio/tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.epub-_epub_cover].png new file mode 100644 index 00000000..2b5a2581 Binary files /dev/null and b/tagstudio/tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.epub-_epub_cover].png differ diff --git a/tagstudio/tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.ods-_open_doc_thumb].png b/tagstudio/tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.ods-_open_doc_thumb].png new file mode 100644 index 00000000..5e749f2d Binary files /dev/null and b/tagstudio/tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.ods-_open_doc_thumb].png differ diff --git a/tagstudio/tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.odt-_open_doc_thumb].png b/tagstudio/tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.odt-_open_doc_thumb].png new file mode 100644 index 00000000..ac2158e7 Binary files /dev/null and b/tagstudio/tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.odt-_open_doc_thumb].png differ diff --git a/tagstudio/tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.pdf-thumbnailer3].png b/tagstudio/tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.pdf-thumbnailer3].png new file mode 100644 index 00000000..0ba9ea61 Binary files /dev/null and b/tagstudio/tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.pdf-thumbnailer3].png differ diff --git a/tagstudio/tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.svg-thumbnailer4].png b/tagstudio/tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.svg-thumbnailer4].png new file mode 100644 index 00000000..ebd90431 Binary files /dev/null and b/tagstudio/tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.svg-thumbnailer4].png differ diff --git a/tagstudio/tests/qt/test_build_tag_panel.py b/tagstudio/tests/qt/test_build_tag_panel.py new file mode 100644 index 00000000..9026b475 --- /dev/null +++ b/tagstudio/tests/qt/test_build_tag_panel.py @@ -0,0 +1,185 @@ +from typing import cast + +from PySide6.QtWidgets import QApplication, QMainWindow +from src.core.library.alchemy.models import Tag +from src.qt.modals.build_tag import BuildTagPanel +from src.qt.widgets.tag import TagAliasWidget + + +def test_build_tag_panel_add_sub_tag_callback(library, generate_tag): + parent = library.add_tag(generate_tag("xxx", id=123)) + child = library.add_tag(generate_tag("xx", id=124)) + assert child + assert parent + + panel: BuildTagPanel = BuildTagPanel(library, child) + + panel.add_subtag_callback(parent.id) + + assert len(panel.subtag_ids) == 1 + + +def test_build_tag_panel_remove_subtag_callback(library, generate_tag): + parent = library.add_tag(generate_tag("xxx", id=123)) + child = library.add_tag(generate_tag("xx", id=124)) + assert child + assert parent + + library.update_tag(child, {parent.id}, [], []) + + child = library.get_tag(child.id) + + assert child + + panel: BuildTagPanel = BuildTagPanel(library, child) + + panel.remove_subtag_callback(parent.id) + + assert len(panel.subtag_ids) == 0 + + +import os + +os.environ["QT_QPA_PLATFORM"] = "offscreen" + + +def test_build_tag_panel_remove_selected_alias(library, generate_tag): + app = QApplication.instance() or QApplication([]) + + window = QMainWindow() + parent_tag = library.add_tag(generate_tag("xxx", id=123)) + panel = BuildTagPanel(library, parent_tag) + panel.setParent(window) + + panel.add_alias_callback() + window.show() + + assert panel.aliases_flow_layout.count() == 2 + + alias_widget = panel.aliases_flow_layout.itemAt(0).widget() + alias_widget.text_field.setFocus() + + app.processEvents() + + panel.remove_selected_alias() + + assert panel.aliases_flow_layout.count() == 1 + + +def test_build_tag_panel_add_alias_callback(library, generate_tag): + tag = library.add_tag(generate_tag("xxx", id=123)) + assert tag + + panel: BuildTagPanel = BuildTagPanel(library, tag) + + panel.add_alias_callback() + + assert panel.aliases_flow_layout.count() == 2 + + +def test_build_tag_panel_remove_alias_callback(library, generate_tag): + tag = library.add_tag(generate_tag("xxx", id=123)) + assert tag + + library.update_tag(tag, [], {"alias", "alias_2"}, {123, 124}) + + tag = library.get_tag(tag.id) + + assert "alias" in tag.alias_strings + assert "alias_2" in tag.alias_strings + + panel: BuildTagPanel = BuildTagPanel(library, tag) + + alias = library.get_alias(tag.id, tag.alias_ids[0]) + + panel.remove_alias_callback(alias.name, alias.id) + + assert len(panel.alias_ids) == 1 + assert len(panel.alias_names) == 1 + assert alias.name not in panel.alias_names + + +def test_build_tag_panel_set_subtags(library, generate_tag): + parent = library.add_tag(generate_tag("parent", id=123)) + child = library.add_tag(generate_tag("child", id=124)) + assert parent + assert child + + library.add_subtag(child.id, parent.id) + + child = library.get_tag(child.id) + + panel: BuildTagPanel = BuildTagPanel(library, child) + + assert len(panel.subtag_ids) == 1 + assert panel.subtag_flow_layout.count() == 2 + + +def test_build_tag_panel_add_aliases(library, generate_tag): + tag = library.add_tag(generate_tag("xxx", id=123)) + assert tag + + library.update_tag(tag, [], {"alias", "alias_2"}, {123, 124}) + + tag = library.get_tag(tag.id) + + assert "alias" in tag.alias_strings + assert "alias_2" in tag.alias_strings + + panel: BuildTagPanel = BuildTagPanel(library, tag) + + widget = panel.aliases_flow_layout.itemAt(0).widget() + + alias_names: set[str] = set() + alias_names.add(cast(TagAliasWidget, widget).text_field.text()) + + widget = panel.aliases_flow_layout.itemAt(1).widget() + alias_names.add(cast(TagAliasWidget, widget).text_field.text()) + + assert "alias" in alias_names + assert "alias_2" in alias_names + + old_text = cast(TagAliasWidget, widget).text_field.text() + cast(TagAliasWidget, widget).text_field.setText("alias_update") + + panel.add_aliases() + + assert old_text not in panel.alias_names + assert "alias_update" in panel.alias_names + assert len(panel.alias_names) == 2 + + +def test_build_tag_panel_set_aliases(library, generate_tag): + tag = library.add_tag(generate_tag("xxx", id=123)) + assert tag + + library.update_tag(tag, [], {"alias"}, {123}) + + tag = library.get_tag(tag.id) + + assert len(tag.alias_ids) == 1 + + panel: BuildTagPanel = BuildTagPanel(library, tag) + + assert panel.aliases_flow_layout.count() == 2 + assert len(panel.alias_names) == 1 + assert len(panel.alias_ids) == 1 + + +def test_build_tag_panel_set_tag(library, generate_tag): + tag = library.add_tag(generate_tag("xxx", id=123)) + assert tag + + panel: BuildTagPanel = BuildTagPanel(library, tag) + + assert panel.tag + assert panel.tag.name == "xxx" + + +def test_build_tag_panel_build_tag(library): + panel: BuildTagPanel = BuildTagPanel(library) + + tag: Tag = panel.build_tag() + + assert tag + assert tag.name == "New Tag" diff --git a/tagstudio/tests/qt/test_preview_panel.py b/tagstudio/tests/qt/test_preview_panel.py index 3b612e32..f8550b86 100644 --- a/tagstudio/tests/qt/test_preview_panel.py +++ b/tagstudio/tests/qt/test_preview_panel.py @@ -16,7 +16,7 @@ def test_update_widgets_not_selected(qt_driver, library): panel.update_widgets() assert panel.preview_img.isVisible() - assert panel.file_label.text() == "No Items Selected" + assert panel.file_label.text() == "No Items Selected" @pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) diff --git a/tagstudio/tests/qt/test_driver.py b/tagstudio/tests/qt/test_qt_driver.py similarity index 96% rename from tagstudio/tests/qt/test_driver.py rename to tagstudio/tests/qt/test_qt_driver.py index e032b3fb..a8a484d1 100644 --- a/tagstudio/tests/qt/test_driver.py +++ b/tagstudio/tests/qt/test_qt_driver.py @@ -79,7 +79,7 @@ def test_library_state_update(qt_driver): assert len(qt_driver.frame_content) == 2 # filter by tag - state = FilterState(tag="foo", page_size=10) + state = FilterState.from_tag_name("foo").with_page_size(10) qt_driver.filter_items(state) assert qt_driver.filter.page_size == 10 assert len(qt_driver.frame_content) == 1 @@ -94,7 +94,7 @@ def test_library_state_update(qt_driver): assert list(entry.tags)[0].name == "foo" # When state property is changed, previous one is overwritten - state = FilterState(path="bar.md") + state = FilterState.from_path("*bar.md") qt_driver.filter_items(state) assert len(qt_driver.frame_content) == 1 entry = qt_driver.frame_content[0] diff --git a/tagstudio/tests/qt/test_tag_widget.py b/tagstudio/tests/qt/test_tag_widget.py index 9d10691a..86158bf4 100644 --- a/tagstudio/tests/qt/test_tag_widget.py +++ b/tagstudio/tests/qt/test_tag_widget.py @@ -84,7 +84,9 @@ def test_tag_widget_remove(qtbot, qt_driver, library, entry_full): def test_tag_widget_edit(qtbot, qt_driver, library, entry_full): # Given - tag = list(entry_full.tags)[0] + entry = next(library.get_entries(with_joins=True)) + library.add_tag(list(entry.tags)[0]) + tag = library.get_tag(list(entry.tags)[0].id) assert tag assert entry_full.tag_box_fields @@ -99,9 +101,7 @@ def test_tag_widget_edit(qtbot, qt_driver, library, entry_full): assert isinstance(tag_widget, TagWidget) # When - actions = tag_widget.bg_button.actions() - edit_action = [a for a in actions if a.text() == "Edit"][0] - edit_action.triggered.emit() + tag_box_widget.edit_tag(tag) # Then panel = tag_box_widget.edit_modal.widget diff --git a/tagstudio/tests/qt/test_thumb_renderer.py b/tagstudio/tests/qt/test_thumb_renderer.py new file mode 100644 index 00000000..8290ff05 --- /dev/null +++ b/tagstudio/tests/qt/test_thumb_renderer.py @@ -0,0 +1,48 @@ +# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +import io +from functools import partial +from pathlib import Path + +import pytest +from PIL import Image +from src.qt.widgets.thumb_renderer import ThumbRenderer +from syrupy.extensions.image import PNGImageSnapshotExtension + + +@pytest.mark.parametrize( + ["fixture_file", "thumbnailer"], + [ + ( + "sample.odt", + ThumbRenderer._open_doc_thumb, + ), + ( + "sample.ods", + ThumbRenderer._open_doc_thumb, + ), + ( + "sample.epub", + ThumbRenderer._epub_cover, + ), + ( + "sample.pdf", + partial(ThumbRenderer._pdf_thumb, size=200), + ), + ( + "sample.svg", + partial(ThumbRenderer._image_vector_thumb, size=200), + ), + ], +) +def test_preview_render(cwd, fixture_file, thumbnailer, snapshot): + file_path: Path = cwd / "fixtures" / fixture_file + img: Image.Image = thumbnailer(file_path) + + img_bytes = io.BytesIO() + img.save(img_bytes, format="PNG") + img_bytes.seek(0) + + assert img_bytes.read() == snapshot(extension_class=PNGImageSnapshotExtension) diff --git a/tagstudio/tests/test_driver.py b/tagstudio/tests/test_driver.py new file mode 100644 index 00000000..240406f9 --- /dev/null +++ b/tagstudio/tests/test_driver.py @@ -0,0 +1,69 @@ +from os import makedirs +from pathlib import Path +from tempfile import TemporaryDirectory + +from PySide6.QtCore import QSettings +from src.core.constants import TS_FOLDER_NAME +from src.core.driver import DriverMixin +from src.core.enums import SettingItems +from src.core.library.alchemy.library import LibraryStatus + + +class TestDriver(DriverMixin): + def __init__(self, settings): + self.settings = settings + + +def test_evaluate_path_empty(): + # Given + settings = QSettings() + driver = TestDriver(settings) + + # When + result = driver.evaluate_path(None) + + # Then + assert result == LibraryStatus(success=True) + + +def test_evaluate_path_missing(): + # Given + settings = QSettings() + driver = TestDriver(settings) + + # When + result = driver.evaluate_path("/0/4/5/1/") + + # Then + assert result == LibraryStatus(success=False, message="Path does not exist.") + + +def test_evaluate_path_last_lib_not_exists(): + # Given + settings = QSettings() + settings.setValue(SettingItems.LAST_LIBRARY, "/0/4/5/1/") + driver = TestDriver(settings) + + # When + result = driver.evaluate_path(None) + + # Then + assert result == LibraryStatus(success=True, library_path=None, message=None) + + +def test_evaluate_path_last_lib_present(): + # Given + with TemporaryDirectory() as tmpdir: + settings_file = tmpdir + "/test_settings.ini" + settings = QSettings(settings_file, QSettings.Format.IniFormat) + settings.setValue(SettingItems.LAST_LIBRARY, tmpdir) + settings.sync() + + makedirs(Path(tmpdir) / TS_FOLDER_NAME) + driver = TestDriver(settings) + + # When + result = driver.evaluate_path(None) + + # Then + assert result == LibraryStatus(success=True, library_path=Path(tmpdir)) diff --git a/tagstudio/tests/test_filter_state.py b/tagstudio/tests/test_filter_state.py deleted file mode 100644 index f97f5f32..00000000 --- a/tagstudio/tests/test_filter_state.py +++ /dev/null @@ -1,36 +0,0 @@ -import pytest -from src.core.library.alchemy.enums import FilterState - - -def test_filter_state_query(): - # Given - query = "tag:foo" - state = FilterState(query=query) - - # When - assert state.tag == "foo" - - -@pytest.mark.parametrize( - ["attribute", "comparator"], - [ - ("tag", str), - ("tag_id", int), - ("path", str), - ("name", str), - ("id", int), - ], -) -def test_filter_state_attrs_compare(attribute, comparator): - # When - state = FilterState(**{attribute: "2"}) - - # Then - # compare the attribute value - assert getattr(state, attribute) == comparator("2") - - # Then - for prop in ("tag", "tag_id", "path", "name", "id"): - if prop == attribute: - continue - assert not getattr(state, prop) diff --git a/tagstudio/tests/test_json_migration.py b/tagstudio/tests/test_json_migration.py new file mode 100644 index 00000000..c8ad58e6 --- /dev/null +++ b/tagstudio/tests/test_json_migration.py @@ -0,0 +1,49 @@ +# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +import pathlib +from time import time + +from src.core.enums import LibraryPrefs +from src.qt.widgets.migration_modal import JsonMigrationModal + +CWD = pathlib.Path(__file__) + + +def test_json_migration(): + modal = JsonMigrationModal(CWD.parent / "fixtures" / "json_library") + modal.migrate(skip_ui=True) + + start = time() + while not modal.done and (time() - start < 60): + pass + + # Entries ================================================================== + # Count + assert len(modal.json_lib.entries) == modal.sql_lib.entries_count + # Path Parity + assert modal.check_path_parity() + # Field Parity + assert modal.check_field_parity() + + # Tags ===================================================================== + # Count + assert len(modal.json_lib.tags) == len(modal.sql_lib.tags) + # Shorthand Parity + assert modal.check_shorthand_parity() + # Subtag/Parent Tag Parity + assert modal.check_subtag_parity() + # Alias Parity + assert modal.check_alias_parity() + # Color Parity + assert modal.check_color_parity() + + # Extension Filter List ==================================================== + # Count + assert len(modal.json_lib.ext_list) == len(modal.sql_lib.prefs(LibraryPrefs.EXTENSION_LIST)) + # List Type + assert modal.check_ext_type() + # No Leading Dot + for ext in modal.sql_lib.prefs(LibraryPrefs.EXTENSION_LIST): + assert ext[0] != "." diff --git a/tagstudio/tests/test_library.py b/tagstudio/tests/test_library.py index b61bef81..82f9522e 100644 --- a/tagstudio/tests/test_library.py +++ b/tagstudio/tests/test_library.py @@ -1,46 +1,91 @@ -from pathlib import Path, PureWindowsPath +from pathlib import Path from tempfile import TemporaryDirectory import pytest -from src.core.constants import LibraryPrefs +from src.core.enums import DefaultEnum, LibraryPrefs from src.core.library.alchemy import Entry, Library from src.core.library.alchemy.enums import FilterState from src.core.library.alchemy.fields import TextField, _FieldID +from src.core.library.alchemy.models import Tag -def test_library_bootstrap(): - with TemporaryDirectory() as tmp_dir: - lib = Library() - lib.open_library(tmp_dir) - assert lib.engine +def test_library_add_alias(library, generate_tag): + tag = library.add_tag(generate_tag("xxx", id=123)) + assert tag + + subtag_ids: set[int] = set() + alias_ids: set[int] = set() + alias_names: set[str] = set() + alias_names.add("test_alias") + library.update_tag(tag, subtag_ids, alias_names, alias_ids) + + # Note: ask if it is expected behaviour that you need to re-request + # for the tag. Or if the tag in memory should be updated + alias_ids = library.get_tag(tag.id).alias_ids + + assert len(alias_ids) == 1 -def test_library_add_file(): +def test_library_get_alias(library, generate_tag): + tag = library.add_tag(generate_tag("xxx", id=123)) + assert tag + + subtag_ids: set[int] = set() + alias_ids: set[int] = set() + alias_names: set[str] = set() + alias_names.add("test_alias") + library.update_tag(tag, subtag_ids, alias_names, alias_ids) + + alias_ids = library.get_tag(tag.id).alias_ids + + assert library.get_alias(tag.id, alias_ids[0]).name == "test_alias" + + +def test_library_update_alias(library, generate_tag): + tag: Tag = library.add_tag(generate_tag("xxx", id=123)) + assert tag + + subtag_ids: set[int] = set() + alias_ids: set[int] = set() + alias_names: set[str] = set() + alias_names.add("test_alias") + library.update_tag(tag, subtag_ids, alias_names, alias_ids) + + tag = library.get_tag(tag.id) + alias_ids = tag.alias_ids + + assert library.get_alias(tag.id, alias_ids[0]).name == "test_alias" + + alias_names.remove("test_alias") + alias_names.add("alias_update") + library.update_tag(tag, subtag_ids, alias_names, alias_ids) + + tag = library.get_tag(tag.id) + + assert len(tag.alias_ids) == 1 + assert library.get_alias(tag.id, tag.alias_ids[0]).name == "alias_update" + + +@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +def test_library_add_file(library): """Check Entry.path handling for insert vs lookup""" - with TemporaryDirectory() as tmp_dir: - # create file in tmp_dir - file_path = Path(tmp_dir) / "bar.txt" - file_path.write_text("bar") - lib = Library() - lib.open_library(tmp_dir) + entry = Entry( + path=Path("bar.txt"), + folder=library.folder, + fields=library.default_fields, + ) - entry = Entry( - path=file_path, - folder=lib.folder, - fields=lib.default_fields, - ) + assert not library.has_path_entry(entry.path) - assert not lib.has_path_entry(entry.path) + assert library.add_entries([entry]) - assert lib.add_entries([entry]) - - assert lib.has_path_entry(entry.path) is True + assert library.has_path_entry(entry.path) def test_create_tag(library, generate_tag): # tag already exists - assert not library.add_tag(generate_tag("foo")) + assert not library.add_tag(generate_tag("foo", id=1000)) # new tag name tag = library.add_tag(generate_tag("xxx", id=123)) @@ -51,14 +96,26 @@ def test_create_tag(library, generate_tag): assert tag_inc.id > 1000 +def test_tag_subtag_itself(library, generate_tag): + # tag already exists + assert not library.add_tag(generate_tag("foo", id=1000)) + + # new tag name + tag = library.add_tag(generate_tag("xxx", id=123)) + assert tag + assert tag.id == 123 + + library.update_tag(tag, {tag.id}, {}, {}) + tag = library.get_tag(tag.id) + assert len(tag.subtag_ids) == 0 + + def test_library_search(library, generate_tag, entry_full): assert library.entries_count == 2 tag = list(entry_full.tags)[0] results = library.search_library( - FilterState( - tag=tag.name, - ), + FilterState.from_tag_name(tag.name), ) assert results.total_count == 1 @@ -75,36 +132,28 @@ def test_library_search(library, generate_tag, entry_full): def test_tag_search(library): tag = library.tags[0] - assert library.search_tags( - FilterState(tag=tag.name.lower()), - ) + assert library.search_tags(tag.name.lower()) - assert library.search_tags( - FilterState(tag=tag.name.upper()), - ) + assert library.search_tags(tag.name.upper()) - assert library.search_tags(FilterState(tag=tag.name[2:-2])) + assert library.search_tags(tag.name[2:-2]) - assert not library.search_tags( - FilterState(tag=tag.name * 2), - ) + assert not library.search_tags(tag.name * 2) -def test_get_entry(library, entry_min): +def test_get_entry(library: Library, entry_min): assert entry_min.id - results = library.search_library(FilterState(id=entry_min.id)) - assert len(results) == results.total_count == 1 - assert results[0].tags + result = library.get_entry_full(entry_min.id) + assert result + assert result.tags def test_entries_count(library): entries = [Entry(path=Path(f"{x}.txt"), folder=library.folder, fields=[]) for x in range(10)] - library.add_entries(entries) - results = library.search_library( - FilterState( - page_size=5, - ) - ) + new_ids = library.add_entries(entries) + assert len(new_ids) == 10 + + results = library.search_library(FilterState.show_all().with_page_size(5)) assert results.total_count == 12 assert len(results) == 5 @@ -120,7 +169,7 @@ def test_add_field_to_entry(library): # meta tags + content tags assert len(entry.tag_box_fields) == 2 - library.add_entries([entry]) + assert library.add_entries([entry]) # When library.add_entry_field_type(entry.id, field_id=_FieldID.TAGS) @@ -131,7 +180,7 @@ def test_add_field_to_entry(library): assert len(entry.tag_box_fields) == 3 -def test_add_field_tag(library, entry_full, generate_tag): +def test_add_field_tag(library: Library, entry_full, generate_tag): # Given tag_name = "xxx" tag = generate_tag(tag_name) @@ -141,8 +190,8 @@ def test_add_field_tag(library, entry_full, generate_tag): library.add_field_tag(entry_full, tag, tag_field.type_key) # Then - results = library.search_library(FilterState(id=entry_full.id)) - tag_field = results[0].tag_box_fields[0] + result = library.get_entry_full(entry_full.id) + tag_field = result.tag_box_fields[0] assert [x.name for x in tag_field.tags if x.name == tag_name] @@ -164,6 +213,17 @@ def test_subtags_add(library, generate_tag): assert tag.subtag_ids +def test_remove_tag(library, generate_tag): + tag = library.add_tag(generate_tag("food", id=123)) + + assert tag + + tag_count = len(library.tags) + + library.remove_tag(tag) + assert len(library.tags) == tag_count - 1 + + @pytest.mark.parametrize("is_exclude", [True, False]) def test_search_filter_extensions(library, is_exclude): # Given @@ -175,7 +235,7 @@ def test_search_filter_extensions(library, is_exclude): # When results = library.search_library( - FilterState(), + FilterState.show_all(), ) # Then @@ -196,7 +256,7 @@ def test_search_library_case_insensitive(library): # When results = library.search_library( - FilterState(tag=tag.name.upper()), + FilterState.from_tag_name(tag.name.upper()), ) # Then @@ -208,29 +268,7 @@ def test_search_library_case_insensitive(library): def test_preferences(library): for pref in LibraryPrefs: - assert library.prefs(pref) == pref.value - - -def test_save_windows_path(library, generate_tag): - # pretend we are on windows and create `Path` - - entry = Entry( - path=PureWindowsPath("foo\\bar.txt"), - folder=library.folder, - fields=library.default_fields, - ) - tag = generate_tag("win_path") - tag_name = tag.name - - library.add_entries([entry]) - # library.add_tag(tag) - library.add_field_tag(entry, tag, create_field=True) - - results = library.search_library(FilterState(tag=tag_name)) - assert results - - # path should be saved in posix format - assert str(results[0].path) == "foo/bar.txt" + assert library.prefs(pref) == pref.default def test_remove_entry_field(library, entry_full): @@ -292,7 +330,8 @@ def test_update_entry_with_multiple_identical_fields(library, entry_full): assert entry.text_fields[1].value == "new value" -def test_mirror_entry_fields(library, entry_full): +def test_mirror_entry_fields(library: Library, entry_full): + # new entry target_entry = Entry( folder=library.folder, path=Path("xxx"), @@ -305,16 +344,19 @@ def test_mirror_entry_fields(library, entry_full): ], ) + # insert new entry and get id entry_id = library.add_entries([target_entry])[0] - results = library.search_library(FilterState(id=entry_id)) - new_entry = results[0] + # get new entry from library + new_entry = library.get_entry_full(entry_id) + # mirror fields onto new entry library.mirror_entry_fields(new_entry, entry_full) - results = library.search_library(FilterState(id=entry_id)) - entry = results[0] + # get new entry from library again + entry = library.get_entry_full(entry_id) + # make sure fields are there after getting it from the library again assert len(entry.fields) == 4 assert {x.type_key for x in entry.fields} == { _FieldID.TITLE.name, @@ -336,22 +378,6 @@ def test_remove_tag_from_field(library, entry_full): assert removed_tag not in [tag.name for tag in field.tags] -@pytest.mark.parametrize( - ["query_name", "has_result"], - [ - ("foo", 1), # filename substring - ("bar", 1), # filename substring - ("one", 0), # path, should not match - ], -) -def test_search_file_name(library, query_name, has_result): - results = library.search_library( - FilterState(name=query_name), - ) - - assert results.total_count == has_result - - @pytest.mark.parametrize( ["query_name", "has_result"], [ @@ -361,12 +387,10 @@ def test_search_file_name(library, query_name, has_result): (222, 0), ], ) -def test_search_entry_id(library, query_name, has_result): - results = library.search_library( - FilterState(id=query_name), - ) +def test_search_entry_id(library: Library, query_name: int, has_result): + result = library.get_entry(query_name) - assert results.total_count == has_result + assert (result is not None) == has_result def test_update_field_order(library, entry_full): @@ -394,3 +418,57 @@ def test_update_field_order(library, entry_full): assert entry.text_fields[0].value == "first" assert entry.text_fields[1].position == 1 assert entry.text_fields[1].value == "second" + + +def test_library_prefs_multiple_identical_vals(): + # check the preferences are inherited from DefaultEnum + assert issubclass(LibraryPrefs, DefaultEnum) + + # create custom settings with identical values + class TestPrefs(DefaultEnum): + FOO = 1 + BAR = 1 + + assert TestPrefs.FOO.default == 1 + assert TestPrefs.BAR.default == 1 + assert TestPrefs.BAR.name == "BAR" + + # accessing .value should raise exception + with pytest.raises(AttributeError): + assert TestPrefs.BAR.value + + +def test_path_search_glob_after(library: Library): + results = library.search_library(FilterState.from_path("foo*")) + assert results.total_count == 1 + assert len(results.items) == 1 + + +def test_path_search_glob_in_front(library: Library): + results = library.search_library(FilterState.from_path("*bar.md")) + assert results.total_count == 1 + assert len(results.items) == 1 + + +def test_path_search_glob_both_sides(library: Library): + results = library.search_library(FilterState.from_path("*one/two*")) + assert results.total_count == 1 + assert len(results.items) == 1 + + +@pytest.mark.parametrize(["filetype", "num_of_filetype"], [("md", 1), ("txt", 1), ("png", 0)]) +def test_filetype_search(library, filetype, num_of_filetype): + results = library.search_library(FilterState.from_filetype(filetype)) + assert len(results.items) == num_of_filetype + + +@pytest.mark.parametrize(["filetype", "num_of_filetype"], [("png", 2), ("apng", 1), ("ng", 0)]) +def test_filetype_return_one_filetype(file_mediatypes_library, filetype, num_of_filetype): + results = file_mediatypes_library.search_library(FilterState.from_filetype(filetype)) + assert len(results.items) == num_of_filetype + + +@pytest.mark.parametrize(["mediatype", "num_of_mediatype"], [("plaintext", 2), ("image", 0)]) +def test_mediatype_search(library, mediatype, num_of_mediatype): + results = library.search_library(FilterState.from_mediatype(mediatype)) + assert len(results.items) == num_of_mediatype diff --git a/tagstudio/tests/test_search.py b/tagstudio/tests/test_search.py new file mode 100644 index 00000000..7d2e69db --- /dev/null +++ b/tagstudio/tests/test_search.py @@ -0,0 +1,124 @@ +import pytest +from src.core.library.alchemy.enums import FilterState +from src.core.library.alchemy.library import Library +from src.core.query_lang.util import ParsingError + + +def verify_count(lib: Library, query: str, count: int): + results = lib.search_library(FilterState.from_search_query(query)) + assert results.total_count == count + assert len(results.items) == count + + +@pytest.mark.parametrize( + ["query", "count"], + [ + ("", 29), + ("path:*", 29), + ("path:*inherit*", 24), + ("path:*comp*", 5), + ("special:untagged", 1), + ("filetype:png", 23), + ("filetype:jpg", 6), + ("filetype:'jpg'", 6), + ("tag_id:1011", 5), + ("tag_id:1038", 11), + ("doesnt exist", 0), + ("archived", 0), + ("favorite", 0), + ("tag:favorite", 0), + ("circle", 11), + ("tag:square", 11), + ("green", 5), + ("orange", 5), + ("tag:orange", 5), + ], +) +def test_single_constraint(search_library: Library, query: str, count: int): + verify_count(search_library, query, count) + + +@pytest.mark.parametrize( + ["query", "count"], + [ + ("circle aND square", 5), + ("circle square", 5), + ("green AND square", 2), + ("green square", 2), + ("orange AnD square", 2), + ("orange square", 2), + ("orange and filetype:png", 5), + ("square and filetype:jpg", 2), + ("orange filetype:png", 5), + ("green path:*inherit*", 4), + ], +) +def test_and(search_library: Library, query: str, count: int): + verify_count(search_library, query, count) + + +@pytest.mark.parametrize( + ["query", "count"], + [ + ("square or circle", 17), + ("orange or green", 10), + ("orange Or circle", 14), + ("orange oR square", 14), + ("square OR green", 14), + ("circle or green", 14), + ("green or circle", 14), + ("filetype:jpg or tag:orange", 11), + ("red or filetype:png", 25), + ("filetype:jpg or path:*comp*", 11), + ], +) +def test_or(search_library: Library, query: str, count: int): + verify_count(search_library, query, count) + + +@pytest.mark.parametrize( + ["query", "count"], + [ + ("not unexistant", 29), + ("not path:*", 0), + ("not not path:*", 29), + ("not special:untagged", 28), + ("not filetype:png", 6), + ("not filetype:jpg", 23), + ("not tag_id:1011", 24), + ("not tag_id:1038", 18), + ("not green", 24), + ("tag:favorite", 0), + ("not circle", 18), + ("not tag:square", 18), + ("circle and not square", 6), + ("not circle and square", 6), + ("special:untagged or not filetype:jpg", 24), + ("not square or green", 20), + ], +) +def test_not(search_library: Library, query: str, count: int): + verify_count(search_library, query, count) + + +@pytest.mark.parametrize( + ["query", "count"], + [ + ("(tag_id:1041)", 11), + ("(((tag_id:1041)))", 11), + ("not (not tag_id:1041)", 11), + ("((circle) and (not square))", 6), + ("(not ((square) OR (green)))", 15), + ("filetype:png and (tag:square or green)", 12), + ], +) +def test_parentheses(search_library: Library, query: str, count: int): + verify_count(search_library, query, count) + + +@pytest.mark.parametrize( + "invalid_query", ["asd AND", "asd AND AND", "tag:(", "(asd", "asd[]", "asd]", ":", "tag: :"] +) +def test_syntax(search_library: Library, invalid_query: str): + with pytest.raises(ParsingError) as e_info: # noqa: F841 + search_library.search_library(FilterState.from_search_query(invalid_query))