Compare commits
83 Commits
Alpha-v9.3
...
v9.4.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fc0dd03aa | ||
|
|
90b9af48e3 | ||
|
|
b2dbc5722b | ||
|
|
6490cc905d | ||
|
|
dfa4079b23 | ||
|
|
6ff7303321 | ||
|
|
4d405b5d77 | ||
|
|
bf8816f715 | ||
|
|
8c9b04d1ec | ||
|
|
5995e4d416 | ||
|
|
fc714e02e6 | ||
|
|
85b6d9decc | ||
|
|
3e2fb1282c | ||
|
|
9a78bf3066 | ||
|
|
1c53f05e4f | ||
|
|
1e4883c577 | ||
|
|
3e950a0cbe | ||
|
|
ca08ce57b6 | ||
|
|
7f3b3d06af | ||
|
|
3d427997ea | ||
|
|
65237ed106 | ||
|
|
cb12956309 | ||
|
|
cc2e9f97c4 | ||
|
|
81ddf5366c | ||
|
|
8219ffc416 | ||
|
|
85d62e6519 | ||
|
|
341aa92ecb | ||
|
|
dcb8ded987 | ||
|
|
569390ea72 | ||
|
|
4590226bca | ||
|
|
d8ef54392b | ||
|
|
41087b14c6 | ||
|
|
38626ac690 | ||
|
|
bc38e568fa | ||
|
|
4b35df0924 | ||
|
|
750cbf0f9c | ||
|
|
5ac40f5b11 | ||
|
|
fda6077b20 | ||
|
|
cb4798b715 | ||
|
|
c51f9869b2 | ||
|
|
e1b1ef9888 | ||
|
|
3fcf6022b9 | ||
|
|
938832505b | ||
|
|
b107fb5809 | ||
|
|
ec960f2372 | ||
|
|
30b60a0d31 | ||
|
|
ce87b11fbd | ||
|
|
e463635cc0 | ||
|
|
aa0aad4300 | ||
|
|
bebca634de | ||
|
|
92a6e1130c | ||
|
|
c79086f715 | ||
|
|
9ce07bd369 | ||
|
|
33ee27a84f | ||
|
|
883354b263 | ||
|
|
6862f89d1a | ||
|
|
1204d2b7b5 | ||
|
|
15ee13c7b4 | ||
|
|
49ad8117ef | ||
|
|
4f193613de | ||
|
|
31038711f2 | ||
|
|
cc827108ef | ||
|
|
9fec4822c1 | ||
|
|
501ab1f977 | ||
|
|
b3c01e180a | ||
|
|
4c6ebec529 | ||
|
|
5c25666e67 | ||
|
|
fae65bd9e9 | ||
|
|
8e065ca8ac | ||
|
|
888b674f05 | ||
|
|
82946cb0b8 | ||
|
|
aa2925cde0 | ||
|
|
e5a0e5aa9b | ||
|
|
5bc80a043f | ||
|
|
65d88b9987 | ||
|
|
37ff35fcf6 | ||
|
|
9b13e338bb | ||
|
|
a47b0adb6e | ||
|
|
9f39bf6fdc | ||
|
|
e375166bfe | ||
|
|
7054ffd227 | ||
|
|
6d283d1f2d | ||
|
|
a0baf015db |
16
.envrc.recommended
Normal file
@@ -0,0 +1,16 @@
|
||||
# vi: ft=bash
|
||||
#
|
||||
# If you wish to use this file, copy or symlink it to `.envrc` for direnv to read it.
|
||||
# This will use the flake development shell.
|
||||
|
||||
if ! has nix_direnv_version || ! nix_direnv_version 3.0.5; then
|
||||
source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.0.5/direnvrc" "sha256-RuwIS+QKFj/T9M2TFXScjBsLR6V3A17YVoEW/Q6AZ1w="
|
||||
fi
|
||||
|
||||
devenv_root_file="$(mktemp -t devenv-root-XXXXXXXX)"
|
||||
printf %s "${PWD}" >"${devenv_root_file}"
|
||||
if ! use flake . --override-input devenv-root "file+file://${devenv_root_file}"; then
|
||||
printf '%s\n' "devenv could not be built. The devenv environment was not loaded. Make the necessary changes to devenv.nix and hit enter to try again." >&2
|
||||
fi
|
||||
rm "${devenv_root_file}"
|
||||
unset devenv_root_file
|
||||
64
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
name: Bug Report
|
||||
description: File a bug or issue report.
|
||||
title: '[Bug]: '
|
||||
labels: ['Type: Bug']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
*Please add an appropriate title for this issue.*
|
||||
|
||||
Before reporting, read the [documentation](https://github.com/TagStudioDev/TagStudio/blob/main/doc/index.md) and search existing [issues](https://github.com/TagStudioDev/TagStudio/issues).
|
||||
Validate that you are using an up-to-date version[^1], your issue might already be fixed!
|
||||
Questions, guidance, and usage goes in [discussions](https://github.com/TagStudioDev/TagStudio/discussions). Invalid issues will be closed.
|
||||
|
||||
[^1]: This can mean latest release, pre-release, or git commit.
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Checklist
|
||||
description: Make sure you've checked (and actually did) all of the below.
|
||||
options:
|
||||
- label: I am using an up-to-date version.
|
||||
required: true
|
||||
- label: I have read the [documentation](https://github.com/TagStudioDev/TagStudio/blob/main/doc/index.md).
|
||||
required: true
|
||||
- label: I have searched existing [issues](https://github.com/TagStudioDev/TagStudio/issues).
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: TagStudio Version
|
||||
placeholder: Alpha 9.1.0
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Operating System & Version
|
||||
placeholder: TagOS 3.14
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Description
|
||||
description: Clear and concise description of the problem. Attach screenshots if needed, and include any errors you see.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: Clear and concise description of what you think should happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: Minimal steps neded for the problem to occur.
|
||||
placeholder: |
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Logs
|
||||
description: Provide any logs, if applicable.
|
||||
40
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature.
|
||||
title: '[Feature Request]: '
|
||||
labels: ['Type: Enhancement']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
*Please add an appropriate title for this feature request.*
|
||||
|
||||
Before suggesting, read the [documentation](https://github.com/TagStudioDev/TagStudio/blob/main/doc/index.md) and search existing [issues](https://github.com/TagStudioDev/TagStudio/issues).
|
||||
Validate that you are using an up-to-date version[^1], your feature might already be implemented!
|
||||
Questions, guidance, and usage goes in [discussions](https://github.com/TagStudioDev/TagStudio/discussions). Invalid issues will be closed.
|
||||
|
||||
[^1]: This can mean latest release, pre-release, or git commit.
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Checklist
|
||||
description: Make sure you've checked (and actually did) all of the below.
|
||||
options:
|
||||
- label: I am using an up-to-date version.
|
||||
required: true
|
||||
- label: I have read the [documentation](https://github.com/TagStudioDev/TagStudio/blob/main/doc/index.md).
|
||||
required: true
|
||||
- label: I have searched existing [issues](https://github.com/TagStudioDev/TagStudio/issues).
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Description
|
||||
description: Clear and concise description of the problem or missing capability. Attach screenshots if needed, and explain your own use case.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Solution
|
||||
description: Clear and concise description of what you think should happen.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Alternatives
|
||||
description: Any considered alternative solutions or workarounds. If undesirable, why?
|
||||
3
.github/workflows/apprun.yaml
vendored
@@ -33,7 +33,8 @@ jobs:
|
||||
libxcb-xinerama0 \
|
||||
libopengl0 \
|
||||
libxcb-cursor0 \
|
||||
libpulse0
|
||||
libpulse0 \
|
||||
ffmpeg
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
|
||||
2
.github/workflows/mypy.yaml
vendored
@@ -23,8 +23,6 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
# pyside 6.6.3 has some issue in their .pyi files
|
||||
pip install PySide6==6.6.2
|
||||
pip install -r requirements.txt
|
||||
pip install mypy==1.10.0
|
||||
mkdir tagstudio/.mypy_cache
|
||||
|
||||
22
.github/workflows/pytest.yaml
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
name: pytest
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
pytest:
|
||||
name: Run tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
pytest tagstudio/tests/
|
||||
5
.gitignore
vendored
@@ -55,6 +55,7 @@ coverage.xml
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
tagstudio/tests/fixtures/library/*
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
@@ -252,3 +253,7 @@ compile_commands.json
|
||||
.TagStudio
|
||||
TagStudio.ini
|
||||
# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,python,qt
|
||||
|
||||
.envrc
|
||||
.direnv
|
||||
.devenv
|
||||
|
||||
170
CONTRIBUTING.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# Contributing to TagStudio
|
||||
|
||||
_Last Updated: June 10th, 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.
|
||||
|
||||
## Getting Started
|
||||
|
||||
- Check the [Planned Features](https://github.com/TagStudioDev/TagStudio/blob/main/doc/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.
|
||||
|
||||
### Contribution Checklist
|
||||
|
||||
- I've read the [Planned Features](https://github.com/TagStudioDev/TagStudio/blob/main/doc/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!_**
|
||||
|
||||
## 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`)
|
||||
|
||||
### Creating a Python Virtual Environment
|
||||
|
||||
If you wish to launch the source version of TagStudio outside of your IDE:
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Depending on your system, Python may be called `python`, `py`, `python3`, or `py3`. These instructions use the alias `python3` for consistency. You can check to see which alias your system uses and if it's for the correct Python version by typing `python3 --version` (or whichever alias) into your terminal.
|
||||
|
||||
> [!TIP]
|
||||
> On Linux and macOS, you can launch the `tagstudio.sh` script to skip the following process, minus the `requirements-dev.txt` installation step. _Using the script is fine if you just want to launch the program from source._
|
||||
|
||||
1. In the root repository directory, create a python virtual environment:
|
||||
`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`
|
||||
|
||||
3. Install the required packages:
|
||||
|
||||
- `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)
|
||||
|
||||
- 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)
|
||||
|
||||
- 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.
|
||||
|
||||
- **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
|
||||
|
||||
When pushing your code, several automated workflows will check it against predefined tests and style checks. It's _highly recommended_ that you run these checks locally beforehand to avoid having to fight back-and-forth with the workflow checks inside your pull requests.
|
||||
|
||||
> [!TIP]
|
||||
> To format the code automatically before each commit, there's a configured action available for the `pre-commit` hook. Install it by running `pre-commit install`. The hook will be executed each time on running `git commit`.
|
||||
|
||||
### [Ruff](https://github.com/astral-sh/ruff)
|
||||
|
||||
A Python linter and code formatter. Ruff uses the `pyproject.toml` as its config file and runs whenever code is pushed or pulled into the project.
|
||||
|
||||
#### Running Locally
|
||||
|
||||
- Lint code with by moving into the `/tagstudio` directory with `cd tagstudio` and running `ruff --config ../pyproject.toml`.
|
||||
- Format code with `ruff format` inside the repository directory
|
||||
|
||||
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/).
|
||||
|
||||
### [Mypy](https://github.com/python/mypy)
|
||||
|
||||
Mypy is a static type checker for Python. It sure has a lot to say sometimes, but we recommend you take its advice when possible. Mypy also uses the `pyproject.toml` as its config file and runs whenever code is pushed or pulled into the project.
|
||||
|
||||
#### 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.
|
||||
|
||||
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/`.
|
||||
|
||||
## Code Guidelines
|
||||
|
||||
### 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.
|
||||
|
||||
### 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
|
||||
|
||||
#### Runtime
|
||||
|
||||
- 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.
|
||||
|
||||
#### Git/GitHub Specifics
|
||||
|
||||
- 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.
|
||||
- Use imperative-style present-tense commit messages. Examples:
|
||||
- "Add feature foo"
|
||||
- "Change method bar"
|
||||
- "Fix function foobar"
|
||||
- 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.
|
||||
|
||||
## Documentation Guidelines
|
||||
|
||||
Documentation contributions include anything inside of the `doc/` folder, as well as the `README.md` and `CONTRIBUTING.md` files.
|
||||
|
||||
- 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_
|
||||
96
README.md
@@ -10,15 +10,18 @@
|
||||
|
||||
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.
|
||||
|
||||
<p align="center">
|
||||
<img width="80%" src="screenshot.jpg">
|
||||
</p>
|
||||
<figure align="center">
|
||||
<img width="80%" src="screenshot.jpg" alt="TagStudio Screenshot" align="center">
|
||||
|
||||
<figcaption><i>TagStudio Alpha v9.1.0 running on Windows 10.</i></figcaption>
|
||||
</figure>
|
||||
|
||||
## Contents
|
||||
|
||||
- [Goals](#goals)
|
||||
- [Priorities](#priorities)
|
||||
- [Current Features](#current-features)
|
||||
- [Contributing](#contributing)
|
||||
- [Installation](#installation)
|
||||
- [Usage](#usage)
|
||||
- [FAQ](#faq)
|
||||
@@ -50,13 +53,19 @@ TagStudio is a photo & file organization application with an underlying system t
|
||||
- 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 and other docs.
|
||||
> For more information on the project itself, please see the [FAQ](#faq) section as well as the [documentation](/doc/index.md).
|
||||
|
||||
## Contributing
|
||||
|
||||
If you're interested in contributing to TagStudio, please take a look at the [contribution guidelines](/CONTRIBUTING.md) for how to get started!
|
||||
|
||||
## 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. 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.
|
||||
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.
|
||||
|
||||
> [!NOTE]
|
||||
For video thumbnails and playback, you'll also need [FFmpeg](https://ffmpeg.org/download.html) installed on your system.
|
||||
|
||||
> [!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
|
||||
@@ -112,9 +121,6 @@ To create a new tag, click on Edit -> New Tag from the menu bar. From there, ent
|
||||
|
||||
To edit a tag, right-click the tag in the tag field of the preview pane and select “Edit Tag”
|
||||
|
||||
> [!WARNING]
|
||||
> There is currently no method to view all tags that you’ve created in your library. This is a top priority for future releases.
|
||||
|
||||
### Relinking Renamed/Moved Files
|
||||
|
||||
Inevitably, some of the files inside your library will be renamed, moved, or deleted. If a file has been renamed or moved, TagStudio will display the thumbnail as a red tag with a cross through it _(this icon is also used for items with broken thumbnails)._ To relink moved files or delete these entries, go to Tools -> Manage Unlinked Entries. Click the “Refresh” button to scan your library for unlinked entries. Once complete, you can attempt to “Search & Relink” any unlinked entries to their respective files, or “Delete Unlinked Entries” in the event the original files have been deleted and you no longer wish to keep their metadata entries inside your library.
|
||||
@@ -143,7 +149,7 @@ Load in a .dupeguru file generated by [dupeGuru](https://github.com/arsenetar/du
|
||||
Create an image collage of your photos and videos.
|
||||
|
||||
> [!CAUTION]
|
||||
> Collage sizes and options are hardcoded.
|
||||
> Collage sizes and options are hardcoded, and there's no GUI indicating the process of the collage creation.
|
||||
|
||||
#### Macros
|
||||
|
||||
@@ -159,65 +165,20 @@ Import JSON sidecar data generated by [gallery-dl](https://github.com/mikf/galle
|
||||
> [!CAUTION]
|
||||
> This feature is not supported or documented in any official capacity whatsoever. It will likely be rolled-in to a larger and more generalized sidecar importing feature in the future.
|
||||
|
||||
## Creating a Development Environment
|
||||
## Launching/Building From Source
|
||||
|
||||
If you're interested in contributing to TagStudio or just wish to poke around the live codebase, here are instructions for setting up the Python project.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.12
|
||||
|
||||
### Creating a Python Virtual Environment
|
||||
|
||||
> [!NOTE]
|
||||
> Depending on your system, Python may be called `python`, `py`, `python3`, or `py3`. These instructions use the alias `python3`. You can check to see which alias you system uses and if it's for the correct Python version by typing `python3 --version` (or whichever alias) into your terminal.
|
||||
|
||||
_Skip these steps if launching from the .sh script on Linux/macOS._
|
||||
|
||||
1. In the root repository directory, create a python virtual environment:
|
||||
`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`
|
||||
|
||||
3. Install the required packages:
|
||||
|
||||
- required to run the app: `pip install -r requirements.txt`
|
||||
- required to develop: `pip install -r requirements-dev.txt`
|
||||
|
||||
To run all the tests use `python -m pytest tests/` from the `tagstudio` folder.
|
||||
|
||||
_Learn more about setting up a virtual environment [here](https://docs.python.org/3/tutorial/venv.html)._
|
||||
|
||||
### Launching Development Environment
|
||||
|
||||
- **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.
|
||||
|
||||
- **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`.
|
||||
|
||||
- **NixOS** (TagStudio.sh)
|
||||
- Use the provided `flake.nix` file to create and enter a working environment by running `nix develop`. Then, run the `TagStudio.sh` script.
|
||||
|
||||
- **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`.
|
||||
See instructions in the "[Creating Development Environment](/CONTRIBUTING.md/#creating-a-development-environment)" section from the [contribution documentation](/CONTRIBUTING.md).
|
||||
|
||||
## FAQ
|
||||
|
||||
### What State Is the Project Currently In?
|
||||
|
||||
As of writing (Alpha v9.2.1) the project is in a useable state, however it lacks proper testing and quality of life features.
|
||||
As of writing (Alpha v9.3.0) the project is in a useable state, however it lacks proper testing and quality of life features.
|
||||
|
||||
### What Features Are You Planning on Adding?
|
||||
|
||||
> [!NOTE]
|
||||
> **_See [Planned Features](/doc/planned_features.md) documentation._**
|
||||
> [!IMPORTANT]
|
||||
> See the [Planned Features](/doc/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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -283,18 +244,3 @@ I’ve been developing this project over several years in private, and have gone
|
||||
### 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...)._
|
||||
|
||||
### Can I Contribute?
|
||||
|
||||
**Yes!!** I recommend taking a look at the [Priority Features](#priority-features), [Future Features](#future-features), and [Features I Won't Pull](#features-i-likely-wont-addpull) lists, as well as the project issues to see what’s currently being worked on. Please do not submit pull requests with new feature additions without opening up an issue with a feature request first.
|
||||
|
||||
Code formatting is automatically checked via [Ruff](https://docs.astral.sh/ruff/).
|
||||
|
||||
To format the code manually, install ruff via `pip install -r requirements-dev.txt` and then run `ruff format`
|
||||
|
||||
To format the code automatically before each commit, there's a configured action available for `pre-commit` hook. Install it by running `pre-commit install`. The hook will be executed each time on running `git commit`.
|
||||
|
||||
More structured documentation on contribution requirements is on its way, but for now:
|
||||
|
||||
- Use `pathlib` in favor of `os.path`
|
||||
- Try to make new UI additions match the existing style of the application
|
||||
|
||||
544
flake.lock
generated
@@ -1,12 +1,429 @@
|
||||
{
|
||||
"nodes": {
|
||||
"cachix": {
|
||||
"inputs": {
|
||||
"devenv": "devenv_2",
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"flake-compat"
|
||||
],
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"nixpkgs"
|
||||
],
|
||||
"pre-commit-hooks": [
|
||||
"devenv",
|
||||
"pre-commit-hooks"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1712055811,
|
||||
"narHash": "sha256-7FcfMm5A/f02yyzuavJe06zLa9hcMHsagE28ADcmQvk=",
|
||||
"owner": "cachix",
|
||||
"repo": "cachix",
|
||||
"rev": "02e38da89851ec7fec3356a5c04bc8349cae0e30",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "cachix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"devenv": {
|
||||
"inputs": {
|
||||
"cachix": "cachix",
|
||||
"flake-compat": "flake-compat_2",
|
||||
"nix": "nix_2",
|
||||
"nixpkgs": "nixpkgs_2",
|
||||
"pre-commit-hooks": "pre-commit-hooks"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1724763216,
|
||||
"narHash": "sha256-oW2bwCrJpIzibCNK6zfIDaIQw765yMAuMSG2gyZfGv0=",
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"rev": "1e4ef61205b9aa20fe04bf1c468b6a316281c4f1",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"devenv-root": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"narHash": "sha256-d6xi4mKdjkX2JFicDIv5niSzpyI0m/Hnm8GGAIU04kY=",
|
||||
"type": "file",
|
||||
"url": "file:///dev/null"
|
||||
},
|
||||
"original": {
|
||||
"type": "file",
|
||||
"url": "file:///dev/null"
|
||||
}
|
||||
},
|
||||
"devenv_2": {
|
||||
"inputs": {
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"cachix",
|
||||
"flake-compat"
|
||||
],
|
||||
"nix": "nix",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"poetry2nix": "poetry2nix",
|
||||
"pre-commit-hooks": [
|
||||
"devenv",
|
||||
"cachix",
|
||||
"pre-commit-hooks"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1708704632,
|
||||
"narHash": "sha256-w+dOIW60FKMaHI1q5714CSibk99JfYxm0CzTinYWr+Q=",
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"rev": "2ee4450b0f4b95a1b90f2eb5ffea98b90e48c196",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"ref": "python-rewrite",
|
||||
"repo": "devenv",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-compat": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1673956053,
|
||||
"narHash": "sha256-4gtG9iQuiKITOjNQQeQIpoIB6b16fm+504Ch3sNKLd8=",
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"rev": "35bb57c0c8d8b62bbfd284272c928ceb64ddbde9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-compat_2": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1696426674,
|
||||
"narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1725024810,
|
||||
"narHash": "sha256-ODYRm8zHfLTH3soTFWE452ydPYz2iTvr9T8ftDMUQ3E=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "af510d4a62d071ea13925ce41c95e3dec816c01d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1689068808,
|
||||
"narHash": "sha256-6ixXo3wt24N/melDWjq70UuHQLxGV8jZvooRanIHXw0=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "919d646de7be200f3bf08cb76ae1f09402b6f9b4",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils_2": {
|
||||
"inputs": {
|
||||
"systems": "systems_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1710146030,
|
||||
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils_3": {
|
||||
"inputs": {
|
||||
"systems": "systems_3"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1710146030,
|
||||
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"gitignore": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"pre-commit-hooks",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1709087332,
|
||||
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix": {
|
||||
"inputs": {
|
||||
"flake-compat": "flake-compat",
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"cachix",
|
||||
"devenv",
|
||||
"nixpkgs"
|
||||
],
|
||||
"nixpkgs-regression": "nixpkgs-regression"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1712911606,
|
||||
"narHash": "sha256-BGvBhepCufsjcUkXnEEXhEVjwdJAwPglCC2+bInc794=",
|
||||
"owner": "domenkozar",
|
||||
"repo": "nix",
|
||||
"rev": "b24a9318ea3f3600c1e24b4a00691ee912d4de12",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "domenkozar",
|
||||
"ref": "devenv-2.21",
|
||||
"repo": "nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix-github-actions": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"cachix",
|
||||
"devenv",
|
||||
"poetry2nix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1688870561,
|
||||
"narHash": "sha256-4UYkifnPEw1nAzqqPOTL2MvWtm3sNGw1UTYTalkTcGY=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nix-github-actions",
|
||||
"rev": "165b1650b753316aa7f1787f3005a8d2da0f5301",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "nix-github-actions",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix2container": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils_3",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1724996935,
|
||||
"narHash": "sha256-njRK9vvZ1JJsP8oV2OgkBrpJhgQezI03S7gzskCcHos=",
|
||||
"owner": "nlewo",
|
||||
"repo": "nix2container",
|
||||
"rev": "fa6bb0a1159f55d071ba99331355955ae30b3401",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nlewo",
|
||||
"repo": "nix2container",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix_2": {
|
||||
"inputs": {
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"flake-compat"
|
||||
],
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"nixpkgs"
|
||||
],
|
||||
"nixpkgs-regression": "nixpkgs-regression_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1712911606,
|
||||
"narHash": "sha256-BGvBhepCufsjcUkXnEEXhEVjwdJAwPglCC2+bInc794=",
|
||||
"owner": "domenkozar",
|
||||
"repo": "nix",
|
||||
"rev": "b24a9318ea3f3600c1e24b4a00691ee912d4de12",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "domenkozar",
|
||||
"ref": "devenv-2.21",
|
||||
"repo": "nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1712473363,
|
||||
"narHash": "sha256-TIScFAVdI2yuybMxxNjC4YZ/j++c64wwuKbpnZnGiyU=",
|
||||
"lastModified": 1692808169,
|
||||
"narHash": "sha256-x9Opq06rIiwdwGeK2Ykj69dNc2IvUH1fY55Wm7atwrE=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "9201b5ff357e781bf014d0330d18555695df7ba8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-qt6": {
|
||||
"locked": {
|
||||
"lastModified": 1718428119,
|
||||
"narHash": "sha256-WdWDpNaq6u1IPtxtYHHWpl5BmabtpmLnMAx0RdJ/vo8=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "e6cea36f83499eb4e9cd184c8a8e823296b50ad5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "e6cea36f83499eb4e9cd184c8a8e823296b50ad5",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-regression": {
|
||||
"locked": {
|
||||
"lastModified": 1643052045,
|
||||
"narHash": "sha256-uGJ0VXIhWKGXxkeNnq4TvV3CIOkUJ3PAoLZ3HMzNVMw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-regression_2": {
|
||||
"locked": {
|
||||
"lastModified": 1643052045,
|
||||
"narHash": "sha256-uGJ0VXIhWKGXxkeNnq4TvV3CIOkUJ3PAoLZ3HMzNVMw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-stable": {
|
||||
"locked": {
|
||||
"lastModified": 1710695816,
|
||||
"narHash": "sha256-3Eh7fhEID17pv9ZxrPwCLfqXnYP006RKzSs0JptsN84=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "614b4613980a522ba49f0d194531beddbb7220d3",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-23.11",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1713361204,
|
||||
"narHash": "sha256-TA6EDunWTkc5FvDCqU3W2T3SFn0gRZqh6D/hJnM02MM=",
|
||||
"owner": "cachix",
|
||||
"repo": "devenv-nixpkgs",
|
||||
"rev": "285676e87ad9f0ca23d8714a6ab61e7e027020c6",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"ref": "rolling",
|
||||
"repo": "devenv-nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_3": {
|
||||
"locked": {
|
||||
"lastModified": 1724819573,
|
||||
"narHash": "sha256-GnR7/ibgIH1vhoy8cYdmXE6iyZqKqFxQSVkFgosBh6w=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "e89cf1c932006531f454de7d652163a9a5c86668",
|
||||
"rev": "71e91c409d1e654808b2621f28a327acfdad8dc2",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -16,9 +433,128 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"poetry2nix": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nix-github-actions": "nix-github-actions",
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"cachix",
|
||||
"devenv",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1692876271,
|
||||
"narHash": "sha256-IXfZEkI0Mal5y1jr6IRWMqK8GW2/f28xJenZIPQqkY0=",
|
||||
"owner": "nix-community",
|
||||
"repo": "poetry2nix",
|
||||
"rev": "d5006be9c2c2417dafb2e2e5034d83fabd207ee3",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "poetry2nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"pre-commit-hooks": {
|
||||
"inputs": {
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"flake-compat"
|
||||
],
|
||||
"flake-utils": "flake-utils_2",
|
||||
"gitignore": "gitignore",
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"nixpkgs"
|
||||
],
|
||||
"nixpkgs-stable": "nixpkgs-stable"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1713775815,
|
||||
"narHash": "sha256-Wu9cdYTnGQQwtT20QQMg7jzkANKQjwBD9iccfGKkfls=",
|
||||
"owner": "cachix",
|
||||
"repo": "pre-commit-hooks.nix",
|
||||
"rev": "2ac4dcbf55ed43f3be0bae15e181f08a57af24a4",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "pre-commit-hooks.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
"devenv": "devenv",
|
||||
"devenv-root": "devenv-root",
|
||||
"flake-parts": "flake-parts",
|
||||
"nix2container": "nix2container",
|
||||
"nixpkgs": "nixpkgs_3",
|
||||
"nixpkgs-qt6": "nixpkgs-qt6",
|
||||
"systems": "systems_4"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems_2": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems_3": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems_4": {
|
||||
"locked": {
|
||||
"lastModified": 1689347949,
|
||||
"narHash": "sha256-12tWmuL2zgBgZkdoB6qXZsgJEH9LR3oUgpaQq2RbI80=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default-linux",
|
||||
"rev": "31732fcf5e8fea42e59c2488ad31a0e651500f68",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default-linux",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
266
flake.nix
@@ -1,70 +1,208 @@
|
||||
{
|
||||
inputs.nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||
description = "TagStudio";
|
||||
|
||||
outputs = { self, nixpkgs, }:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.x86_64-linux;
|
||||
in {
|
||||
devShells.x86_64-linux.default = pkgs.mkShell {
|
||||
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [
|
||||
pkgs.gcc-unwrapped
|
||||
pkgs.zlib
|
||||
pkgs.libglvnd
|
||||
pkgs.glib
|
||||
pkgs.stdenv.cc.cc
|
||||
pkgs.fontconfig
|
||||
pkgs.libxkbcommon
|
||||
pkgs.xorg.libxcb
|
||||
pkgs.freetype
|
||||
pkgs.dbus
|
||||
pkgs.qt6.qtwayland
|
||||
pkgs.qt6.full
|
||||
pkgs.qt6.qtbase
|
||||
pkgs.zstd
|
||||
];
|
||||
buildInputs = with pkgs; [
|
||||
cmake
|
||||
gdb
|
||||
zstd
|
||||
qt6.qtbase
|
||||
qt6.full
|
||||
qt6.qtwayland
|
||||
qtcreator
|
||||
python312Packages.pip
|
||||
python312Full
|
||||
python312Packages.virtualenv # run virtualenv .
|
||||
python312Packages.pyusb # fixes the pyusb 'No backend available' when installed directly via pip
|
||||
inputs = {
|
||||
devenv.url = "github:cachix/devenv";
|
||||
|
||||
libgcc
|
||||
makeWrapper
|
||||
bashInteractive
|
||||
glib
|
||||
libxkbcommon
|
||||
freetype
|
||||
binutils
|
||||
dbus
|
||||
coreutils
|
||||
libGL
|
||||
libGLU
|
||||
fontconfig
|
||||
xorg.libxcb
|
||||
|
||||
|
||||
# this is for the shellhook portion
|
||||
qt6.wrapQtAppsHook
|
||||
makeWrapper
|
||||
bashInteractive
|
||||
];
|
||||
# set the environment variables that Qt apps expect
|
||||
shellHook = ''
|
||||
export QT_QPA_PLATFORM=wayland
|
||||
export LIBRARY_PATH=/usr/lib:/usr/lib64:$LIBRARY_PATH
|
||||
# export LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib/:/run/opengl-driver/lib/
|
||||
export QT_PLUGIN_PATH=${pkgs.qt6.qtbase}/${pkgs.qt6.qtbase.qtPluginPrefix}
|
||||
bashdir=$(mktemp -d)
|
||||
makeWrapper "$(type -p bash)" "$bashdir/bash" "''${qtWrapperArgs[@]}"
|
||||
exec "$bashdir/bash"
|
||||
'';
|
||||
devenv-root = {
|
||||
url = "file+file:///dev/null";
|
||||
flake = false;
|
||||
};
|
||||
|
||||
flake-parts = {
|
||||
url = "github:hercules-ci/flake-parts";
|
||||
inputs.nixpkgs-lib.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
nix2container = {
|
||||
url = "github:nlewo/nix2container";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||
|
||||
# Pinned to Qt version 6.7.1
|
||||
nixpkgs-qt6.url = "github:NixOS/nixpkgs/e6cea36f83499eb4e9cd184c8a8e823296b50ad5";
|
||||
|
||||
systems.url = "github:nix-systems/default-linux";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{
|
||||
flake-parts,
|
||||
nixpkgs,
|
||||
nixpkgs-qt6,
|
||||
self,
|
||||
systems,
|
||||
...
|
||||
}@inputs:
|
||||
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||
imports = [ inputs.devenv.flakeModule ];
|
||||
|
||||
systems = import systems;
|
||||
|
||||
perSystem =
|
||||
{
|
||||
config,
|
||||
pkgs,
|
||||
system,
|
||||
...
|
||||
}:
|
||||
let
|
||||
inherit (nixpkgs) lib;
|
||||
|
||||
qt6Pkgs = import nixpkgs-qt6 { inherit system; };
|
||||
in
|
||||
{
|
||||
formatter = pkgs.nixfmt-rfc-style;
|
||||
|
||||
devenv.shells = rec {
|
||||
default = tagstudio;
|
||||
|
||||
tagstudio =
|
||||
let
|
||||
cfg = config.devenv.shells.tagstudio;
|
||||
in
|
||||
{
|
||||
# NOTE: many things were simply transferred over from previous,
|
||||
# there must be additional work in ensuring all relevant dependencies
|
||||
# are in place (and no extraneous). I have already spent much
|
||||
# work making this in the first place and just need to get it out
|
||||
# there, especially after my promises. Would appreciate any help
|
||||
# (possibly PRs!) on taking care of this. Otherwise, just expect
|
||||
# this to get ironed out over time.
|
||||
#
|
||||
# Thank you! -Xarvex
|
||||
|
||||
devenv.root =
|
||||
let
|
||||
devenvRoot = builtins.readFile inputs.devenv-root.outPath;
|
||||
in
|
||||
# If not overriden (/dev/null), --impure is necessary.
|
||||
pkgs.lib.mkIf (devenvRoot != "") devenvRoot;
|
||||
|
||||
name = "TagStudio";
|
||||
|
||||
# Derived from previous flake iteration.
|
||||
packages =
|
||||
(with pkgs; [
|
||||
cmake
|
||||
binutils
|
||||
coreutils
|
||||
dbus
|
||||
fontconfig
|
||||
freetype
|
||||
gdb
|
||||
glib
|
||||
libGL
|
||||
libGLU
|
||||
libgcc
|
||||
libxkbcommon
|
||||
mypy
|
||||
ruff
|
||||
xorg.libxcb
|
||||
zstd
|
||||
])
|
||||
++ (with qt6Pkgs; [
|
||||
qt6.full
|
||||
qt6.qtbase
|
||||
qt6.qtwayland
|
||||
qtcreator
|
||||
]);
|
||||
|
||||
enterShell =
|
||||
let
|
||||
setQtEnv =
|
||||
pkgs.runCommand "set-qt-env"
|
||||
{
|
||||
buildInputs = with qt6Pkgs.qt6; [
|
||||
qtbase
|
||||
];
|
||||
|
||||
nativeBuildInputs =
|
||||
(with pkgs; [
|
||||
makeShellWrapper
|
||||
])
|
||||
++ (with qt6Pkgs.qt6; [
|
||||
wrapQtAppsHook
|
||||
]);
|
||||
}
|
||||
''
|
||||
makeShellWrapper "$(type -p sh)" "$out" "''${qtWrapperArgs[@]}"
|
||||
sed "/^exec/d" -i "$out"
|
||||
'';
|
||||
in
|
||||
''
|
||||
source ${setQtEnv}
|
||||
'';
|
||||
|
||||
scripts.tagstudio.exec = ''
|
||||
python ${cfg.devenv.root}/tagstudio/tag_studio.py
|
||||
'';
|
||||
|
||||
env = {
|
||||
QT_QPA_PLATFORM = "wayland;xcb";
|
||||
|
||||
# Derived from previous flake iteration.
|
||||
# Not desired given LD_LIBRARY_PATH pollution.
|
||||
# See supposed alternative below, further research required.
|
||||
LD_LIBRARY_PATH = lib.makeLibraryPath (
|
||||
(with pkgs; [
|
||||
dbus
|
||||
fontconfig
|
||||
freetype
|
||||
gcc-unwrapped
|
||||
glib
|
||||
libglvnd
|
||||
libkrb5
|
||||
libpulseaudio
|
||||
libva
|
||||
libxkbcommon
|
||||
openssl
|
||||
stdenv.cc.cc.lib
|
||||
wayland
|
||||
xorg.libxcb
|
||||
xorg.libXrandr
|
||||
zlib
|
||||
zstd
|
||||
])
|
||||
++ (with qt6Pkgs.qt6; [
|
||||
qtbase
|
||||
qtwayland
|
||||
full
|
||||
])
|
||||
);
|
||||
};
|
||||
|
||||
languages.python = {
|
||||
enable = true;
|
||||
venv = {
|
||||
enable = true;
|
||||
quiet = true;
|
||||
requirements =
|
||||
let
|
||||
excludeDeps =
|
||||
req: deps:
|
||||
builtins.concatStringsSep "\n" (
|
||||
builtins.filter (line: !(lib.any (elem: lib.hasPrefix elem line) deps)) (lib.splitString "\n" req)
|
||||
);
|
||||
in
|
||||
''
|
||||
${builtins.readFile ./requirements.txt}
|
||||
${excludeDeps (builtins.readFile ./requirements-dev.txt) [
|
||||
"mypy"
|
||||
"ruff"
|
||||
]}
|
||||
'';
|
||||
};
|
||||
|
||||
# Should be able to replace LD_LIBRARY_PATH?
|
||||
# Was not quite able to get working,
|
||||
# will be consulting cachix community. -Xarvex
|
||||
# libraries = with pkgs; [ ];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
[tool.ruff]
|
||||
exclude = ["main_window.py", "home_ui.py", "resources.py", "resources_rc.py"]
|
||||
exclude = ["main_window.py", "home_ui.py", "resources.py", "resources_rc.py", "**/vendored/"]
|
||||
|
||||
[tool.mypy]
|
||||
strict_optional = false
|
||||
disable_error_code = ["union-attr", "annotation-unchecked", "import-untyped"]
|
||||
explicit_package_bases = true
|
||||
warn_unused_ignores = true
|
||||
exclude = ['tests', 'src/qt/helpers/vendored']
|
||||
|
||||
@@ -3,3 +3,4 @@ pre-commit==3.7.0
|
||||
pytest==8.2.0
|
||||
Pyinstaller==6.6.0
|
||||
mypy==1.10.0
|
||||
syrupy==4.6.1
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
humanfriendly==10.0
|
||||
opencv_python>=4.8.0.74,<=4.9.0.80
|
||||
Pillow==10.3.0
|
||||
PySide6>=6.5.1.1,<=6.6.3.1
|
||||
PySide6_Addons>=6.5.1.1,<=6.6.3.1
|
||||
PySide6_Essentials>=6.5.1.1,<=6.6.3.1
|
||||
PySide6==6.7.1
|
||||
PySide6_Addons==6.7.1
|
||||
PySide6_Essentials==6.7.1
|
||||
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
|
||||
pydub==0.25.1
|
||||
mutagen==1.47.0
|
||||
numpy==1.26.4
|
||||
ffmpeg-python==0.2.0
|
||||
Send2Trash==1.8.3
|
||||
vtf2img==0.1.0
|
||||
|
||||
@@ -26,7 +26,7 @@ a = Analysis(
|
||||
['tagstudio/tag_studio.py'],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=[('tagstudio/resources', 'resources')],
|
||||
datas=[('tagstudio/resources', 'resources'), ('tagstudio/src', 'src')],
|
||||
hiddenimports=[],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
|
||||
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 361 KiB |
BIN
tagstudio/resources/qt/images/broken_link_icon.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
tagstudio/resources/qt/images/file_icons/adobe_illustrator.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
tagstudio/resources/qt/images/file_icons/adobe_photoshop.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
tagstudio/resources/qt/images/file_icons/affinity_photo.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
tagstudio/resources/qt/images/file_icons/audio.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
tagstudio/resources/qt/images/file_icons/document.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
tagstudio/resources/qt/images/file_icons/file_generic.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
tagstudio/resources/qt/images/file_icons/font.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
tagstudio/resources/qt/images/file_icons/image.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
tagstudio/resources/qt/images/file_icons/image_vector.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
tagstudio/resources/qt/images/file_icons/material.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
tagstudio/resources/qt/images/file_icons/model.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
tagstudio/resources/qt/images/file_icons/presentation.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
tagstudio/resources/qt/images/file_icons/program.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
tagstudio/resources/qt/images/file_icons/spreadsheet.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
tagstudio/resources/qt/images/file_icons/text.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
tagstudio/resources/qt/images/file_icons/video.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 172 B After Width: | Height: | Size: 172 B |
|
Before Width: | Height: | Size: 151 B After Width: | Height: | Size: 151 B |
|
Before Width: | Height: | Size: 229 KiB After Width: | Height: | Size: 397 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 12 KiB |
BIN
tagstudio/resources/qt/images/thumb_loading.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 327 B After Width: | Height: | Size: 327 B |
|
Before Width: | Height: | Size: 445 B After Width: | Height: | Size: 445 B |
BIN
tagstudio/resources/qt/videos/placeholder.mp4
Normal file
@@ -1,4 +1,4 @@
|
||||
VERSION: str = "9.3.0" # Major.Minor.Patch
|
||||
VERSION: str = "9.4.1" # Major.Minor.Patch
|
||||
VERSION_BRANCH: str = "" # Usually "" or "Pre-Release"
|
||||
|
||||
# The folder & file names where TagStudio keeps its data relative to a library.
|
||||
@@ -7,118 +7,10 @@ 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]
|
||||
|
||||
BOX_FIELDS = ["tag_box", "text_box"]
|
||||
TEXT_FIELDS = ["text_line", "text_box"]
|
||||
@@ -163,3 +55,5 @@ TAG_COLORS = [
|
||||
"cool gray",
|
||||
"olive",
|
||||
]
|
||||
TAG_FAVORITE = 1
|
||||
TAG_ARCHIVED = 0
|
||||
|
||||
@@ -12,7 +12,9 @@ class SettingItems(str, enum.Enum):
|
||||
|
||||
|
||||
class Theme(str, enum.Enum):
|
||||
COLOR_BG = "#65000000"
|
||||
COLOR_BG_DARK = "#65000000"
|
||||
COLOR_BG_LIGHT = "#22000000"
|
||||
COLOR_DARK_LABEL = "#DD000000"
|
||||
COLOR_HOVER = "#65AAAAAA"
|
||||
COLOR_PRESSED = "#65EEEEEE"
|
||||
COLOR_DISABLED = "#65F39CAA"
|
||||
@@ -24,3 +26,16 @@ class SearchMode(int, enum.Enum):
|
||||
|
||||
AND = 0
|
||||
OR = 1
|
||||
|
||||
|
||||
class FieldID(int, enum.Enum):
|
||||
TITLE = 0
|
||||
AUTHOR = 1
|
||||
ARTIST = 2
|
||||
DESCRIPTION = 4
|
||||
NOTES = 5
|
||||
TAGS = 6
|
||||
CONTENT_TAGS = 7
|
||||
META_TAGS = 8
|
||||
DATE_PUBLISHED = 14
|
||||
SOURCE = 21
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
"""The Library object and related methods for TagStudio."""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import time
|
||||
import traceback
|
||||
import xml.etree.ElementTree as ET
|
||||
@@ -18,6 +18,7 @@ from pathlib import Path
|
||||
from typing import cast, Generator
|
||||
from typing_extensions import Self
|
||||
|
||||
from src.core.enums import FieldID
|
||||
from src.core.json_typing import JsonCollation, JsonEntry, JsonLibary, JsonTag
|
||||
from src.core.utils.str import strip_punctuation
|
||||
from src.core.utils.web import strip_web_protocol
|
||||
@@ -80,7 +81,7 @@ class Entry:
|
||||
# self.word_count: int = None
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"\n{self.compressed_dict()}\n"
|
||||
return str(self.compressed_dict())
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.__str__()
|
||||
@@ -736,7 +737,9 @@ class Library:
|
||||
"""Maps a full filepath to its corresponding Entry's ID."""
|
||||
self.filename_to_entry_id_map.clear()
|
||||
for entry in self.entries:
|
||||
self.filename_to_entry_id_map[(entry.path / entry.filename)] = entry.id
|
||||
self.filename_to_entry_id_map[
|
||||
(self.library_dir / entry.path / entry.filename)
|
||||
] = entry.id
|
||||
|
||||
# def _map_filenames_to_entry_ids(self):
|
||||
# """Maps the file paths of entries to their index in the library list."""
|
||||
@@ -843,24 +846,22 @@ class Library:
|
||||
|
||||
def clear_internal_vars(self):
|
||||
"""Clears the internal variables of the Library object."""
|
||||
self.library_dir = None
|
||||
self.is_legacy_library = False
|
||||
|
||||
# Reset Directory Data =================================================
|
||||
self.library_dir = None
|
||||
|
||||
# Reset Entries ========================================================
|
||||
self.entries.clear()
|
||||
self._next_entry_id = 0
|
||||
# self.filtered_entries.clear()
|
||||
self._entry_id_to_index_map.clear()
|
||||
|
||||
self._collation_id_to_index_map.clear()
|
||||
|
||||
self.missing_matches = {}
|
||||
self.dir_file_count = -1
|
||||
self.files_not_in_library.clear()
|
||||
self.missing_files.clear()
|
||||
self.fixed_files.clear()
|
||||
self.filename_to_entry_id_map: dict[Path, int] = {}
|
||||
self.ext_list = self.default_ext_exclude_list
|
||||
|
||||
# Reset Tags ===========================================================
|
||||
self.tags.clear()
|
||||
self._next_tag_id = 1000
|
||||
self._tag_strings_to_id_map = {}
|
||||
@@ -868,6 +869,13 @@ class Library:
|
||||
self._tag_id_to_index_map = {}
|
||||
self._tag_entry_ref_map.clear()
|
||||
|
||||
# Reset Collations =====================================================
|
||||
self.collations.clear()
|
||||
self._collation_id_to_index_map.clear()
|
||||
|
||||
# Reset Extension List =================================================
|
||||
self.ext_list = self.default_ext_exclude_list
|
||||
|
||||
def refresh_dir(self) -> Generator:
|
||||
"""Scans a directory for files, and adds those relative filenames to internal variables."""
|
||||
|
||||
@@ -878,54 +886,72 @@ class Library:
|
||||
|
||||
# Scans the directory for files, keeping track of:
|
||||
# - Total file count
|
||||
# - Files without library entries
|
||||
# for type in TYPES:
|
||||
start_time = time.time()
|
||||
# - Files without Library entries
|
||||
start_time_total = time.time()
|
||||
start_time_loop = time.time()
|
||||
ext_set = set(self.ext_list) # Should be slightly faster
|
||||
for f in self.library_dir.glob("**/*"):
|
||||
try:
|
||||
if (
|
||||
"$RECYCLE.BIN" not in f.parts
|
||||
and TS_FOLDER_NAME not in f.parts
|
||||
and "tagstudio_thumbs" not in f.parts
|
||||
and not f.is_dir()
|
||||
):
|
||||
if f.suffix not in self.ext_list and self.is_exclude_list:
|
||||
self.dir_file_count += 1
|
||||
file = f.relative_to(self.library_dir)
|
||||
if file not in self.filename_to_entry_id_map:
|
||||
self.files_not_in_library.append(file)
|
||||
elif f.suffix in self.ext_list and not self.is_exclude_list:
|
||||
self.dir_file_count += 1
|
||||
file = f.relative_to(self.library_dir)
|
||||
try:
|
||||
_ = self.filename_to_entry_id_map[file]
|
||||
except KeyError:
|
||||
# print(file)
|
||||
self.files_not_in_library.append(file)
|
||||
except PermissionError:
|
||||
logging.info(
|
||||
f"The File/Folder {f} cannot be accessed, because it requires higher permission!"
|
||||
)
|
||||
end_time = time.time()
|
||||
end_time_loop = time.time()
|
||||
# Yield output every 1/30 of a second
|
||||
if (end_time - start_time) > 0.034:
|
||||
if (end_time_loop - start_time_loop) > 0.034:
|
||||
yield self.dir_file_count
|
||||
start_time = time.time()
|
||||
# Sorts the files by date modified, descending.
|
||||
if len(self.files_not_in_library) <= 100000:
|
||||
start_time_loop = time.time()
|
||||
try:
|
||||
self.files_not_in_library = sorted(
|
||||
self.files_not_in_library,
|
||||
key=lambda t: -(self.library_dir / t).stat().st_ctime,
|
||||
)
|
||||
# Skip this file if it should be excluded
|
||||
ext: str = f.suffix.lower()
|
||||
if (ext in ext_set and self.is_exclude_list) or (
|
||||
ext not in ext_set and not self.is_exclude_list
|
||||
):
|
||||
continue
|
||||
|
||||
# Finish if the file/path is already mapped in the Library
|
||||
if self.filename_to_entry_id_map.get(f) is not None:
|
||||
# No other checks are required.
|
||||
self.dir_file_count += 1
|
||||
continue
|
||||
|
||||
# If the file is new, check for validity
|
||||
if (
|
||||
"$RECYCLE.BIN" in f.parts
|
||||
or TS_FOLDER_NAME in f.parts
|
||||
or "tagstudio_thumbs" in f.parts
|
||||
or f.is_dir()
|
||||
):
|
||||
continue
|
||||
|
||||
# Add the validated new file to the Library
|
||||
self.dir_file_count += 1
|
||||
self.files_not_in_library.append(f)
|
||||
|
||||
except PermissionError:
|
||||
logging.info(f'[LIBRARY] Cannot access "{f}": PermissionError')
|
||||
|
||||
yield self.dir_file_count
|
||||
end_time_total = time.time()
|
||||
logging.info(
|
||||
f"[LIBRARY] Scanned directories in {(end_time_total - start_time_total):.3f} seconds"
|
||||
)
|
||||
# Sorts the files by date modified, descending
|
||||
if len(self.files_not_in_library) <= 150000:
|
||||
try:
|
||||
if platform.system() == "Windows" or platform.system() == "Darwin":
|
||||
self.files_not_in_library = sorted(
|
||||
self.files_not_in_library,
|
||||
key=lambda t: -(t).stat().st_birthtime, # type: ignore[attr-defined]
|
||||
)
|
||||
else:
|
||||
self.files_not_in_library = sorted(
|
||||
self.files_not_in_library,
|
||||
key=lambda t: -(t).stat().st_ctime,
|
||||
)
|
||||
except (FileExistsError, FileNotFoundError):
|
||||
print(
|
||||
"[LIBRARY] [ERROR] Couldn't sort files, some were moved during the scanning/sorting process."
|
||||
logging.info(
|
||||
"[LIBRARY][ERROR] Couldn't sort files, some were moved during the scanning/sorting process."
|
||||
)
|
||||
pass
|
||||
else:
|
||||
print(
|
||||
"[LIBRARY][INFO] Not bothering to sort files because there's OVER 100,000! Better sorting methods will be added in the future."
|
||||
logging.info(
|
||||
"[LIBRARY][INFO] Not bothering to sort files because there's OVER 150,000! Better sorting methods will be added in the future."
|
||||
)
|
||||
|
||||
def refresh_missing_files(self):
|
||||
@@ -945,7 +971,7 @@ class Library:
|
||||
# Step [1/2]:
|
||||
# Remove this Entry from the Entries list.
|
||||
entry = self.get_entry(entry_id)
|
||||
path = entry.path / entry.filename
|
||||
path = self.library_dir / entry.path / entry.filename
|
||||
# logging.info(f'Removing path: {path}')
|
||||
|
||||
del self.filename_to_entry_id_map[path]
|
||||
@@ -1075,25 +1101,22 @@ class Library:
|
||||
)
|
||||
)
|
||||
for match in matches:
|
||||
# print(f'MATCHED ({match[2]}%): \n {files[match[0]]} \n-> {files[match[1]]}')
|
||||
file_1 = files[match[0]].relative_to(self.library_dir)
|
||||
file_2 = files[match[1]].relative_to(self.library_dir)
|
||||
file_1 = files[match[0]]
|
||||
file_2 = files[match[1]]
|
||||
|
||||
if (
|
||||
file_1.resolve in self.filename_to_entry_id_map.keys()
|
||||
file_1 in self.filename_to_entry_id_map.keys()
|
||||
and file_2 in self.filename_to_entry_id_map.keys()
|
||||
):
|
||||
self.dupe_files.append(
|
||||
(files[match[0]], files[match[1]], match[2])
|
||||
)
|
||||
print("")
|
||||
|
||||
for dupe in self.dupe_files:
|
||||
print(
|
||||
f"[LIBRARY] MATCHED ({dupe[2]}%): \n {dupe[0]} \n-> {dupe[1]}",
|
||||
end="\n",
|
||||
)
|
||||
# self.dupe_files.append(full_path)
|
||||
|
||||
def remove_missing_files(self):
|
||||
deleted = []
|
||||
@@ -1280,8 +1303,7 @@ class Library:
|
||||
"""Adds files from the `files_not_in_library` list to the Library as Entries. Returns list of added indices."""
|
||||
new_ids: list[int] = []
|
||||
for file in self.files_not_in_library:
|
||||
path = Path(file)
|
||||
# print(os.path.split(file))
|
||||
path = Path(*file.parts[len(self.library_dir.parts) :])
|
||||
entry = Entry(
|
||||
id=self._next_entry_id, filename=path.name, path=path.parent, fields=[]
|
||||
)
|
||||
@@ -1292,8 +1314,6 @@ class Library:
|
||||
self.files_not_in_library.clear()
|
||||
return new_ids
|
||||
|
||||
self.files_not_in_library.clear()
|
||||
|
||||
def get_entry(self, entry_id: int) -> Entry:
|
||||
"""Returns an Entry object given an Entry ID."""
|
||||
return self.entries[self._entry_id_to_index_map[int(entry_id)]]
|
||||
@@ -1314,9 +1334,7 @@ class Library:
|
||||
"""Returns an Entry ID given the full filepath it points to."""
|
||||
try:
|
||||
if self.entries:
|
||||
return self.filename_to_entry_id_map[
|
||||
Path(filename).relative_to(self.library_dir)
|
||||
]
|
||||
return self.filename_to_entry_id_map[filename]
|
||||
except KeyError:
|
||||
return -1
|
||||
|
||||
@@ -1347,10 +1365,18 @@ class Library:
|
||||
only_missing: bool = "missing" in query or "no file" in query
|
||||
allow_adv: bool = "filename:" in query_words
|
||||
tag_only: bool = "tag_id:" in query_words
|
||||
tag_only_ids: list[int] = []
|
||||
if allow_adv:
|
||||
query_words.remove("filename:")
|
||||
if tag_only:
|
||||
query_words.remove("tag_id:")
|
||||
if query_words and query_words[0].isdigit():
|
||||
tag_only_ids.append(int(query_words[0]))
|
||||
tag_only_ids.extend(self.get_tag_cluster(int(query_words[0])))
|
||||
else:
|
||||
logging.error(
|
||||
f"[Library][ERROR] Invalid Tag ID in query: {query_words}"
|
||||
)
|
||||
# TODO: Expand this to allow for dynamic fields to work.
|
||||
only_no_author: bool = "no author" in query or "no artist" in query
|
||||
|
||||
@@ -1377,15 +1403,9 @@ class Library:
|
||||
all_tag_terms.remove(all_tag_terms[i])
|
||||
break
|
||||
|
||||
# print(all_tag_terms)
|
||||
|
||||
# non_entry_count = 0
|
||||
# Iterate over all Entries =============================================================
|
||||
for entry in self.entries:
|
||||
allowed_ext: bool = entry.filename.suffix not in self.ext_list
|
||||
# try:
|
||||
# entry: Entry = self.entries[self.file_to_library_index_map[self._source_filenames[i]]]
|
||||
# print(f'{entry}')
|
||||
allowed_ext: bool = entry.filename.suffix.lower() not in self.ext_list
|
||||
|
||||
if allowed_ext == self.is_exclude_list:
|
||||
# If the entry has tags of any kind, append them to this main tag list.
|
||||
@@ -1427,7 +1447,6 @@ class Library:
|
||||
# elif query in entry.path.lower():
|
||||
|
||||
# NOTE: This searches path and filenames.
|
||||
|
||||
if allow_adv:
|
||||
if [q for q in query_words if (q in str(entry.path).lower())]:
|
||||
results.append((ItemType.ENTRY, entry.id))
|
||||
@@ -1436,17 +1455,14 @@ class Library:
|
||||
]:
|
||||
results.append((ItemType.ENTRY, entry.id))
|
||||
elif tag_only:
|
||||
if entry.has_tag(self, int(query_words[0])):
|
||||
results.append((ItemType.ENTRY, entry.id))
|
||||
for id in tag_only_ids:
|
||||
if entry.has_tag(self, id) and entry.id not in results:
|
||||
results.append((ItemType.ENTRY, entry.id))
|
||||
break
|
||||
|
||||
# elif query in entry.filename.lower():
|
||||
# self.filtered_entries.append(index)
|
||||
elif entry_tags:
|
||||
# function to add entry to results
|
||||
def add_entry(entry: Entry):
|
||||
# self.filter_entries.append()
|
||||
# self.filtered_file_list.append(file)
|
||||
# results.append((SearchItemType.ENTRY, entry.id))
|
||||
added = False
|
||||
for f in entry.fields:
|
||||
if self.get_field_attr(f, "type") == "collation":
|
||||
@@ -1516,30 +1532,10 @@ class Library:
|
||||
add_entry(entry)
|
||||
break
|
||||
|
||||
# sys.stdout.write(
|
||||
# f'\r[INFO][FILTER]: {len(self.filtered_file_list)} matches found')
|
||||
# sys.stdout.flush()
|
||||
|
||||
# except:
|
||||
# # # Put this here to have new non-registered images show up
|
||||
# # if query == "untagged" or query == "no author" or query == "no artist":
|
||||
# # self.filtered_file_list.append(file)
|
||||
# # non_entry_count = non_entry_count + 1
|
||||
# pass
|
||||
|
||||
# end_time = time.time()
|
||||
# print(
|
||||
# f'[INFO][FILTER]: {len(self.filtered_entries)} matches found ({(end_time - start_time):.3f} seconds)')
|
||||
|
||||
# if non_entry_count:
|
||||
# print(
|
||||
# f'[INFO][FILTER]: There are {non_entry_count} new files in {self.source_dir} that do not have entries. These will not appear in most filtered results.')
|
||||
# if not self.filtered_entries:
|
||||
# print("[INFO][FILTER]: Filter returned no results.")
|
||||
else:
|
||||
for entry in self.entries:
|
||||
added = False
|
||||
allowed_ext = entry.filename.suffix not in self.ext_list
|
||||
allowed_ext = entry.filename.suffix.lower() not in self.ext_list
|
||||
if allowed_ext == self.is_exclude_list:
|
||||
for f in entry.fields:
|
||||
if self.get_field_attr(f, "type") == "collation":
|
||||
@@ -1560,8 +1556,6 @@ class Library:
|
||||
|
||||
if not added:
|
||||
results.append((ItemType.ENTRY, entry.id))
|
||||
# for file in self._source_filenames:
|
||||
# self.filtered_file_list.append(file)
|
||||
results.reverse()
|
||||
return results
|
||||
|
||||
@@ -1948,48 +1942,44 @@ class Library:
|
||||
if data:
|
||||
# Add a Title Field if the data doesn't already exist.
|
||||
if data.get("title"):
|
||||
field_id = 0 # Title Field ID
|
||||
if not self.does_field_content_exist(entry_id, field_id, data["title"]):
|
||||
self.add_field_to_entry(entry_id, field_id)
|
||||
if not self.does_field_content_exist(
|
||||
entry_id, FieldID.TITLE, data["title"]
|
||||
):
|
||||
self.add_field_to_entry(entry_id, FieldID.TITLE)
|
||||
self.update_entry_field(entry_id, -1, data["title"], "replace")
|
||||
|
||||
# Add an Author Field if the data doesn't already exist.
|
||||
if data.get("author"):
|
||||
field_id = 1 # Author Field ID
|
||||
if not self.does_field_content_exist(
|
||||
entry_id, field_id, data["author"]
|
||||
entry_id, FieldID.AUTHOR, data["author"]
|
||||
):
|
||||
self.add_field_to_entry(entry_id, field_id)
|
||||
self.add_field_to_entry(entry_id, FieldID.AUTHOR)
|
||||
self.update_entry_field(entry_id, -1, data["author"], "replace")
|
||||
|
||||
# Add an Artist Field if the data doesn't already exist.
|
||||
if data.get("artist"):
|
||||
field_id = 2 # Artist Field ID
|
||||
if not self.does_field_content_exist(
|
||||
entry_id, field_id, data["artist"]
|
||||
entry_id, FieldID.ARTIST, data["artist"]
|
||||
):
|
||||
self.add_field_to_entry(entry_id, field_id)
|
||||
self.add_field_to_entry(entry_id, FieldID.ARTIST)
|
||||
self.update_entry_field(entry_id, -1, data["artist"], "replace")
|
||||
|
||||
# Add a Date Published Field if the data doesn't already exist.
|
||||
if data.get("date_published"):
|
||||
field_id = 14 # Date Published Field ID
|
||||
date = str(
|
||||
datetime.datetime.strptime(
|
||||
data["date_published"], "%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
)
|
||||
if not self.does_field_content_exist(entry_id, field_id, date):
|
||||
self.add_field_to_entry(entry_id, field_id)
|
||||
if not self.does_field_content_exist(
|
||||
entry_id, FieldID.DATE_PUBLISHED, date
|
||||
):
|
||||
self.add_field_to_entry(entry_id, FieldID.DATE_PUBLISHED)
|
||||
# entry = self.entries[entry_id]
|
||||
self.update_entry_field(entry_id, -1, date, "replace")
|
||||
|
||||
# Process String Tags if the data doesn't already exist.
|
||||
if data.get("tags"):
|
||||
tags_field_id = 6 # Tags Field ID
|
||||
content_tags_field_id = 7 # Content Tags Field ID
|
||||
meta_tags_field_id = 8 # Meta Tags Field ID
|
||||
notes_field_id = 5 # Notes Field ID
|
||||
tags: list[str] = data["tags"]
|
||||
# extra: list[str] = []
|
||||
# for tag in tags:
|
||||
@@ -2038,7 +2028,7 @@ class Library:
|
||||
# tag_field_indices = self.get_field_index_in_entry(
|
||||
# entry_index, tags_field_id)
|
||||
content_tags_field_indices = self.get_field_index_in_entry(
|
||||
self.get_entry(entry_id), content_tags_field_id
|
||||
self.get_entry(entry_id), FieldID.CONTENT_TAGS
|
||||
)
|
||||
# meta_tags_field_indices = self.get_field_index_in_entry(
|
||||
# entry_index, meta_tags_field_id)
|
||||
@@ -2050,50 +2040,45 @@ class Library:
|
||||
# elif meta_tags_field_indices:
|
||||
# priority_field_index = meta_tags_field_indices[0]
|
||||
|
||||
if priority_field_index > 0:
|
||||
if priority_field_index >= 0:
|
||||
self.update_entry_field(
|
||||
entry_id, priority_field_index, [matching[0]], "append"
|
||||
)
|
||||
else:
|
||||
self.add_field_to_entry(entry_id, content_tags_field_id)
|
||||
self.add_field_to_entry(entry_id, FieldID.CONTENT_TAGS)
|
||||
self.update_entry_field(
|
||||
entry_id, -1, [matching[0]], "append"
|
||||
)
|
||||
|
||||
# Add all original string tags as a note.
|
||||
str_tags = f"Original Tags: {tags}"
|
||||
if not self.does_field_content_exist(
|
||||
entry_id, notes_field_id, str_tags
|
||||
):
|
||||
self.add_field_to_entry(entry_id, notes_field_id)
|
||||
if not self.does_field_content_exist(entry_id, FieldID.NOTES, str_tags):
|
||||
self.add_field_to_entry(entry_id, FieldID.NOTES)
|
||||
self.update_entry_field(entry_id, -1, str_tags, "replace")
|
||||
|
||||
# Add a Description Field if the data doesn't already exist.
|
||||
if "description" in data.keys() and data["description"]:
|
||||
field_id = 4 # Description Field ID
|
||||
if data.get("description"):
|
||||
if not self.does_field_content_exist(
|
||||
entry_id, field_id, data["description"]
|
||||
entry_id, FieldID.DESCRIPTION, data["description"]
|
||||
):
|
||||
self.add_field_to_entry(entry_id, field_id)
|
||||
self.add_field_to_entry(entry_id, FieldID.DESCRIPTION)
|
||||
self.update_entry_field(
|
||||
entry_id, -1, data["description"], "replace"
|
||||
)
|
||||
if "content" in data.keys() and data["content"]:
|
||||
field_id = 4 # Description Field ID
|
||||
if data.get("content"):
|
||||
if not self.does_field_content_exist(
|
||||
entry_id, field_id, data["content"]
|
||||
entry_id, FieldID.DESCRIPTION, data["content"]
|
||||
):
|
||||
self.add_field_to_entry(entry_id, field_id)
|
||||
self.add_field_to_entry(entry_id, FieldID.DESCRIPTION)
|
||||
self.update_entry_field(entry_id, -1, data["content"], "replace")
|
||||
if "source" in data.keys() and data["source"]:
|
||||
field_id = 21 # Source Field ID
|
||||
if data.get("source"):
|
||||
for source in data["source"].split(" "):
|
||||
if source and source != " ":
|
||||
source = strip_web_protocol(string=source)
|
||||
if not self.does_field_content_exist(
|
||||
entry_id, field_id, source
|
||||
entry_id, FieldID.SOURCE, source
|
||||
):
|
||||
self.add_field_to_entry(entry_id, field_id)
|
||||
self.add_field_to_entry(entry_id, FieldID.SOURCE)
|
||||
self.update_entry_field(entry_id, -1, source, "replace")
|
||||
|
||||
def add_field_to_entry(self, entry_id: int, field_id: int) -> None:
|
||||
@@ -2313,7 +2298,7 @@ class Library:
|
||||
return self.tags[self._tag_id_to_index_map[int(tag_id)]]
|
||||
|
||||
def get_tag_cluster(self, tag_id: int) -> list[int]:
|
||||
"""Returns a list of Tag IDs that reference this Tag."""
|
||||
"""Returns a list of Tag IDs that reference this Tag as its parent."""
|
||||
if tag_id in self._tag_id_to_cluster_map:
|
||||
return self._tag_id_to_cluster_map[int(tag_id)]
|
||||
return []
|
||||
|
||||
486
tagstudio/src/core/media_types.py
Normal file
@@ -0,0 +1,486 @@
|
||||
# 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 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"
|
||||
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"
|
||||
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"
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
media_type: MediaType,
|
||||
extensions: set[str],
|
||||
is_iana: bool = False,
|
||||
) -> None:
|
||||
self.media_type: MediaType = media_type
|
||||
self.extensions: set[str] = extensions
|
||||
self.is_iana: bool = is_iana
|
||||
|
||||
|
||||
class MediaCategories:
|
||||
"""Contains 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",
|
||||
".blend10",
|
||||
".blend11",
|
||||
".blend12",
|
||||
".blend13",
|
||||
".blend14",
|
||||
".blend15",
|
||||
".blend16",
|
||||
".blend17",
|
||||
".blend18",
|
||||
".blend19",
|
||||
".blend2",
|
||||
".blend20",
|
||||
".blend21",
|
||||
".blend22",
|
||||
".blend23",
|
||||
".blend24",
|
||||
".blend25",
|
||||
".blend26",
|
||||
".blend27",
|
||||
".blend28",
|
||||
".blend29",
|
||||
".blend3",
|
||||
".blend30",
|
||||
".blend31",
|
||||
".blend32",
|
||||
".blend4",
|
||||
".blend5",
|
||||
".blend6",
|
||||
".blend7",
|
||||
".blend8",
|
||||
".blend9",
|
||||
}
|
||||
_DATABASE_SET: set[str] = {
|
||||
".accdb",
|
||||
".mdb",
|
||||
".sqlite",
|
||||
}
|
||||
_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",
|
||||
}
|
||||
_FONT_SET: set[str] = {
|
||||
".fon",
|
||||
".otf",
|
||||
".ttc",
|
||||
".ttf",
|
||||
".woff",
|
||||
".woff2",
|
||||
}
|
||||
_IMAGE_ANIMATED_SET: set[str] = {
|
||||
".apng",
|
||||
".gif",
|
||||
".webp",
|
||||
".jxl",
|
||||
}
|
||||
_IMAGE_RAW_SET: set[str] = {
|
||||
".arw",
|
||||
".cr2",
|
||||
".cr3",
|
||||
".crw",
|
||||
".dng",
|
||||
".nef",
|
||||
".orf",
|
||||
".raf",
|
||||
".raw",
|
||||
".rw2",
|
||||
}
|
||||
_IMAGE_VECTOR_SET: set[str] = {".svg"}
|
||||
_IMAGE_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"}
|
||||
_PACKAGE_SET: set[str] = {
|
||||
".aab",
|
||||
".akp",
|
||||
".apk",
|
||||
".apkm",
|
||||
".apks",
|
||||
".pkg",
|
||||
".xapk",
|
||||
}
|
||||
_PDF_SET: set[str] = {
|
||||
".pdf",
|
||||
}
|
||||
_PLAINTEXT_SET: set[str] = {
|
||||
".bat",
|
||||
".css",
|
||||
".csv",
|
||||
".htm",
|
||||
".html",
|
||||
".ini",
|
||||
".js",
|
||||
".json",
|
||||
".jsonc",
|
||||
".md",
|
||||
".php",
|
||||
".plist",
|
||||
".prefs",
|
||||
".sh",
|
||||
".ts",
|
||||
".txt",
|
||||
".xml",
|
||||
".vmt",
|
||||
".fgd",
|
||||
".nut",
|
||||
".cfg",
|
||||
".conf",
|
||||
".vdf",
|
||||
".vcfg",
|
||||
".gi",
|
||||
".inf",
|
||||
".vqlayout",
|
||||
".qss",
|
||||
".vsc",
|
||||
".kv3",
|
||||
".vsnd_template",
|
||||
}
|
||||
_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,
|
||||
)
|
||||
AFFINITY_PHOTO_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.AFFINITY_PHOTO,
|
||||
extensions=_AFFINITY_PHOTO_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
ARCHIVE_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.ARCHIVE,
|
||||
extensions=_ARCHIVE_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
AUDIO_MIDI_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.AUDIO_MIDI,
|
||||
extensions=_AUDIO_MIDI_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
AUDIO_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.AUDIO,
|
||||
extensions=_AUDIO_SET | _AUDIO_MIDI_SET,
|
||||
is_iana=True,
|
||||
)
|
||||
BLENDER_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.BLENDER,
|
||||
extensions=_BLENDER_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
DATABASE_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.DATABASE,
|
||||
extensions=_DATABASE_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
DISK_IMAGE_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.DISK_IMAGE,
|
||||
extensions=_DISK_IMAGE_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
DOCUMENT_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.DOCUMENT,
|
||||
extensions=_DOCUMENT_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
FONT_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.FONT,
|
||||
extensions=_FONT_SET,
|
||||
is_iana=True,
|
||||
)
|
||||
IMAGE_ANIMATED_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.IMAGE_ANIMATED,
|
||||
extensions=_IMAGE_ANIMATED_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
IMAGE_RAW_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.IMAGE_RAW,
|
||||
extensions=_IMAGE_RAW_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
IMAGE_VECTOR_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.IMAGE_VECTOR,
|
||||
extensions=_IMAGE_VECTOR_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
IMAGE_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.IMAGE,
|
||||
extensions=_IMAGE_SET | _IMAGE_RAW_SET | _IMAGE_VECTOR_SET,
|
||||
is_iana=True,
|
||||
)
|
||||
INSTALLER_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.INSTALLER,
|
||||
extensions=_INSTALLER_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
MATERIAL_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.MATERIAL,
|
||||
extensions=_MATERIAL_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
MODEL_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.MODEL,
|
||||
extensions=_MODEL_SET,
|
||||
is_iana=True,
|
||||
)
|
||||
PACKAGE_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.PACKAGE,
|
||||
extensions=_PACKAGE_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
PDF_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.PDF,
|
||||
extensions=_PDF_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
PLAINTEXT_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.PLAINTEXT,
|
||||
extensions=_PLAINTEXT_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
PRESENTATION_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.PRESENTATION,
|
||||
extensions=_PRESENTATION_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
PROGRAM_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.PROGRAM,
|
||||
extensions=_PROGRAM_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
SHORTCUT_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.SHORTCUT,
|
||||
extensions=_SHORTCUT_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
SOURCE_ENGINE_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.SOURCE_ENGINE,
|
||||
extensions=_SOURCE_ENGINE_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
SPREADSHEET_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.SPREADSHEET,
|
||||
extensions=_SPREADSHEET_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
TEXT_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.TEXT,
|
||||
extensions=_DOCUMENT_SET | _PLAINTEXT_SET,
|
||||
is_iana=True,
|
||||
)
|
||||
VIDEO_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.VIDEO,
|
||||
extensions=_VIDEO_SET,
|
||||
is_iana=True,
|
||||
)
|
||||
|
||||
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,
|
||||
FONT_TYPES,
|
||||
IMAGE_ANIMATED_TYPES,
|
||||
IMAGE_RAW_TYPES,
|
||||
IMAGE_TYPES,
|
||||
IMAGE_VECTOR_TYPES,
|
||||
INSTALLER_TYPES,
|
||||
MATERIAL_TYPES,
|
||||
MODEL_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]:
|
||||
"""Returns 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.
|
||||
"""
|
||||
types: set[MediaType] = set()
|
||||
mime_guess: bool = False
|
||||
|
||||
for cat in MediaCategories.ALL_CATEGORIES:
|
||||
if ext in cat.extensions:
|
||||
types.add(cat.media_type)
|
||||
elif mime_fallback and cat.is_iana:
|
||||
type: str = mimetypes.guess_type(Path("x" + ext), strict=False)[0]
|
||||
if type and type.startswith(cat.media_type.value):
|
||||
types.add(cat.media_type)
|
||||
mime_guess = True
|
||||
|
||||
# logging.info(
|
||||
# f"({ext}) Media Categories Found: {[x.value for x in types]}{' (MIME)' if mime_guess else ''}"
|
||||
# )
|
||||
return types
|
||||
@@ -13,7 +13,7 @@ class ColorType(int, Enum):
|
||||
DARK_ACCENT = 4
|
||||
|
||||
|
||||
_TAG_COLORS = {
|
||||
_TAG_COLORS: dict = {
|
||||
"": {
|
||||
ColorType.PRIMARY: "#1e1e1e",
|
||||
ColorType.TEXT: ColorType.LIGHT_ACCENT,
|
||||
@@ -277,13 +277,58 @@ _TAG_COLORS = {
|
||||
},
|
||||
}
|
||||
|
||||
_UI_COLORS: dict = {
|
||||
"": {
|
||||
ColorType.PRIMARY: "#333333",
|
||||
ColorType.BORDER: "#555555",
|
||||
ColorType.LIGHT_ACCENT: "#FFFFFF",
|
||||
ColorType.DARK_ACCENT: "#1e1e1e",
|
||||
},
|
||||
"red": {
|
||||
ColorType.PRIMARY: "#e22c3c",
|
||||
ColorType.BORDER: "#e54252",
|
||||
ColorType.LIGHT_ACCENT: "#f39caa",
|
||||
ColorType.DARK_ACCENT: "#440d12",
|
||||
},
|
||||
"green": {
|
||||
ColorType.PRIMARY: "#28bb48",
|
||||
ColorType.BORDER: "#43c568",
|
||||
ColorType.LIGHT_ACCENT: "#DDFFCC",
|
||||
ColorType.DARK_ACCENT: "#0d3828",
|
||||
},
|
||||
"purple": {
|
||||
ColorType.PRIMARY: "#C76FF3",
|
||||
ColorType.BORDER: "#c364f2",
|
||||
ColorType.LIGHT_ACCENT: "#EFD4FB",
|
||||
ColorType.DARK_ACCENT: "#3E1555",
|
||||
},
|
||||
"theme_dark": {
|
||||
ColorType.PRIMARY: "#333333",
|
||||
ColorType.BORDER: "#555555",
|
||||
ColorType.LIGHT_ACCENT: "#FFFFFF",
|
||||
ColorType.DARK_ACCENT: "#1e1e1e",
|
||||
},
|
||||
"theme_light": {
|
||||
ColorType.PRIMARY: "#FFFFFF",
|
||||
ColorType.BORDER: "#333333",
|
||||
ColorType.LIGHT_ACCENT: "#999999",
|
||||
ColorType.DARK_ACCENT: "#888888",
|
||||
},
|
||||
}
|
||||
|
||||
def get_tag_color(type, color):
|
||||
|
||||
def get_tag_color(color_type, color):
|
||||
color = color.lower()
|
||||
try:
|
||||
if type == ColorType.TEXT:
|
||||
return get_tag_color(_TAG_COLORS[color][type], color)
|
||||
if color_type == ColorType.TEXT:
|
||||
return get_tag_color(_TAG_COLORS[color][color_type], color)
|
||||
else:
|
||||
return _TAG_COLORS[color][type]
|
||||
return _TAG_COLORS[color][color_type]
|
||||
except KeyError:
|
||||
return "#FF00FF"
|
||||
|
||||
|
||||
def get_ui_color(color_type: ColorType, color: str):
|
||||
"""Returns a hex value given a color name and ColorType."""
|
||||
color = color.lower()
|
||||
return _UI_COLORS.get(color).get(color_type)
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from enum import Enum
|
||||
|
||||
from src.core.library import Entry, Library
|
||||
from src.core.constants import TS_FOLDER_NAME, TEXT_FIELDS
|
||||
@@ -30,7 +31,7 @@ class TagStudioCore:
|
||||
json_dump = {}
|
||||
info = {}
|
||||
_filepath: Path = Path(filepath)
|
||||
_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.
|
||||
|
||||
109
tagstudio/src/qt/helpers/blender_thumbnailer.py
Normal file
@@ -0,0 +1,109 @@
|
||||
#!/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 #####
|
||||
|
||||
# <pep8 compliant>
|
||||
|
||||
|
||||
## This file is a modified script that gets the thumbnail data stored in a blend file
|
||||
|
||||
|
||||
import struct
|
||||
from PIL import (
|
||||
Image,
|
||||
ImageOps,
|
||||
)
|
||||
import gzip
|
||||
import os
|
||||
|
||||
|
||||
def blend_extract_thumb(path):
|
||||
REND = b"REND"
|
||||
TEST = b"TEST"
|
||||
|
||||
blendfile = open(path, "rb")
|
||||
|
||||
head = blendfile.read(12)
|
||||
|
||||
if head[0:2] == b"\x1f\x8b": # gzip magic
|
||||
blendfile.close()
|
||||
blendfile = gzip.GzipFile("", "rb", 0, open(path, "rb"))
|
||||
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 "<i"
|
||||
int_endian_pair = int_endian + "i"
|
||||
|
||||
while True:
|
||||
bhead = blendfile.read(sizeof_bhead)
|
||||
|
||||
if len(bhead) < sizeof_bhead:
|
||||
return None, 0, 0
|
||||
|
||||
code = bhead[:4]
|
||||
length = struct.unpack(int_endian, bhead[4:8])[0] # 4 == sizeof(int)
|
||||
|
||||
if code == REND:
|
||||
blendfile.seek(length, os.SEEK_CUR)
|
||||
else:
|
||||
break
|
||||
|
||||
if code != TEST:
|
||||
return None, 0, 0
|
||||
|
||||
try:
|
||||
x, y = struct.unpack(int_endian_pair, blendfile.read(8)) # 8 == sizeof(int) * 2
|
||||
except struct.error:
|
||||
return None, 0, 0
|
||||
|
||||
length -= 8 # sizeof(int) * 2
|
||||
|
||||
if length != x * y * 4:
|
||||
return None, 0, 0
|
||||
|
||||
image_buffer = blendfile.read(length)
|
||||
|
||||
if len(image_buffer) != length:
|
||||
return None, 0, 0
|
||||
|
||||
return image_buffer, x, y
|
||||
|
||||
|
||||
def blend_thumb(file_in):
|
||||
buf, width, height = blend_extract_thumb(file_in)
|
||||
image = Image.frombuffer(
|
||||
"RGBA",
|
||||
(width, height),
|
||||
buf,
|
||||
)
|
||||
image = ImageOps.flip(image)
|
||||
return image
|
||||
@@ -10,23 +10,28 @@ from src.qt.helpers.gradient import linear_gradient
|
||||
|
||||
# TODO: Consolidate the built-in QT theme values with the values
|
||||
# here, in enums.py, and in palette.py.
|
||||
_THEME_DARK_FG: str = "#FFFFFF55"
|
||||
_THEME_DARK_FG: str = "#FFFFFF77"
|
||||
_THEME_LIGHT_FG: str = "#000000DD"
|
||||
_THEME_DARK_BG: str = "#000000DD"
|
||||
_THEME_LIGHT_BG: str = "#FFFFFF55"
|
||||
|
||||
|
||||
def theme_fg_overlay(image: Image.Image) -> 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.
|
||||
"""
|
||||
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
|
||||
dark_fg
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else _THEME_LIGHT_FG
|
||||
else light_fg
|
||||
)
|
||||
|
||||
im = Image.new(mode="RGBA", size=image.size, color=overlay_color)
|
||||
return _apply_overlay(image, im)
|
||||
|
||||
|
||||
30
tagstudio/src/qt/helpers/file_deleter.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from send2trash import send2trash
|
||||
|
||||
logging.basicConfig(format="%(message)s", level=logging.INFO)
|
||||
|
||||
|
||||
def delete_file(path: str | Path) -> bool:
|
||||
"""Sends a file to the system trash.
|
||||
|
||||
Args:
|
||||
path (str | Path): The path of the file to delete.
|
||||
"""
|
||||
_path = Path(path)
|
||||
try:
|
||||
logging.info(f"[delete_file] Sending to Trash: {_path}")
|
||||
send2trash(_path)
|
||||
return True
|
||||
except PermissionError as e:
|
||||
logging.error(f"[delete_file][ERROR] PermissionError: {e}")
|
||||
except FileNotFoundError:
|
||||
logging.error(f"[delete_file][ERROR] File Not Found: {_path}")
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
return False
|
||||
31
tagstudio/src/qt/helpers/file_tester.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
import ffmpeg
|
||||
from pathlib import Path
|
||||
|
||||
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):
|
||||
"""
|
||||
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
|
||||
@@ -2,24 +2,14 @@
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
from PIL import Image, ImageEnhance, ImageChops
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def four_corner_gradient_background(
|
||||
image: Image.Image, adj_size, mask, hl
|
||||
def four_corner_gradient(
|
||||
image: Image.Image, size: tuple[int, int], mask: Image.Image
|
||||
) -> 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)
|
||||
|
||||
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)))
|
||||
@@ -29,26 +19,25 @@ def four_corner_gradient_background(
|
||||
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
|
||||
|
||||
|
||||
|
||||
16
tagstudio/src/qt/helpers/qbutton_wrapper.py
Normal file
@@ -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
|
||||
|
||||
from PySide6.QtWidgets import QPushButton
|
||||
|
||||
|
||||
class QPushButtonWrapper(QPushButton):
|
||||
"""
|
||||
This is a customized implementation of the PySide6 QPushButton that allows to suppress the warning that is triggered
|
||||
by disconnecting a signal that is not currently connected.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.is_connected = False
|
||||
31
tagstudio/src/qt/helpers/rounded_pixmap_style.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# 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):
|
||||
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(RoundedPixmapStyle, self).drawItemPixmap(
|
||||
painter, rectangle, alignment, pix
|
||||
)
|
||||
painter.restore()
|
||||
64
tagstudio/src/qt/helpers/silent_popen.py
Normal file
@@ -0,0 +1,64 @@
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def promptless_Popen(
|
||||
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,
|
||||
):
|
||||
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,
|
||||
)
|
||||
51
tagstudio/src/qt/helpers/text_wrapper.py
Normal file
@@ -0,0 +1,51 @@
|
||||
# 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:
|
||||
"""
|
||||
Takes in a single line and returns the index it should be broken up at but
|
||||
it only splits one Time
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
Takes in a string and breaks it up to fit in the canvas given accounts for kerning and 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
|
||||
34
tagstudio/src/qt/helpers/vendored/ffmpeg.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# 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 subprocess
|
||||
import json
|
||||
import sys
|
||||
|
||||
import ffmpeg
|
||||
|
||||
from src.qt.helpers.silent_popen import promptless_Popen
|
||||
|
||||
def _probe(filename, cmd='ffprobe', 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'))
|
||||
1406
tagstudio/src/qt/helpers/vendored/pydub/audio_segment.py
Normal file
88
tagstudio/src/qt/helpers/vendored/pydub/utils.py
Normal file
@@ -0,0 +1,88 @@
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
from pydub.utils import (
|
||||
get_prober_name,
|
||||
fsdecode,
|
||||
_fd_or_path_or_tempfile,
|
||||
get_extra_info,
|
||||
)
|
||||
|
||||
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
|
||||
@@ -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")
|
||||
|
||||
@@ -83,17 +83,17 @@ class Ui_MainWindow(QMainWindow):
|
||||
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()
|
||||
@@ -212,10 +212,10 @@ class Ui_MainWindow(QMainWindow):
|
||||
# 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
|
||||
|
||||
|
||||
@@ -10,20 +10,21 @@ from PySide6.QtWidgets import (
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QComboBox,
|
||||
QListWidget,
|
||||
)
|
||||
|
||||
from src.core.library import Library
|
||||
|
||||
|
||||
class AddFieldModal(QWidget):
|
||||
done = Signal(int)
|
||||
done = Signal(list)
|
||||
|
||||
def __init__(self, library: "Library"):
|
||||
# [Done]
|
||||
# - OR -
|
||||
# [Cancel] [Save]
|
||||
super().__init__()
|
||||
self.is_connected = False
|
||||
self.lib = library
|
||||
self.setWindowTitle(f"Add Field")
|
||||
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
@@ -37,31 +38,25 @@ class AddFieldModal(QWidget):
|
||||
self.title_widget.setStyleSheet(
|
||||
# 'background:blue;'
|
||||
# 'text-align:center;'
|
||||
"font-weight:bold;" "font-size:14px;" "padding-top: 6px" ""
|
||||
"font-weight:bold;" "font-size:14px;" "padding-top: 6px"
|
||||
)
|
||||
self.title_widget.setText("Add Field")
|
||||
self.title_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
self.combo_box = QComboBox()
|
||||
self.combo_box.setEditable(False)
|
||||
# self.combo_box.setMaxVisibleItems(5)
|
||||
self.combo_box.setStyleSheet("combobox-popup:0;")
|
||||
self.combo_box.view().setVerticalScrollBarPolicy(
|
||||
Qt.ScrollBarPolicy.ScrollBarAsNeeded
|
||||
)
|
||||
self.list_widget = QListWidget()
|
||||
self.list_widget.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection)
|
||||
|
||||
items = []
|
||||
for df in self.lib.default_fields:
|
||||
self.combo_box.addItem(
|
||||
f'{df["name"]} ({df["type"].replace("_", " ").title()})'
|
||||
)
|
||||
items.append(f'{df["name"]} ({df["type"].replace("_", " ").title()})')
|
||||
|
||||
self.list_widget.addItems(items)
|
||||
|
||||
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.cancel_button = QPushButton()
|
||||
# self.cancel_button.setText('Cancel')
|
||||
|
||||
self.cancel_button = QPushButton()
|
||||
self.cancel_button.setText("Cancel")
|
||||
self.cancel_button.clicked.connect(self.hide)
|
||||
@@ -74,17 +69,11 @@ class AddFieldModal(QWidget):
|
||||
self.save_button.setDefault(True)
|
||||
self.save_button.clicked.connect(self.hide)
|
||||
self.save_button.clicked.connect(
|
||||
lambda: self.done.emit(self.combo_box.currentIndex())
|
||||
lambda: self.done.emit(self.list_widget.selectedIndexes())
|
||||
)
|
||||
# self.save_button.clicked.connect(lambda: save_callback(widget.get_content()))
|
||||
self.button_layout.addWidget(self.save_button)
|
||||
|
||||
# self.returnPressed.connect(lambda: self.done.emit(self.combo_box.currentIndex()))
|
||||
|
||||
# self.done.connect(lambda x: callback(x))
|
||||
|
||||
self.root_layout.addWidget(self.title_widget)
|
||||
self.root_layout.addWidget(self.combo_box)
|
||||
# self.root_layout.setStretch(1,2)
|
||||
self.root_layout.addStretch(1)
|
||||
self.root_layout.addWidget(self.list_widget)
|
||||
|
||||
self.root_layout.addWidget(self.button_container)
|
||||
|
||||
@@ -5,30 +5,28 @@
|
||||
|
||||
import logging
|
||||
|
||||
from PySide6.QtCore import Signal, Qt
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QLineEdit,
|
||||
QScrollArea,
|
||||
QFrame,
|
||||
QTextEdit,
|
||||
QComboBox,
|
||||
QFrame,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QTextEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from src.core.constants import TAG_COLORS
|
||||
from src.core.library import Library, Tag
|
||||
from src.core.palette import ColorType, get_tag_color
|
||||
from src.core.constants import TAG_COLORS
|
||||
from src.qt.widgets.panel import PanelWidget, PanelModal
|
||||
from src.qt.widgets.tag import TagWidget
|
||||
from src.qt.modals.tag_search import TagSearchPanel
|
||||
from src.qt.widgets.panel import PanelModal, PanelWidget
|
||||
from src.qt.widgets.tag import TagWidget
|
||||
|
||||
|
||||
ERROR = f"[ERROR]"
|
||||
WARNING = f"[WARNING]"
|
||||
INFO = f"[INFO]"
|
||||
ERROR = "[ERROR]"
|
||||
WARNING = "[WARNING]"
|
||||
INFO = "[INFO]"
|
||||
|
||||
logging.basicConfig(format="%(message)s", level=logging.INFO)
|
||||
|
||||
@@ -36,7 +34,7 @@ logging.basicConfig(format="%(message)s", level=logging.INFO)
|
||||
class BuildTagPanel(PanelWidget):
|
||||
on_edit = Signal(Tag)
|
||||
|
||||
def __init__(self, library, tag_id: int = -1):
|
||||
def __init__(self, library, tag_id: int = -1, tag_name: str = "New Tag"):
|
||||
super().__init__()
|
||||
self.lib: Library = library
|
||||
# self.callback = callback
|
||||
@@ -117,7 +115,7 @@ class BuildTagPanel(PanelWidget):
|
||||
self.subtags_add_button = QPushButton()
|
||||
self.subtags_add_button.setText("+")
|
||||
tsp = TagSearchPanel(self.lib)
|
||||
tsp.tag_chosen.connect(lambda x: self.add_subtag_callback(x))
|
||||
tsp.tag_created.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)
|
||||
@@ -163,7 +161,7 @@ class BuildTagPanel(PanelWidget):
|
||||
if tag_id >= 0:
|
||||
self.tag = self.lib.get_tag(tag_id)
|
||||
else:
|
||||
self.tag = Tag(-1, "New Tag", "", [], [], "")
|
||||
self.tag = Tag(-1, tag_name, "", [], [], "")
|
||||
self.set_tag(self.tag)
|
||||
|
||||
def add_subtag_callback(self, tag_id: int):
|
||||
|
||||
245
tagstudio/src/qt/modals/drop_import.py
Normal file
@@ -0,0 +1,245 @@
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import typing
|
||||
|
||||
from PySide6.QtCore import QThreadPool
|
||||
from PySide6.QtGui import QDropEvent, QDragEnterEvent, QDragMoveEvent
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
from src.qt.widgets.progress import ProgressWidget
|
||||
from src.qt.helpers.custom_runnable import CustomRunnable
|
||||
from src.qt.helpers.function_iterator import FunctionIterator
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from src.qt.ts_qt import QtDriver
|
||||
|
||||
import logging
|
||||
|
||||
|
||||
class DropImport:
|
||||
def __init__(self, driver: "QtDriver"):
|
||||
self.driver = driver
|
||||
|
||||
def dropEvent(self, event: QDropEvent):
|
||||
if (
|
||||
event.source() is self.driver
|
||||
): # change that if you want to drop something originating from tagstudio, for moving or so
|
||||
return
|
||||
|
||||
if not event.mimeData().hasUrls():
|
||||
return
|
||||
|
||||
self.urls = event.mimeData().urls()
|
||||
self.import_files()
|
||||
|
||||
def dragEnterEvent(self, event: QDragEnterEvent):
|
||||
if event.mimeData().hasUrls():
|
||||
event.accept()
|
||||
else:
|
||||
event.ignore()
|
||||
|
||||
def dragMoveEvent(self, event: QDragMoveEvent):
|
||||
if event.mimeData().hasUrls():
|
||||
event.accept()
|
||||
else:
|
||||
logging.info(self.driver.selected)
|
||||
event.ignore()
|
||||
|
||||
def import_files(self):
|
||||
self.files: list[Path] = []
|
||||
self.dirs_in_root: list[Path] = []
|
||||
self.duplicate_files: list[Path] = []
|
||||
|
||||
def displayed_text(x):
|
||||
text = f"Searching New Files...\n{x[0]+1} File{'s' if x[0]+1 != 1 else ''} Found."
|
||||
if x[1] == 0:
|
||||
return text
|
||||
return text + f" {x[1]} Already exist in the library folders"
|
||||
|
||||
create_progress_bar(
|
||||
self.collect_files_to_import,
|
||||
"Searching Files",
|
||||
"Searching New Files...\nPreparing...",
|
||||
displayed_text,
|
||||
self.ask_user,
|
||||
)
|
||||
|
||||
def collect_files_to_import(self):
|
||||
for url in self.urls:
|
||||
if not url.isLocalFile():
|
||||
continue
|
||||
|
||||
file = Path(url.toLocalFile())
|
||||
|
||||
if file.is_dir():
|
||||
for f in self.get_files_in_folder(file):
|
||||
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)
|
||||
yield [len(self.files), len(self.duplicate_files)]
|
||||
|
||||
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)
|
||||
|
||||
yield [len(self.files), len(self.duplicate_files)]
|
||||
|
||||
def copy_files(self):
|
||||
fileCount = 0
|
||||
duplicated_files_progress = 0
|
||||
for file in self.files:
|
||||
if file.is_dir():
|
||||
continue
|
||||
|
||||
dest_file = self.get_relative_path(file)
|
||||
full_dest_path: Path = self.driver.lib.library_dir / dest_file
|
||||
|
||||
if file in self.duplicate_files:
|
||||
duplicated_files_progress += 1
|
||||
if self.choice == 0: # skip duplicates
|
||||
continue
|
||||
|
||||
if self.choice == 2: # rename
|
||||
new_name = self.get_renamed_duplicate_filename_in_lib(dest_file)
|
||||
dest_file = dest_file.with_name(new_name)
|
||||
self.driver.lib.files_not_in_library.append(full_dest_path)
|
||||
else: # override is simply copying but not adding a new entry
|
||||
self.driver.lib.files_not_in_library.append(full_dest_path)
|
||||
|
||||
(full_dest_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(file, full_dest_path)
|
||||
|
||||
fileCount += 1
|
||||
yield [fileCount, duplicated_files_progress]
|
||||
|
||||
def ask_user(self):
|
||||
self.choice = -1
|
||||
|
||||
if len(self.duplicate_files) > 0:
|
||||
self.choice = self.duplicates_choice()
|
||||
|
||||
if self.choice == 3: # cancel
|
||||
return
|
||||
|
||||
def displayed_text(x):
|
||||
dupes_choice_text = (
|
||||
"Skipped"
|
||||
if self.choice == 0
|
||||
else ("Overridden" if self.choice == 1 else "Renamed")
|
||||
)
|
||||
|
||||
text = f"Importing New Files...\n{x[0]+1} File{'s' if x[0]+1 != 1 else ''} Imported."
|
||||
if x[1] == 0:
|
||||
return text
|
||||
return text + f" {x[1]} {dupes_choice_text}"
|
||||
|
||||
create_progress_bar(
|
||||
self.copy_files,
|
||||
"Import Files",
|
||||
"Importing New Files...\nPreparing...",
|
||||
displayed_text,
|
||||
self.driver.add_new_files_runnable,
|
||||
len(self.files),
|
||||
)
|
||||
|
||||
def duplicates_choice(self) -> int:
|
||||
display_limit: int = 5
|
||||
msgBox = QMessageBox()
|
||||
msgBox.setWindowTitle(
|
||||
f"File Conflict{'s' if len(self.duplicate_files) > 1 else ''}"
|
||||
)
|
||||
|
||||
dupes_to_show = self.duplicate_files
|
||||
if len(self.duplicate_files) > display_limit:
|
||||
dupes_to_show = dupes_to_show[0:display_limit]
|
||||
|
||||
dupes_str = "\n ".join(map(lambda path: str(path), dupes_to_show))
|
||||
dupes_more = (
|
||||
f"\nand {len(self.duplicate_files)-display_limit} more "
|
||||
if len(self.duplicate_files) > display_limit
|
||||
else "\n"
|
||||
)
|
||||
|
||||
msgBox.setText(
|
||||
f"The following files:\n {dupes_str}{dupes_more}have filenames that already exist in the library folder."
|
||||
)
|
||||
msgBox.addButton("Skip", QMessageBox.ButtonRole.YesRole)
|
||||
msgBox.addButton("Override", QMessageBox.ButtonRole.DestructiveRole)
|
||||
msgBox.addButton("Rename", QMessageBox.ButtonRole.DestructiveRole)
|
||||
msgBox.addButton("Cancel", QMessageBox.ButtonRole.NoRole)
|
||||
return msgBox.exec()
|
||||
|
||||
def get_files_exists_in_library(self, path: Path) -> list[Path]:
|
||||
exists: list[Path] = []
|
||||
if not path.is_dir():
|
||||
return exists
|
||||
|
||||
files = self.get_files_in_folder(path)
|
||||
for file in files:
|
||||
if file.is_dir():
|
||||
exists += self.get_files_exists_in_library(file)
|
||||
elif (self.driver.lib.library_dir / self.get_relative_path(file)).exists():
|
||||
exists.append(file)
|
||||
return exists
|
||||
|
||||
def get_relative_paths(self, paths: list[Path]) -> list[Path]:
|
||||
relative_paths = []
|
||||
for file in paths:
|
||||
relative_paths.append(self.get_relative_path(file))
|
||||
return relative_paths
|
||||
|
||||
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_files_in_folder(self, path: Path) -> list[Path]:
|
||||
files = []
|
||||
for file in path.glob("**/*"):
|
||||
files.append(file)
|
||||
return files
|
||||
|
||||
def get_renamed_duplicate_filename_in_lib(self, filePath: Path) -> str:
|
||||
index = 2
|
||||
o_filename = filePath.name
|
||||
dot_idx = o_filename.index(".")
|
||||
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
|
||||
|
||||
|
||||
def create_progress_bar(
|
||||
function, title: str, text: str, update_label_callback, done_callback, max=0
|
||||
):
|
||||
iterator = FunctionIterator(function)
|
||||
pw = ProgressWidget(
|
||||
window_title=title,
|
||||
label_text=text,
|
||||
cancel_button_text=None,
|
||||
minimum=0,
|
||||
maximum=max,
|
||||
)
|
||||
pw.show()
|
||||
iterator.value.connect(lambda x: pw.update_progress(x[0] + 1))
|
||||
iterator.value.connect(lambda x: pw.update_label(update_label_callback(x)))
|
||||
r = CustomRunnable(lambda: iterator.run())
|
||||
r.done.connect(lambda: (pw.hide(), done_callback())) # type: ignore
|
||||
QThreadPool.globalInstance().start(r)
|
||||
65
tagstudio/src/qt/modals/ffmpeg_checker.py
Normal file
@@ -0,0 +1,65 @@
|
||||
import logging
|
||||
import math
|
||||
from pathlib import Path
|
||||
from shutil import which
|
||||
import subprocess
|
||||
|
||||
from PIL import Image, ImageQt
|
||||
from PySide6.QtCore import Signal, Qt, QUrl
|
||||
from PySide6.QtGui import QPixmap, QDesktopServices
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
|
||||
class FfmpegChecker(QMessageBox):
|
||||
"""A warning dialog for if FFmpeg is missing."""
|
||||
|
||||
HELP_URL = "https://docs.tagstud.io/help/ffmpeg/"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self.setWindowTitle("Warning: Missing dependency")
|
||||
self.setText("Warning: Could not find FFmpeg installation")
|
||||
self.setIcon(QMessageBox.Warning)
|
||||
# Blocks other application interactions until resolved
|
||||
self.setWindowModality(Qt.ApplicationModal)
|
||||
|
||||
self.setStandardButtons(
|
||||
QMessageBox.Help | QMessageBox.Ignore | QMessageBox.Cancel
|
||||
)
|
||||
self.setDefaultButton(QMessageBox.Ignore)
|
||||
# Enables the cancel button but hides it to allow for click X to close dialog
|
||||
self.button(QMessageBox.Cancel).hide()
|
||||
|
||||
self.ffmpeg = False
|
||||
self.ffprobe = False
|
||||
|
||||
def installed(self):
|
||||
"""Checks if both FFmpeg and FFprobe are installed and in the PATH."""
|
||||
if which("ffmpeg"):
|
||||
self.ffmpeg = True
|
||||
if which("ffprobe"):
|
||||
self.ffprobe = True
|
||||
|
||||
logging.info(
|
||||
f"[FFmpegChecker] FFmpeg found: {self.ffmpeg}, FFprobe found: {self.ffprobe}"
|
||||
)
|
||||
return self.ffmpeg and self.ffprobe
|
||||
|
||||
def show_warning(self):
|
||||
"""Displays the warning to the user and awaits respone."""
|
||||
missing = "FFmpeg"
|
||||
# If ffmpeg is installed but not ffprobe
|
||||
if not self.ffprobe and self.ffmpeg:
|
||||
missing = "FFprobe"
|
||||
|
||||
self.setText(f"Warning: Could not find {missing} installation")
|
||||
self.setInformativeText(
|
||||
f"{missing} is required for multimedia thumbnails and playback"
|
||||
)
|
||||
# Shows the dialog
|
||||
selection = self.exec()
|
||||
|
||||
# Selection will either be QMessageBox.Help or (QMessageBox.Ignore | QMessageBox.Cancel) which can be ignored
|
||||
if selection == QMessageBox.Help:
|
||||
QDesktopServices.openUrl(QUrl(self.HELP_URL))
|
||||
@@ -110,4 +110,4 @@ class FileExtensionModal(PanelWidget):
|
||||
for i in range(self.table.rowCount()):
|
||||
ext = self.table.item(i, 0)
|
||||
if ext and ext.text():
|
||||
self.lib.ext_list.append(ext.text())
|
||||
self.lib.ext_list.append(ext.text().lower())
|
||||
|
||||
@@ -18,6 +18,7 @@ from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
)
|
||||
|
||||
from src.core.enums import FieldID
|
||||
from src.core.library import Library, Tag
|
||||
from src.core.palette import ColorType, get_tag_color
|
||||
from src.qt.flowlayout import FlowLayout
|
||||
@@ -60,7 +61,7 @@ def folders_to_tags(library: Library):
|
||||
library.add_tag_to_library(new_tag)
|
||||
branch["dirs"][folder] = dict(dirs={}, tag=new_tag)
|
||||
branch = branch["dirs"][folder]
|
||||
return branch["tag"]
|
||||
return branch.get("tag")
|
||||
|
||||
for tag in library.tags:
|
||||
reversed_tag = reverse_tag(library, tag, None)
|
||||
@@ -73,13 +74,13 @@ def folders_to_tags(library: Library):
|
||||
tag = add_folders_to_tree(folders)
|
||||
if tag:
|
||||
if not entry.has_tag(library, tag.id):
|
||||
entry.add_tag(library, tag.id, 6)
|
||||
entry.add_tag(library, tag.id, FieldID.TAGS)
|
||||
|
||||
logging.info("Done")
|
||||
|
||||
|
||||
def reverse_tag(library: Library, tag: Tag, list: list[Tag]) -> list[Tag]:
|
||||
if list != None:
|
||||
if list is not None:
|
||||
list.append(tag)
|
||||
else:
|
||||
list = [tag]
|
||||
@@ -144,7 +145,7 @@ def generate_preview_data(library: Library):
|
||||
if cut:
|
||||
branch["dirs"].pop(folder)
|
||||
|
||||
if not "tag" in branch:
|
||||
if "tag" not in branch:
|
||||
return
|
||||
if branch["tag"].id == -1 or len(branch["files"]) > 0: # Needs to be first
|
||||
return False
|
||||
@@ -289,7 +290,7 @@ class TreeItem(QWidget):
|
||||
self.children_layout.addWidget(item)
|
||||
for file in data["files"]:
|
||||
label = QLabel()
|
||||
label.setText(" -> " + file)
|
||||
label.setText(" -> " + str(file))
|
||||
self.children_layout.addWidget(label)
|
||||
|
||||
if len(data["files"]) == 0 and len(data["dirs"].values()) == 0:
|
||||
@@ -321,7 +322,7 @@ class ModifiedTagWidget(
|
||||
|
||||
self.bg_button = QPushButton(self)
|
||||
self.bg_button.setFlat(True)
|
||||
if parentTag != None:
|
||||
if parentTag is not None:
|
||||
text = f"{tag.name} ({parentTag.name})".replace("&", "&&")
|
||||
else:
|
||||
text = tag.name.replace("&", "&&")
|
||||
@@ -341,8 +342,8 @@ class ModifiedTagWidget(
|
||||
f"font-weight: 600;"
|
||||
f"border-color:{get_tag_color(ColorType.BORDER, tag.color)};"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:inset;"
|
||||
f"border-width: {math.ceil(1*self.devicePixelRatio())}px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: {math.ceil(self.devicePixelRatio())}px;"
|
||||
f"padding-right: 4px;"
|
||||
f"padding-bottom: 1px;"
|
||||
f"padding-left: 4px;"
|
||||
|
||||
@@ -2,32 +2,37 @@
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
from PySide6.QtCore import Signal, Qt, QSize
|
||||
import typing
|
||||
|
||||
from PySide6.QtCore import QSize, Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLineEdit,
|
||||
QScrollArea,
|
||||
QFrame,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from src.core.library import Library
|
||||
from src.qt.widgets.panel import PanelWidget, PanelModal
|
||||
from src.qt.widgets.tag import TagWidget
|
||||
from src.core.constants import TAG_COLORS
|
||||
from src.qt.modals.build_tag import BuildTagPanel
|
||||
from src.qt.widgets.panel import PanelModal, PanelWidget
|
||||
from src.qt.widgets.tag import TagWidget
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
if typing.TYPE_CHECKING:
|
||||
from src.core.library import Library, Tag
|
||||
from src.qt.ts_qt import QtDriver
|
||||
|
||||
|
||||
class TagDatabasePanel(PanelWidget):
|
||||
tag_chosen = Signal(int)
|
||||
|
||||
def __init__(self, library):
|
||||
def __init__(self, library: "Library", driver: "QtDriver"):
|
||||
super().__init__()
|
||||
self.lib: Library = library
|
||||
# self.callback = callback
|
||||
self.driver: QtDriver = driver
|
||||
self.first_tag_id = -1
|
||||
self.tag_limit = 30
|
||||
# self.selected_tag: int = 0
|
||||
|
||||
self.setMinimumSize(300, 400)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
@@ -103,8 +108,28 @@ class TagDatabasePanel(PanelWidget):
|
||||
# Get tag ids to keep this behaviorally identical
|
||||
tags = [t.id for t in self.lib.tags]
|
||||
|
||||
if query:
|
||||
# sort tags by whether the tag's name is the text that's matching the search, alphabetically, and then by color
|
||||
sorted_tags = sorted(
|
||||
tags,
|
||||
key=lambda tag_id: (
|
||||
not self.lib.get_tag(tag_id).name.lower().startswith(query.lower()),
|
||||
self.lib.get_tag(tag_id).display_name(self.lib),
|
||||
TAG_COLORS.index(self.lib.get_tag(tag_id).color.lower()),
|
||||
),
|
||||
)
|
||||
else:
|
||||
# sort tags by color and then alphabetically
|
||||
sorted_tags = sorted(
|
||||
tags,
|
||||
key=lambda tag_id: (
|
||||
TAG_COLORS.index(self.lib.get_tag(tag_id).color.lower()),
|
||||
self.lib.get_tag(tag_id).display_name(self.lib),
|
||||
),
|
||||
)
|
||||
|
||||
first_id_set = False
|
||||
for tag_id in tags:
|
||||
for tag_id in sorted_tags:
|
||||
if not first_id_set:
|
||||
self.first_tag_id = tag_id
|
||||
first_id_set = True
|
||||
@@ -112,9 +137,14 @@ class TagDatabasePanel(PanelWidget):
|
||||
row = QHBoxLayout(container)
|
||||
row.setContentsMargins(0, 0, 0, 0)
|
||||
row.setSpacing(3)
|
||||
tw = TagWidget(self.lib, self.lib.get_tag(tag_id), True, False)
|
||||
tw.on_edit.connect(
|
||||
lambda checked=False, t=self.lib.get_tag(tag_id): (self.edit_tag(t.id))
|
||||
tag: Tag = self.lib.get_tag(tag_id)
|
||||
tw = TagWidget(self.lib, tag, True, False)
|
||||
tw.on_edit.connect(lambda checked=False, t=tag: (self.edit_tag(t.id)))
|
||||
tw.on_click.connect(
|
||||
lambda checked=False, q=f"tag_id: {tag_id}": (
|
||||
self.driver.main_window.searchField.setText(q),
|
||||
self.driver.filter_items(q),
|
||||
)
|
||||
)
|
||||
row.addWidget(tw)
|
||||
self.scroll_layout.addWidget(container)
|
||||
|
||||
@@ -5,41 +5,38 @@
|
||||
|
||||
import logging
|
||||
import math
|
||||
|
||||
from PySide6.QtCore import Signal, Qt, QSize
|
||||
import src.qt.modals.build_tag as bt
|
||||
from PySide6.QtCore import QSize, Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QPushButton,
|
||||
QLineEdit,
|
||||
QScrollArea,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from src.core.constants import TAG_COLORS
|
||||
from src.core.library import Library
|
||||
from src.core.palette import ColorType, get_tag_color
|
||||
from src.qt.widgets.panel import PanelWidget
|
||||
from src.qt.widgets.panel import PanelModal, PanelWidget
|
||||
from src.qt.widgets.tag import TagWidget
|
||||
|
||||
|
||||
ERROR = f"[ERROR]"
|
||||
WARNING = f"[WARNING]"
|
||||
INFO = f"[INFO]"
|
||||
ERROR = "[ERROR]"
|
||||
WARNING = "[WARNING]"
|
||||
INFO = "[INFO]"
|
||||
|
||||
logging.basicConfig(format="%(message)s", level=logging.INFO)
|
||||
|
||||
|
||||
class TagSearchPanel(PanelWidget):
|
||||
tag_chosen = Signal(int)
|
||||
tag_created = Signal(int)
|
||||
|
||||
def __init__(self, library):
|
||||
def __init__(self, library: "Library"):
|
||||
super().__init__()
|
||||
self.lib: Library = library
|
||||
# self.callback = callback
|
||||
self.first_tag_id = None
|
||||
self.first_tag_id: int | None = None
|
||||
self.tag_limit = 100
|
||||
# self.selected_tag: int = 0
|
||||
self.setMinimumSize(300, 400)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(6, 0, 6, 0)
|
||||
@@ -55,62 +52,64 @@ class TagSearchPanel(PanelWidget):
|
||||
lambda checked=False: self.on_return(self.search_field.text())
|
||||
)
|
||||
|
||||
# self.content_container = QWidget()
|
||||
# self.content_layout = QHBoxLayout(self.content_container)
|
||||
|
||||
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.setStyleSheet('background: #000000;')
|
||||
self.scroll_area.setVerticalScrollBarPolicy(
|
||||
Qt.ScrollBarPolicy.ScrollBarAlwaysOn
|
||||
)
|
||||
# self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.scroll_area.setWidgetResizable(True)
|
||||
self.scroll_area.setFrameShadow(QFrame.Shadow.Plain)
|
||||
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
|
||||
# sa.setMaximumWidth(self.preview_size[0])
|
||||
self.scroll_area.setWidget(self.scroll_contents)
|
||||
|
||||
# self.add_button = QPushButton()
|
||||
# self.root_layout.addWidget(self.add_button)
|
||||
# self.add_button.setText('Add Tag')
|
||||
# # self.done_button.clicked.connect(lambda checked=False, x=1101: (callback(x), self.hide()))
|
||||
# self.add_button.clicked.connect(lambda checked=False, x=1101: callback(x))
|
||||
# # self.setLayout(self.root_layout)
|
||||
|
||||
self.root_layout.addWidget(self.search_field)
|
||||
self.root_layout.addWidget(self.scroll_area)
|
||||
|
||||
# def reset(self):
|
||||
# self.search_field.setText('')
|
||||
# self.update_tags('')
|
||||
# self.search_field.setFocus()
|
||||
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.tag_created.emit(self.first_tag_id)
|
||||
self.search_field.setText("")
|
||||
self.update_tags()
|
||||
elif text:
|
||||
self.create_and_add_tag(text)
|
||||
self.parentWidget().hide()
|
||||
else:
|
||||
self.search_field.setFocus()
|
||||
self.parentWidget().hide()
|
||||
|
||||
def update_tags(self, query: str = ""):
|
||||
# for c in self.scroll_layout.children():
|
||||
# c.widget().deleteLater()
|
||||
while self.scroll_layout.count():
|
||||
# logging.info(f"I'm deleting { self.scroll_layout.itemAt(0).widget()}")
|
||||
self.scroll_layout.takeAt(0).widget().deleteLater()
|
||||
|
||||
found_tags = self.lib.search_tags(query, include_cluster=True)[: self.tag_limit]
|
||||
self.first_tag_id = found_tags[0] if found_tags else None
|
||||
|
||||
for tag_id in found_tags:
|
||||
if query:
|
||||
# sort tags by whether the tag's name is the text that's matching the search, alphabetically, and then by color
|
||||
sorted_tags = sorted(
|
||||
found_tags,
|
||||
key=lambda tag_id: (
|
||||
not self.lib.get_tag(tag_id).name.lower().startswith(query.lower()),
|
||||
self.lib.get_tag(tag_id).display_name(self.lib),
|
||||
TAG_COLORS.index(self.lib.get_tag(tag_id).color.lower()),
|
||||
),
|
||||
)
|
||||
else:
|
||||
# sort tags by color and then alphabetically
|
||||
sorted_tags = sorted(
|
||||
found_tags,
|
||||
key=lambda tag_id: (
|
||||
TAG_COLORS.index(self.lib.get_tag(tag_id).color.lower()),
|
||||
self.lib.get_tag(tag_id).display_name(self.lib),
|
||||
),
|
||||
)
|
||||
|
||||
for tag_id in sorted_tags:
|
||||
c = QWidget()
|
||||
l = QHBoxLayout(c)
|
||||
l.setContentsMargins(0, 0, 0, 0)
|
||||
@@ -131,10 +130,7 @@ class TagSearchPanel(PanelWidget):
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: {math.ceil(1*self.devicePixelRatio())}px;"
|
||||
# f'padding-top: 1.5px;'
|
||||
# f'padding-right: 4px;'
|
||||
f"padding-bottom: 5px;"
|
||||
# f'padding-left: 4px;'
|
||||
f"font-size: 20px;"
|
||||
f"}}"
|
||||
f"QPushButton::hover"
|
||||
@@ -145,15 +141,95 @@ class TagSearchPanel(PanelWidget):
|
||||
f"}}"
|
||||
)
|
||||
|
||||
ab.clicked.connect(lambda checked=False, x=tag_id: self.tag_chosen.emit(x))
|
||||
ab.clicked.connect(lambda checked=False, x=tag_id: self.tag_created.emit(x))
|
||||
|
||||
l.addWidget(tw)
|
||||
l.addWidget(ab)
|
||||
self.scroll_layout.addWidget(c)
|
||||
|
||||
# Add a create tag button if a query is entered
|
||||
if query:
|
||||
c = self.create_tag_button(query)
|
||||
self.scroll_layout.addWidget(c)
|
||||
|
||||
self.search_field.setFocus()
|
||||
|
||||
# def enterEvent(self, event: QEnterEvent) -> None:
|
||||
# self.search_field.setFocus()
|
||||
# return super().enterEvent(event)
|
||||
# self.focusOutEvent
|
||||
def create_tag_button(self, name: str) -> QWidget:
|
||||
"""Construct a "Create Tag" button.
|
||||
|
||||
Args:
|
||||
name (str): The name of the tag to give.
|
||||
"""
|
||||
c = QWidget()
|
||||
l = QHBoxLayout(c)
|
||||
l.setContentsMargins(0, 0, 0, 0)
|
||||
l.setSpacing(3)
|
||||
|
||||
create_and_add_button = QPushButton(self)
|
||||
create_and_add_button.setFlat(True)
|
||||
create_and_add_button.setText(
|
||||
f"Create && Add \"{name.replace("&", "&&")}\" Tag"
|
||||
)
|
||||
|
||||
inner_layout = QHBoxLayout()
|
||||
inner_layout.setObjectName("innerLayout")
|
||||
inner_layout.setContentsMargins(2, 2, 2, 2)
|
||||
create_and_add_button.setLayout(inner_layout)
|
||||
create_and_add_button.setMinimumSize(math.ceil(22 * 1.5), 22)
|
||||
|
||||
create_and_add_button.setStyleSheet(
|
||||
f"QPushButton{{"
|
||||
f"background: {get_tag_color(ColorType.PRIMARY, "dark gray")};"
|
||||
f"color: {get_tag_color(ColorType.TEXT, "dark gray")};"
|
||||
f"font-weight: 600;"
|
||||
f"border-color:{get_tag_color(ColorType.BORDER, "dark gray")};"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: {math.ceil(self.devicePixelRatio())}px;"
|
||||
f"padding-right: 4px;"
|
||||
f"padding-bottom: 1px;"
|
||||
f"padding-left: 4px;"
|
||||
f"font-size: 13px"
|
||||
f"}}"
|
||||
f"QPushButton::hover{{"
|
||||
f"border-color:{get_tag_color(ColorType.LIGHT_ACCENT, "dark gray")};"
|
||||
f"}}"
|
||||
)
|
||||
|
||||
create_and_add_button.clicked.connect(
|
||||
lambda x=name: self.create_and_add_tag(name)
|
||||
)
|
||||
l.addWidget(create_and_add_button)
|
||||
|
||||
return c
|
||||
|
||||
def create_and_add_tag(self, name: str):
|
||||
"""Open "Add Tag" panel to create and add a new tag with the given name.
|
||||
|
||||
Args:
|
||||
name (str): The name of the tag to give.
|
||||
"""
|
||||
self.add_tag_modal = PanelModal(
|
||||
bt.BuildTagPanel(self.lib, tag_name=name),
|
||||
"New Tag",
|
||||
"Add Tag",
|
||||
has_save=True,
|
||||
)
|
||||
self.add_tag_modal.saved.connect(lambda n=name: self.on_tag_modal_saved(n))
|
||||
self.add_tag_modal.save_button.setFocus()
|
||||
self.add_tag_modal.show()
|
||||
|
||||
def on_tag_modal_saved(self, name):
|
||||
"""Callback for actions to perform when a new "Create & Add" tag is confirmed.
|
||||
Args:
|
||||
name (str): The name of the tag to give.
|
||||
"""
|
||||
panel: bt.BuildTagPanel = self.add_tag_modal.widget
|
||||
self.tag_created.emit(self.lib.add_tag_to_library(panel.build_tag()))
|
||||
self.add_tag_modal.hide()
|
||||
self.update_tags(name)
|
||||
|
||||
def showEvent(self, event):
|
||||
# Clear search field and focus when showing modal
|
||||
self.search_field.setText("")
|
||||
self.search_field.setFocus()
|
||||
|
||||
@@ -15,7 +15,7 @@ from PySide6.QtWidgets import (
|
||||
QLineEdit,
|
||||
QSizePolicy,
|
||||
)
|
||||
|
||||
from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper
|
||||
|
||||
# class NumberEdit(QLineEdit):
|
||||
# def __init__(self, parent=None) -> None:
|
||||
@@ -50,13 +50,13 @@ class Pagination(QWidget, QObject):
|
||||
# self.setMinimumHeight(32)
|
||||
|
||||
# [<] ----------------------------------
|
||||
self.prev_button = QPushButton()
|
||||
self.prev_button = QPushButtonWrapper()
|
||||
self.prev_button.setText("<")
|
||||
self.prev_button.setMinimumSize(self.button_size)
|
||||
self.prev_button.setMaximumSize(self.button_size)
|
||||
|
||||
# --- [1] ------------------------------
|
||||
self.start_button = QPushButton()
|
||||
self.start_button = QPushButtonWrapper()
|
||||
self.start_button.setMinimumSize(self.button_size)
|
||||
self.start_button.setMaximumSize(self.button_size)
|
||||
# self.start_button.setStyleSheet('background:cyan;')
|
||||
@@ -104,14 +104,14 @@ class Pagination(QWidget, QObject):
|
||||
self.end_ellipses.setText(". . .")
|
||||
|
||||
# ----------------------------- [42] ---
|
||||
self.end_button = QPushButton()
|
||||
self.end_button = QPushButtonWrapper()
|
||||
self.end_button.setMinimumSize(self.button_size)
|
||||
self.end_button.setMaximumSize(self.button_size)
|
||||
# self.end_button.setMaximumHeight(self.button_size.height())
|
||||
# self.end_button.setStyleSheet('background:red;')
|
||||
|
||||
# ---------------------------------- [>]
|
||||
self.next_button = QPushButton()
|
||||
self.next_button = QPushButtonWrapper()
|
||||
self.next_button.setText(">")
|
||||
self.next_button.setMinimumSize(self.button_size)
|
||||
self.next_button.setMaximumSize(self.button_size)
|
||||
@@ -428,16 +428,15 @@ class Pagination(QWidget, QObject):
|
||||
# print(f'GOTO PAGE: {index}')
|
||||
self.update_buttons(self.page_count, index)
|
||||
|
||||
def _assign_click(self, button: QPushButton, index):
|
||||
try:
|
||||
def _assign_click(self, button: QPushButtonWrapper, index):
|
||||
if button.is_connected:
|
||||
button.clicked.disconnect()
|
||||
except RuntimeError:
|
||||
pass
|
||||
button.clicked.connect(lambda checked=False, i=index: self._goto_page(i))
|
||||
button.is_connected = True
|
||||
|
||||
def _populate_buffer_buttons(self):
|
||||
for i in range(max(self.buffer_page_count * 2, 5)):
|
||||
button = QPushButton()
|
||||
button = QPushButtonWrapper()
|
||||
button.setMinimumSize(self.button_size)
|
||||
button.setMaximumSize(self.button_size)
|
||||
button.setHidden(True)
|
||||
@@ -445,7 +444,7 @@ class Pagination(QWidget, QObject):
|
||||
self.start_buffer_layout.addWidget(button)
|
||||
|
||||
for i in range(max(self.buffer_page_count * 2, 5)):
|
||||
button = QPushButton()
|
||||
button = QPushButtonWrapper()
|
||||
button.setMinimumSize(self.button_size)
|
||||
button.setMaximumSize(self.button_size)
|
||||
button.setHidden(True)
|
||||
|
||||
93
tagstudio/src/qt/resource_manager.py
Normal file
@@ -0,0 +1,93 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from PIL import Image
|
||||
|
||||
import ujson
|
||||
|
||||
logging.basicConfig(format="%(message)s", level=logging.INFO)
|
||||
|
||||
|
||||
class ResourceManager:
|
||||
"""A resource manager for retrieving resources."""
|
||||
|
||||
_map: dict = {}
|
||||
_cache: dict[str, Any] = {}
|
||||
_initialized: bool = False
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Load JSON resource map
|
||||
if not ResourceManager._initialized:
|
||||
with open(
|
||||
Path(__file__).parent / "resources.json", mode="r", encoding="utf-8"
|
||||
) as f:
|
||||
ResourceManager._map = ujson.load(f)
|
||||
logging.info(
|
||||
f"[ResourceManager] {len(ResourceManager._map.items())} resources registered"
|
||||
)
|
||||
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 Path(__file__).parents[2] / "resources" / res.get("path")
|
||||
return None
|
||||
|
||||
def get(self, id: str) -> Any:
|
||||
"""Get a resource from the ResourceManager.
|
||||
This can include resources inside and outside of QResources, and will return
|
||||
theme-respecting variations of resources if available.
|
||||
|
||||
Args:
|
||||
id (str): The name of the resource.
|
||||
|
||||
Returns:
|
||||
Any: The resource if found, else None.
|
||||
"""
|
||||
cached_res = ResourceManager._cache.get(id)
|
||||
if cached_res:
|
||||
return cached_res
|
||||
else:
|
||||
res: dict = ResourceManager._map.get(id)
|
||||
try:
|
||||
if res and 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
|
||||
return data
|
||||
elif res and res.get("mode") == "pil":
|
||||
data = Image.open(
|
||||
Path(__file__).parents[2] / "resources" / res.get("path")
|
||||
)
|
||||
return data
|
||||
elif res and res.get("mode") in ["qt"]:
|
||||
# TODO: Qt resource loading logic
|
||||
pass
|
||||
except FileNotFoundError:
|
||||
logging.error(
|
||||
f"[ResourceManager][ERROR]: Could not find resource: {Path(__file__).parents[2] / "resources" / res.get("path")}"
|
||||
)
|
||||
return None
|
||||
|
||||
def __getattr__(self, __name: str) -> Any:
|
||||
attr = self.get(__name)
|
||||
if attr:
|
||||
return attr
|
||||
raise AttributeError(f"Attribute {id} not found")
|
||||
98
tagstudio/src/qt/resources.json
Normal file
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"play_icon": {
|
||||
"path": "qt/images/play.svg",
|
||||
"mode": "rb"
|
||||
},
|
||||
"pause_icon": {
|
||||
"path": "qt/images/pause.svg",
|
||||
"mode": "rb"
|
||||
},
|
||||
"volume_icon": {
|
||||
"path": "qt/images/volume.svg",
|
||||
"mode": "rb"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,11 @@
|
||||
"""A Qt driver for TagStudio."""
|
||||
|
||||
import ctypes
|
||||
import copy
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
import time
|
||||
import typing
|
||||
@@ -52,6 +54,7 @@ from PySide6.QtWidgets import (
|
||||
QMenu,
|
||||
QMenuBar,
|
||||
QComboBox,
|
||||
QMessageBox,
|
||||
)
|
||||
from humanfriendly import format_timespan
|
||||
|
||||
@@ -64,12 +67,18 @@ from src.core.constants import (
|
||||
TS_FOLDER_NAME,
|
||||
VERSION_BRANCH,
|
||||
VERSION,
|
||||
TEXT_FIELDS,
|
||||
TAG_FAVORITE,
|
||||
TAG_ARCHIVED,
|
||||
)
|
||||
from src.core.palette import ColorType, get_ui_color
|
||||
from src.core.utils.web import strip_web_protocol
|
||||
from src.qt.flowlayout import FlowLayout
|
||||
from src.qt.main_window import Ui_MainWindow
|
||||
from src.qt.helpers.file_deleter import delete_file
|
||||
from src.qt.helpers.function_iterator import FunctionIterator
|
||||
from src.qt.helpers.custom_runnable import CustomRunnable
|
||||
from src.qt.resource_manager import ResourceManager
|
||||
from src.qt.widgets.collage_icon import CollageIconRenderer
|
||||
from src.qt.widgets.panel import PanelModal
|
||||
from src.qt.widgets.thumb_renderer import ThumbRenderer
|
||||
@@ -82,6 +91,8 @@ from src.qt.modals.file_extension import FileExtensionModal
|
||||
from src.qt.modals.fix_unlinked import FixUnlinkedEntriesModal
|
||||
from src.qt.modals.fix_dupes import FixDupeFilesModal
|
||||
from src.qt.modals.folders_to_tags import FoldersToTagsModal
|
||||
from src.qt.modals.drop_import import DropImport
|
||||
from src.qt.modals.ffmpeg_checker import FfmpegChecker
|
||||
|
||||
# this import has side-effect of import PySide resources
|
||||
import src.qt.resources_rc # pylint: disable=unused-import
|
||||
@@ -164,6 +175,7 @@ class QtDriver(QObject):
|
||||
super().__init__()
|
||||
self.core: TagStudioCore = core
|
||||
self.lib = self.core.lib
|
||||
self.rm: ResourceManager = ResourceManager()
|
||||
self.args = args
|
||||
self.frame_dict: dict = {}
|
||||
self.nav_frames: list[NavigationState] = []
|
||||
@@ -265,6 +277,11 @@ class QtDriver(QObject):
|
||||
# f'QScrollBar::{{background:red;}}'
|
||||
# )
|
||||
|
||||
self.drop_import = DropImport(self)
|
||||
self.main_window.dragEnterEvent = self.drop_import.dragEnterEvent # type: ignore
|
||||
self.main_window.dropEvent = self.drop_import.dropEvent # type: ignore
|
||||
self.main_window.dragMoveEvent = self.drop_import.dragMoveEvent # type: ignore
|
||||
|
||||
# # self.main_window.windowFlags() &
|
||||
# # self.main_window.setWindowFlag(Qt.WindowType.FramelessWindowHint, True)
|
||||
# self.main_window.setWindowFlag(Qt.WindowType.NoDropShadowWindowHint, True)
|
||||
@@ -276,8 +293,20 @@ class QtDriver(QObject):
|
||||
|
||||
splash_pixmap = QPixmap(":/images/splash.png")
|
||||
splash_pixmap.setDevicePixelRatio(self.main_window.devicePixelRatio())
|
||||
splash_pixmap = splash_pixmap.scaledToWidth(
|
||||
math.floor(
|
||||
min(
|
||||
(
|
||||
QGuiApplication.primaryScreen().geometry().width()
|
||||
* self.main_window.devicePixelRatio()
|
||||
)
|
||||
/ 4,
|
||||
splash_pixmap.width(),
|
||||
)
|
||||
),
|
||||
Qt.TransformationMode.SmoothTransformation,
|
||||
)
|
||||
self.splash = QSplashScreen(splash_pixmap, Qt.WindowStaysOnTopHint) # type: ignore
|
||||
# self.splash.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
self.splash.show()
|
||||
|
||||
if os.name == "nt":
|
||||
@@ -289,6 +318,9 @@ class QtDriver(QObject):
|
||||
icon.addFile(str(icon_path))
|
||||
app.setWindowIcon(icon)
|
||||
|
||||
self.copied_fields: list[dict] = []
|
||||
self.is_buffer_merged: bool = False
|
||||
|
||||
menu_bar = QMenuBar(self.main_window)
|
||||
self.main_window.setMenuBar(menu_bar)
|
||||
menu_bar.setNativeMenuBar(True)
|
||||
@@ -382,6 +414,36 @@ class QtDriver(QObject):
|
||||
|
||||
edit_menu.addSeparator()
|
||||
|
||||
# NOTE: Name is set in update_clipboard_actions()
|
||||
self.copy_entry_fields_action = QAction(menu_bar)
|
||||
self.copy_entry_fields_action.triggered.connect(
|
||||
lambda: self.copy_entry_fields_callback()
|
||||
)
|
||||
self.copy_entry_fields_action.setShortcut(
|
||||
QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
|
||||
QtCore.Qt.Key.Key_C,
|
||||
)
|
||||
)
|
||||
self.copy_entry_fields_action.setToolTip("Ctrl+C")
|
||||
edit_menu.addAction(self.copy_entry_fields_action)
|
||||
|
||||
# NOTE: Name is set in update_clipboard_actions()
|
||||
self.paste_entry_fields_action = QAction(menu_bar)
|
||||
self.paste_entry_fields_action.triggered.connect(
|
||||
self.paste_entry_fields_callback
|
||||
)
|
||||
self.paste_entry_fields_action.setShortcut(
|
||||
QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
|
||||
QtCore.Qt.Key.Key_V,
|
||||
)
|
||||
)
|
||||
self.paste_entry_fields_action.setToolTip("Ctrl+V")
|
||||
edit_menu.addAction(self.paste_entry_fields_action)
|
||||
|
||||
edit_menu.addSeparator()
|
||||
|
||||
select_all_action = QAction("Select All", menu_bar)
|
||||
select_all_action.triggered.connect(self.select_all_action_callback)
|
||||
select_all_action.setShortcut(
|
||||
@@ -401,6 +463,15 @@ class QtDriver(QObject):
|
||||
|
||||
edit_menu.addSeparator()
|
||||
|
||||
self.delete_file_action = QAction("Delete Selected File(s)", menu_bar)
|
||||
self.delete_file_action.triggered.connect(
|
||||
lambda f="": self.delete_files_callback(f)
|
||||
)
|
||||
self.delete_file_action.setShortcut(QtCore.Qt.Key.Key_Delete)
|
||||
edit_menu.addAction(self.delete_file_action)
|
||||
|
||||
edit_menu.addSeparator()
|
||||
|
||||
manage_file_extensions_action = QAction("Manage File Extensions", menu_bar)
|
||||
manage_file_extensions_action.triggered.connect(
|
||||
lambda: self.show_file_extension_modal()
|
||||
@@ -424,14 +495,24 @@ class QtDriver(QObject):
|
||||
window_menu.addAction(check_action)
|
||||
|
||||
# Tools Menu ===========================================================
|
||||
def create_fix_unlinked_entries_modal():
|
||||
"""Postpone the creation of the modal until it is needed."""
|
||||
if not hasattr(self, "unlinked_modal"):
|
||||
self.unlinked_modal = FixUnlinkedEntriesModal(self.lib, self)
|
||||
self.unlinked_modal.show()
|
||||
|
||||
fix_unlinked_entries_action = QAction("Fix &Unlinked Entries", menu_bar)
|
||||
fue_modal = FixUnlinkedEntriesModal(self.lib, self)
|
||||
fix_unlinked_entries_action.triggered.connect(lambda: fue_modal.show())
|
||||
fix_unlinked_entries_action.triggered.connect(create_fix_unlinked_entries_modal)
|
||||
tools_menu.addAction(fix_unlinked_entries_action)
|
||||
|
||||
def create_dupe_files_modal():
|
||||
"""Postpone the creation of the modal until it is needed."""
|
||||
if not hasattr(self, "dupe_modal"):
|
||||
self.dupe_modal = FixDupeFilesModal(self.lib, self)
|
||||
self.dupe_modal.show()
|
||||
|
||||
fix_dupe_files_action = QAction("Fix Duplicate &Files", menu_bar)
|
||||
fdf_modal = FixDupeFilesModal(self.lib, self)
|
||||
fix_dupe_files_action.triggered.connect(lambda: fdf_modal.show())
|
||||
fix_dupe_files_action.triggered.connect(create_dupe_files_modal)
|
||||
tools_menu.addAction(fix_dupe_files_action)
|
||||
|
||||
create_collage_action = QAction("Create Collage", menu_bar)
|
||||
@@ -482,18 +563,25 @@ class QtDriver(QObject):
|
||||
)
|
||||
window_menu.addAction(show_libs_list_action)
|
||||
|
||||
def create_folders_tags_modal():
|
||||
"""Postpone the creation of the modal until it is needed."""
|
||||
if not hasattr(self, "folders_modal"):
|
||||
self.folders_modal = FoldersToTagsModal(self.lib, self)
|
||||
self.folders_modal.show()
|
||||
|
||||
folders_to_tags_action = QAction("Folders to Tags", menu_bar)
|
||||
ftt_modal = FoldersToTagsModal(self.lib, self)
|
||||
folders_to_tags_action.triggered.connect(lambda: ftt_modal.show())
|
||||
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")
|
||||
)
|
||||
help_menu.addAction(self.repo_action)
|
||||
self.set_macro_menu_viability()
|
||||
self.set_menu_action_viability()
|
||||
|
||||
self.update_clipboard_actions()
|
||||
|
||||
menu_bar.addMenu(file_menu)
|
||||
menu_bar.addMenu(edit_menu)
|
||||
@@ -502,6 +590,9 @@ class QtDriver(QObject):
|
||||
menu_bar.addMenu(window_menu)
|
||||
menu_bar.addMenu(help_menu)
|
||||
|
||||
# ======================================================================
|
||||
|
||||
# Preview Panel --------------------------------------------------------
|
||||
self.preview_panel = PreviewPanel(self.lib, self)
|
||||
l: QHBoxLayout = self.main_window.splitter
|
||||
l.addWidget(self.preview_panel)
|
||||
@@ -510,11 +601,17 @@ class QtDriver(QObject):
|
||||
str(Path(__file__).parents[2] / "resources/qt/fonts/Oxanium-Bold.ttf")
|
||||
)
|
||||
|
||||
self.thumb_sizes: list[tuple[str, int]] = [
|
||||
("Extra Large Thumbnails", 256),
|
||||
("Large Thumbnails", 192),
|
||||
("Medium Thumbnails", 128),
|
||||
("Small Thumbnails", 96),
|
||||
("Mini Thumbnails", 76),
|
||||
]
|
||||
self.thumb_size = 128
|
||||
self.max_results = 500
|
||||
self.item_thumbs: list[ItemThumb] = []
|
||||
self.thumb_renderers: list[ThumbRenderer] = []
|
||||
self.collation_thumb_size = math.ceil(self.thumb_size * 2)
|
||||
|
||||
self.init_library_window()
|
||||
|
||||
@@ -543,29 +640,44 @@ class QtDriver(QObject):
|
||||
if self.args.ci:
|
||||
# gracefully terminate the app in CI environment
|
||||
self.thumb_job_queue.put((self.SIGTERM.emit, []))
|
||||
else:
|
||||
# Startup Checks
|
||||
self.check_ffmpeg()
|
||||
|
||||
app.exec()
|
||||
|
||||
self.shutdown()
|
||||
|
||||
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())
|
||||
# self.ext_font = ImageFont.truetype(os.path.normpath(f'{Path(__file__).parents[2]}/resources/qt/fonts/Oxanium-Bold.ttf'), adj_font_size)
|
||||
|
||||
# Search Button
|
||||
search_button: QPushButton = self.main_window.searchButton
|
||||
search_button.clicked.connect(
|
||||
lambda: self.filter_items(self.main_window.searchField.text())
|
||||
)
|
||||
|
||||
# Search Field
|
||||
search_field: QLineEdit = self.main_window.searchField
|
||||
search_field.returnPressed.connect(
|
||||
lambda: self.filter_items(self.main_window.searchField.text())
|
||||
)
|
||||
|
||||
# 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()
|
||||
|
||||
# Search Type ComboBox
|
||||
search_type_selector: QComboBox = self.main_window.comboBox_2
|
||||
search_type_selector.currentIndexChanged.connect(
|
||||
lambda: self.set_search_type(
|
||||
@@ -684,11 +796,16 @@ class QtDriver(QObject):
|
||||
self.lib.clear_internal_vars()
|
||||
title_text = f"{self.base_title}"
|
||||
self.main_window.setWindowTitle(title_text)
|
||||
self.main_window.setAcceptDrops(False)
|
||||
|
||||
self.nav_frames = []
|
||||
self.cur_frame_idx = -1
|
||||
self.cur_query = ""
|
||||
self.selected.clear()
|
||||
self.copied_fields.clear()
|
||||
self.is_buffer_merged = False
|
||||
self.update_clipboard_actions()
|
||||
self.set_menu_action_viability()
|
||||
self.preview_panel.update_widgets()
|
||||
self.filter_items()
|
||||
self.main_window.toggle_landing_page(True)
|
||||
@@ -726,7 +843,7 @@ class QtDriver(QObject):
|
||||
self.selected.append((item.mode, item.item_id))
|
||||
item.thumb_button.set_selected(True)
|
||||
|
||||
self.set_macro_menu_viability()
|
||||
self.set_menu_action_viability()
|
||||
self.preview_panel.update_widgets()
|
||||
|
||||
def clear_select_action_callback(self):
|
||||
@@ -734,12 +851,15 @@ class QtDriver(QObject):
|
||||
for item in self.item_thumbs:
|
||||
item.thumb_button.set_selected(False)
|
||||
|
||||
self.set_macro_menu_viability()
|
||||
self.set_menu_action_viability()
|
||||
self.preview_panel.update_widgets()
|
||||
|
||||
def show_tag_database(self):
|
||||
self.modal = PanelModal(
|
||||
TagDatabasePanel(self.lib), "Library Tags", "Library Tags", has_save=False
|
||||
TagDatabasePanel(self.lib, self),
|
||||
"Library Tags",
|
||||
"Library Tags",
|
||||
has_save=False,
|
||||
)
|
||||
self.modal.show()
|
||||
|
||||
@@ -754,6 +874,116 @@ class QtDriver(QObject):
|
||||
self.modal.saved.connect(lambda: (panel.save(), self.filter_items("")))
|
||||
self.modal.show()
|
||||
|
||||
def delete_files_callback(self, origin_path: str | Path):
|
||||
"""Callback to send on or more files to the system trash.
|
||||
|
||||
If 0-1 items are currently selected, the origin_path is used to delete the file
|
||||
from the originating context menu item.
|
||||
If there are currently multiple items selected,
|
||||
then the selection buffer is used to determine the files to be deleted.
|
||||
|
||||
Args:
|
||||
origin_path(str): The file path associated with the widget making the call.
|
||||
May or may not be the file targeted, depending on the selection rules.
|
||||
"""
|
||||
entry = None
|
||||
pending: list[Path] = []
|
||||
deleted_count: int = 0
|
||||
|
||||
if len(self.selected) <= 1 and origin_path:
|
||||
pending.append(Path(origin_path))
|
||||
elif (len(self.selected) > 1) or (len(self.selected) <= 1 and not origin_path):
|
||||
for i, item_pair in enumerate(self.selected):
|
||||
if item_pair[0] == ItemType.ENTRY:
|
||||
entry = self.lib.get_entry(item_pair[1])
|
||||
filepath: Path = self.lib.library_dir / entry.path / entry.filename
|
||||
pending.append(filepath)
|
||||
|
||||
if pending:
|
||||
return_code = self.delete_file_confirmation(len(pending), pending[0])
|
||||
logging.info(return_code)
|
||||
# If there was a confirmation and not a cancellation
|
||||
if return_code == 2 and return_code != 3:
|
||||
for i, f in enumerate(pending):
|
||||
if (origin_path == f) or (not origin_path):
|
||||
self.preview_panel.stop_file_use()
|
||||
if delete_file(f):
|
||||
self.main_window.statusbar.showMessage(
|
||||
f'Deleting file [{i}/{len(pending)}]: "{f}"...'
|
||||
)
|
||||
self.main_window.statusbar.repaint()
|
||||
|
||||
entry_id = self.lib.get_entry_id_from_filepath(f)
|
||||
self.lib.remove_entry(entry_id)
|
||||
self.purge_item_from_navigation(ItemType.ENTRY, entry_id)
|
||||
deleted_count += 1
|
||||
self.selected.clear()
|
||||
|
||||
if deleted_count > 0:
|
||||
self.refresh_frame(self.nav_frames[self.cur_frame_idx].contents)
|
||||
self.preview_panel.update_widgets()
|
||||
|
||||
if len(self.selected) <= 1 and deleted_count == 0:
|
||||
self.main_window.statusbar.showMessage("No files deleted.")
|
||||
elif len(self.selected) <= 1 and deleted_count == 1:
|
||||
self.main_window.statusbar.showMessage(f"Deleted {deleted_count} file!")
|
||||
elif len(self.selected) > 1 and deleted_count == 0:
|
||||
self.main_window.statusbar.showMessage("No files deleted.")
|
||||
elif len(self.selected) > 1 and deleted_count < len(self.selected):
|
||||
self.main_window.statusbar.showMessage(
|
||||
f"Only deleted {deleted_count} file{'' if deleted_count == 1 else 's'}! Check if any of the files are currently missing or in use."
|
||||
)
|
||||
elif len(self.selected) > 1 and deleted_count == len(self.selected):
|
||||
self.main_window.statusbar.showMessage(f"Deleted {deleted_count} files!")
|
||||
else:
|
||||
self.main_window.statusbar.showMessage(f"Deleted {deleted_count} files!")
|
||||
self.main_window.statusbar.repaint()
|
||||
|
||||
def delete_file_confirmation(self, count: int, filename: Path | None = None) -> int:
|
||||
"""A confirmation dialogue box for deleting files.
|
||||
|
||||
Args:
|
||||
count(int): The number of files to be deleted.
|
||||
filename(Path | None): The filename to show if only one file is to be deleted.
|
||||
"""
|
||||
trash_term: str = "Trash"
|
||||
if platform.system() == "Windows":
|
||||
trash_term = "Recycle Bin"
|
||||
# NOTE: Windows + send2trash will PERMANENTLY delete files which cannot be moved to the Recycle Bin.
|
||||
# This is done without any warning, so this message is currently the best way I've got to inform the user.
|
||||
# https://github.com/arsenetar/send2trash/issues/28
|
||||
# This warning is applied to all platforms until at least macOS and Linux can be verified to
|
||||
# not exhibit this same behavior.
|
||||
perm_warning: str = (
|
||||
f"<h4 style='color: {get_ui_color(ColorType.PRIMARY, 'red')}'>"
|
||||
f"<b>WARNING!</b> If this file can't be moved to the {trash_term}, "
|
||||
f"</b>it will be <b>permanently deleted!</b></h4>"
|
||||
)
|
||||
|
||||
msg = QMessageBox()
|
||||
msg.setTextFormat(Qt.TextFormat.RichText)
|
||||
msg.setWindowTitle("Delete File" if count == 1 else "Delete Files")
|
||||
msg.setIcon(QMessageBox.Icon.Warning)
|
||||
if count <= 1:
|
||||
msg.setText(
|
||||
f"<h3>Are you sure you want to move this file to the {trash_term}?</h3>"
|
||||
"<h4>This will remove it from TagStudio <i>AND</i> your file system!</h4>"
|
||||
f"{filename if filename else ''}"
|
||||
f"{perm_warning}<br>"
|
||||
)
|
||||
elif count > 1:
|
||||
msg.setText(
|
||||
f"<h3>Are you sure you want to move these {count} files to the {trash_term}?</h3>"
|
||||
"<h4>This will remove them from TagStudio <i>AND</i> your file system!</h4>"
|
||||
f"{perm_warning}<br>"
|
||||
)
|
||||
|
||||
yes_button: QPushButton = msg.addButton("&Yes", QMessageBox.ButtonRole.YesRole)
|
||||
msg.addButton("&No", QMessageBox.ButtonRole.NoRole)
|
||||
msg.setDefaultButton(yes_button)
|
||||
|
||||
return msg.exec()
|
||||
|
||||
def add_new_files_callback(self):
|
||||
"""Runs when user initiates adding new files to the Library."""
|
||||
# # if self.lib.files_not_in_library:
|
||||
@@ -865,7 +1095,13 @@ class QtDriver(QObject):
|
||||
)
|
||||
)
|
||||
r = CustomRunnable(lambda: iterator.run())
|
||||
r.done.connect(lambda: (pw.hide(), pw.deleteLater(), self.filter_items("")))
|
||||
r.done.connect(
|
||||
lambda: (
|
||||
pw.hide(),
|
||||
pw.deleteLater(),
|
||||
self.filter_items(self.main_window.searchField.text()),
|
||||
)
|
||||
)
|
||||
QThreadPool.globalInstance().start(r)
|
||||
|
||||
def new_file_macros_runnable(self, new_ids):
|
||||
@@ -895,7 +1131,7 @@ class QtDriver(QObject):
|
||||
"""Runs a specific Macro on an Entry given a Macro name."""
|
||||
entry = self.lib.get_entry(entry_id)
|
||||
path = self.lib.library_dir / entry.path / entry.filename
|
||||
source = entry.path.parts[0]
|
||||
source = "" if entry.path == Path(".") else entry.path.parts[0].lower()
|
||||
if name == "sidecar":
|
||||
self.lib.add_generic_data_to_entry(
|
||||
self.core.get_gdl_sidecar(path, source), entry_id
|
||||
@@ -939,6 +1175,145 @@ class QtDriver(QObject):
|
||||
mode="replace",
|
||||
)
|
||||
|
||||
def copy_entry_fields_callback(self):
|
||||
"""Copies fields from selected Entries into to buffer."""
|
||||
merged_fields: list[dict] = []
|
||||
merged_count: int = 0
|
||||
for item_type, item_id in self.selected:
|
||||
if item_type == ItemType.ENTRY:
|
||||
entry = self.lib.get_entry(item_id)
|
||||
|
||||
if len(entry.fields) > 0:
|
||||
merged_count += 1
|
||||
|
||||
for field in entry.fields:
|
||||
field_id: int = self.lib.get_field_attr(field, "id")
|
||||
content = self.lib.get_field_attr(field, "content")
|
||||
|
||||
if self.lib.get_field_obj(int(field_id))["type"] == "tag_box":
|
||||
existing_fields: list[int] = self.lib.get_field_index_in_entry(
|
||||
entry, field_id
|
||||
)
|
||||
if existing_fields and merged_fields:
|
||||
for i in content:
|
||||
field_index = copy.deepcopy(existing_fields[0])
|
||||
if i not in merged_fields[field_index][field_id]:
|
||||
merged_fields[field_index][field_id].append(
|
||||
copy.deepcopy(i)
|
||||
)
|
||||
else:
|
||||
merged_fields.append(copy.deepcopy({field_id: content}))
|
||||
|
||||
if self.lib.get_field_obj(int(field_id))["type"] in TEXT_FIELDS:
|
||||
if {field_id: content} not in merged_fields:
|
||||
merged_fields.append(copy.deepcopy({field_id: content}))
|
||||
|
||||
# Only set merged state to True if multiple Entries with actual field data were copied.
|
||||
if merged_count > 1:
|
||||
self.is_buffer_merged = True
|
||||
else:
|
||||
self.is_buffer_merged = False
|
||||
|
||||
self.copied_fields = merged_fields
|
||||
self.update_clipboard_actions()
|
||||
|
||||
def paste_entry_fields_callback(self):
|
||||
"""Pastes buffered fields into currently selected Entries."""
|
||||
# Code ported from ts_cli.py
|
||||
if self.copied_fields:
|
||||
for item_type, item_id in self.selected:
|
||||
if item_type == ItemType.ENTRY:
|
||||
entry = self.lib.get_entry(item_id)
|
||||
|
||||
for field in self.copied_fields:
|
||||
field_id: int = self.lib.get_field_attr(field, "id")
|
||||
content = self.lib.get_field_attr(field, "content")
|
||||
|
||||
if self.lib.get_field_obj(int(field_id))["type"] == "tag_box":
|
||||
existing_fields: list[int] = (
|
||||
self.lib.get_field_index_in_entry(entry, field_id)
|
||||
)
|
||||
if existing_fields:
|
||||
self.lib.update_entry_field(
|
||||
item_id, existing_fields[0], content, "append"
|
||||
)
|
||||
else:
|
||||
self.lib.add_field_to_entry(item_id, field_id)
|
||||
self.lib.update_entry_field(
|
||||
item_id, -1, content, "append"
|
||||
)
|
||||
|
||||
if self.lib.get_field_obj(int(field_id))["type"] in TEXT_FIELDS:
|
||||
if not self.lib.does_field_content_exist(
|
||||
item_id, field_id, content
|
||||
):
|
||||
self.lib.add_field_to_entry(item_id, field_id)
|
||||
self.lib.update_entry_field(
|
||||
item_id, -1, content, "replace"
|
||||
)
|
||||
|
||||
self.preview_panel.update_widgets()
|
||||
self.update_badges()
|
||||
self.update_clipboard_actions()
|
||||
|
||||
def update_clipboard_actions(self):
|
||||
"""Updates the text and enabled state of the field copy & paste actions."""
|
||||
# Buffer State Dependant
|
||||
if self.copied_fields:
|
||||
self.paste_entry_fields_action.setDisabled(False)
|
||||
else:
|
||||
self.paste_entry_fields_action.setDisabled(True)
|
||||
self.paste_entry_fields_action.setText("&Paste Fields")
|
||||
|
||||
# Selection Count Dependant
|
||||
if len(self.selected) <= 0:
|
||||
self.copy_entry_fields_action.setDisabled(True)
|
||||
self.paste_entry_fields_action.setDisabled(True)
|
||||
self.copy_entry_fields_action.setText("&Copy Fields")
|
||||
if len(self.selected) == 1:
|
||||
self.copy_entry_fields_action.setDisabled(False)
|
||||
self.copy_entry_fields_action.setText("&Copy Fields")
|
||||
elif len(self.selected) > 1:
|
||||
self.copy_entry_fields_action.setDisabled(False)
|
||||
self.copy_entry_fields_action.setText("&Copy Combined Fields")
|
||||
|
||||
# Merged State Dependant
|
||||
if self.is_buffer_merged:
|
||||
self.paste_entry_fields_action.setText("&Paste Combined Fields")
|
||||
else:
|
||||
self.paste_entry_fields_action.setText("&Paste Fields")
|
||||
|
||||
def thumb_size_callback(self, index: int):
|
||||
"""
|
||||
Performs 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:
|
||||
logging.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.setMinimumSize(self.thumb_size, self.thumb_size)
|
||||
it.setMaximumSize(self.thumb_size, self.thumb_size)
|
||||
it.thumb_button.thumb_size = (self.thumb_size, self.thumb_size)
|
||||
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:
|
||||
@@ -1106,6 +1481,7 @@ class QtDriver(QObject):
|
||||
item_thumb = ItemThumb(
|
||||
None, self.lib, self.preview_panel, (self.thumb_size, self.thumb_size)
|
||||
)
|
||||
|
||||
layout.addWidget(item_thumb)
|
||||
self.item_thumbs.append(item_thumb)
|
||||
|
||||
@@ -1184,16 +1560,19 @@ class QtDriver(QObject):
|
||||
if it.mode == type and it.item_id == id:
|
||||
self.preview_panel.set_tags_updated_slot(it.update_badges)
|
||||
|
||||
self.set_macro_menu_viability()
|
||||
self.set_menu_action_viability()
|
||||
self.update_clipboard_actions()
|
||||
self.preview_panel.update_widgets()
|
||||
|
||||
def set_macro_menu_viability(self):
|
||||
def set_menu_action_viability(self):
|
||||
if len([x[1] for x in self.selected if x[0] == ItemType.ENTRY]) == 0:
|
||||
self.autofill_action.setDisabled(True)
|
||||
self.sort_fields_action.setDisabled(True)
|
||||
self.delete_file_action.setDisabled(True)
|
||||
else:
|
||||
self.autofill_action.setDisabled(False)
|
||||
self.sort_fields_action.setDisabled(False)
|
||||
self.delete_file_action.setDisabled(False)
|
||||
|
||||
def update_thumbs(self):
|
||||
"""Updates search thumbnails."""
|
||||
@@ -1242,16 +1621,24 @@ class QtDriver(QObject):
|
||||
|
||||
for i, item_thumb in enumerate(self.item_thumbs, start=0):
|
||||
if i < len(self.nav_frames[self.cur_frame_idx].contents):
|
||||
filepath = ""
|
||||
filepath: Path = None # Initialize
|
||||
if self.nav_frames[self.cur_frame_idx].contents[i][0] == ItemType.ENTRY:
|
||||
entry = self.lib.get_entry(
|
||||
self.nav_frames[self.cur_frame_idx].contents[i][1]
|
||||
)
|
||||
filepath = self.lib.library_dir / entry.path / entry.filename
|
||||
filepath: Path = self.lib.library_dir / entry.path / entry.filename
|
||||
|
||||
try:
|
||||
item_thumb.delete_action.triggered.disconnect()
|
||||
except RuntimeWarning:
|
||||
pass
|
||||
item_thumb.delete_action.triggered.connect(
|
||||
lambda checked=False, f=filepath: self.delete_files_callback(f)
|
||||
)
|
||||
|
||||
item_thumb.set_item_id(entry.id)
|
||||
item_thumb.assign_archived(entry.has_tag(self.lib, 0))
|
||||
item_thumb.assign_favorite(entry.has_tag(self.lib, 1))
|
||||
item_thumb.assign_archived(entry.has_tag(self.lib, TAG_ARCHIVED))
|
||||
item_thumb.assign_favorite(entry.has_tag(self.lib, TAG_FAVORITE))
|
||||
# ctrl_down = True if QGuiApplication.keyboardModifiers() else False
|
||||
# TODO: Change how this works. The click function
|
||||
# for collations a few lines down should NOT be allowed during modifier keys.
|
||||
@@ -1292,7 +1679,9 @@ class QtDriver(QObject):
|
||||
else collation.e_ids_and_pages[0][0]
|
||||
)
|
||||
cover_e = self.lib.get_entry(cover_id)
|
||||
filepath = self.lib.library_dir / cover_e.path / cover_e.filename
|
||||
filepath: Path = (
|
||||
self.lib.library_dir / cover_e.path / cover_e.filename
|
||||
)
|
||||
item_thumb.set_count(str(len(collation.e_ids_and_pages)))
|
||||
item_thumb.update_clickable(
|
||||
clickable=(
|
||||
@@ -1457,6 +1846,7 @@ class QtDriver(QObject):
|
||||
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.nav_frames = []
|
||||
self.cur_frame_idx = -1
|
||||
@@ -1466,6 +1856,12 @@ class QtDriver(QObject):
|
||||
self.filter_items()
|
||||
self.main_window.toggle_landing_page(False)
|
||||
|
||||
def check_ffmpeg(self) -> None:
|
||||
"""Checks if FFmpeg is installed and displays a warning if not."""
|
||||
self.ffmpeg_checker = FfmpegChecker()
|
||||
if not self.ffmpeg_checker.installed():
|
||||
self.ffmpeg_checker.show_warning()
|
||||
|
||||
def create_collage(self) -> None:
|
||||
"""Generates and saves an image collage based on Library Entries."""
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
import logging
|
||||
import os
|
||||
import math
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
@@ -12,24 +12,15 @@ from PIL import Image, ImageChops, UnidentifiedImageError
|
||||
from PIL.Image import DecompressionBombError
|
||||
from PySide6.QtCore import (
|
||||
QObject,
|
||||
QThread,
|
||||
Signal,
|
||||
QRunnable,
|
||||
Qt,
|
||||
QThreadPool,
|
||||
QSize,
|
||||
QEvent,
|
||||
QTimer,
|
||||
QSettings,
|
||||
)
|
||||
|
||||
from src.core.library import Library
|
||||
from src.core.constants import DOC_TYPES, VIDEO_TYPES, IMAGE_TYPES
|
||||
from src.core.media_types import MediaCategories, MediaType
|
||||
from src.qt.helpers.file_tester import is_readable_video
|
||||
|
||||
|
||||
ERROR = f"[ERROR]"
|
||||
WARNING = f"[WARNING]"
|
||||
INFO = f"[INFO]"
|
||||
ERROR = "[ERROR]"
|
||||
WARNING = "[WARNING]"
|
||||
INFO = "[INFO]"
|
||||
|
||||
|
||||
logging.basicConfig(format="%(message)s", level=logging.INFO)
|
||||
@@ -53,7 +44,6 @@ class CollageIconRenderer(QObject):
|
||||
):
|
||||
entry = self.lib.get_entry(entry_id)
|
||||
filepath = self.lib.library_dir / entry.path / entry.filename
|
||||
file_type = os.path.splitext(filepath)[1].lower()[1:]
|
||||
color: str = ""
|
||||
|
||||
try:
|
||||
@@ -85,15 +75,13 @@ class CollageIconRenderer(QObject):
|
||||
|
||||
if data_only_mode:
|
||||
pic = Image.new("RGB", size, color)
|
||||
# collage.paste(pic, (y*thumb_size, x*thumb_size))
|
||||
self.rendered.emit(pic)
|
||||
if not data_only_mode:
|
||||
logging.info(
|
||||
f"\r{INFO} Combining [ID:{entry_id}/{len(self.lib.entries)}]: {self.get_file_color(filepath.suffix.lower())}{entry.path}{os.sep}{entry.filename}\033[0m"
|
||||
f"\r{INFO} Combining [ID:{entry_id}/{len(self.lib.entries)}]: {self.get_file_color(filepath.suffix.lower())}{entry.path}/{entry.filename}\033[0m"
|
||||
)
|
||||
# sys.stdout.write(f'\r{INFO} Combining [{i+1}/{len(self.lib.entries)}]: {self.get_file_color(file_type)}{entry.path}{os.sep}{entry.filename}{RESET}')
|
||||
# sys.stdout.flush()
|
||||
if filepath.suffix.lower() in IMAGE_TYPES:
|
||||
ext: str = filepath.suffix.lower()
|
||||
if MediaType.IMAGE in MediaCategories.get_types(ext):
|
||||
try:
|
||||
with Image.open(
|
||||
str(self.lib.library_dir / entry.path / entry.filename)
|
||||
@@ -107,39 +95,46 @@ class CollageIconRenderer(QObject):
|
||||
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 as e:
|
||||
logging.info(f"[ERROR] One of the images was too big ({e})")
|
||||
elif filepath.suffix.lower() in VIDEO_TYPES:
|
||||
video = cv2.VideoCapture(str(filepath))
|
||||
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)
|
||||
elif MediaType.VIDEO in MediaCategories.get_types(ext):
|
||||
if 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)
|
||||
with Image.fromarray(frame, mode="RGB") as pic:
|
||||
if keep_aspect:
|
||||
pic.thumbnail(size)
|
||||
else:
|
||||
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)
|
||||
# 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:
|
||||
pic.thumbnail(size)
|
||||
else:
|
||||
pic = pic.resize(size)
|
||||
if data_tint_mode and color:
|
||||
pic = ImageChops.hard_light(
|
||||
pic, Image.new("RGB", size, color)
|
||||
)
|
||||
self.rendered.emit(pic)
|
||||
except (UnidentifiedImageError, FileNotFoundError):
|
||||
logging.info(
|
||||
f"\n{ERROR} Couldn't read {entry.path}{os.sep}{entry.filename}"
|
||||
)
|
||||
logging.info(f"\n{ERROR} Couldn't read {entry.path}/{entry.filename}")
|
||||
with Image.open(
|
||||
str(
|
||||
Path(__file__).parents[2]
|
||||
@@ -150,31 +145,27 @@ 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 KeyboardInterrupt:
|
||||
# self.quit(save=False, backup=True)
|
||||
run = False
|
||||
# clear()
|
||||
logging.info("\n")
|
||||
logging.info(f"{INFO} Collage operation cancelled.")
|
||||
clear_scr = False
|
||||
except:
|
||||
logging.info(f"{ERROR} {entry.path}{os.sep}{entry.filename}")
|
||||
except Exception:
|
||||
logging.info(f"{ERROR} {entry.path}/{entry.filename}")
|
||||
traceback.print_exc()
|
||||
logging.info("Continuing...")
|
||||
|
||||
self.done.emit()
|
||||
# logging.info('Done!')
|
||||
|
||||
# NOTE: Depreciated
|
||||
def get_file_color(self, ext: str):
|
||||
if ext.lower().replace(".", "", 1) == "gif":
|
||||
_ext = ext.lower().replace(".", "", 1)
|
||||
if _ext == "gif":
|
||||
return "\033[93m"
|
||||
if ext.lower().replace(".", "", 1) in IMAGE_TYPES:
|
||||
elif MediaType.IMAGE in MediaCategories.get_types(_ext):
|
||||
return "\033[37m"
|
||||
elif ext.lower().replace(".", "", 1) in VIDEO_TYPES:
|
||||
elif MediaType.VIDEO in MediaCategories.get_types(_ext):
|
||||
return "\033[96m"
|
||||
elif ext.lower().replace(".", "", 1) in DOC_TYPES:
|
||||
elif MediaType.DOCUMENT in MediaCategories.get_types(_ext):
|
||||
return "\033[92m"
|
||||
else:
|
||||
return "\033[97m"
|
||||
|
||||
@@ -4,15 +4,16 @@
|
||||
|
||||
|
||||
import math
|
||||
import os
|
||||
from types import FunctionType, MethodType
|
||||
from pathlib import Path
|
||||
from typing import Optional, cast, Callable, Any
|
||||
from typing import Optional, cast, Callable
|
||||
|
||||
from PIL import Image, ImageQt
|
||||
from PySide6.QtCore import Qt, QEvent
|
||||
from PySide6.QtGui import QPixmap, QEnterEvent
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel
|
||||
from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper
|
||||
from src.qt.helpers.color_overlay import theme_fg_overlay
|
||||
|
||||
|
||||
class FieldContainer(QWidget):
|
||||
@@ -34,22 +35,25 @@ class FieldContainer(QWidget):
|
||||
|
||||
def __init__(self, title: str = "Field", inline: bool = True) -> None:
|
||||
super().__init__()
|
||||
# self.mode:str = mode
|
||||
self.setObjectName("fieldContainer")
|
||||
# self.item = item
|
||||
self.title: str = title
|
||||
self.inline: bool = inline
|
||||
# self.editable:bool = editable
|
||||
self.copy_callback: FunctionType = None
|
||||
self.edit_callback: FunctionType = None
|
||||
self.remove_callback: Callable = None
|
||||
button_size = 24
|
||||
# self.setStyleSheet('border-style:solid;border-color:#1e1a33;border-radius:8px;border-width:2px;')
|
||||
|
||||
self.clipboard_icon_128 = theme_fg_overlay(FieldContainer.clipboard_icon_128)
|
||||
self.edit_icon_128 = theme_fg_overlay(FieldContainer.edit_icon_128)
|
||||
self.trash_icon_128 = theme_fg_overlay(FieldContainer.trash_icon_128)
|
||||
|
||||
self.clipboard_icon_128 = theme_fg_overlay(FieldContainer.clipboard_icon_128)
|
||||
self.edit_icon_128 = theme_fg_overlay(FieldContainer.edit_icon_128)
|
||||
self.trash_icon_128 = theme_fg_overlay(FieldContainer.trash_icon_128)
|
||||
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setObjectName("baseLayout")
|
||||
self.root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
# self.setStyleSheet('background-color:red;')
|
||||
|
||||
self.inner_layout = QVBoxLayout()
|
||||
self.inner_layout.setObjectName("innerLayout")
|
||||
@@ -61,7 +65,6 @@ class FieldContainer(QWidget):
|
||||
self.root_layout.addWidget(self.inner_container)
|
||||
|
||||
self.title_container = QWidget()
|
||||
# self.title_container.setStyleSheet('background:black;')
|
||||
self.title_layout = QHBoxLayout(self.title_container)
|
||||
self.title_layout.setAlignment(Qt.AlignmentFlag.AlignHCenter)
|
||||
self.title_layout.setObjectName("fieldLayout")
|
||||
@@ -74,14 +77,12 @@ class FieldContainer(QWidget):
|
||||
self.title_widget.setObjectName("fieldTitle")
|
||||
self.title_widget.setWordWrap(True)
|
||||
self.title_widget.setStyleSheet("font-weight: bold; font-size: 14px;")
|
||||
# self.title_widget.setStyleSheet('background-color:orange;')
|
||||
self.title_widget.setText(title)
|
||||
# self.inner_layout.addWidget(self.title_widget)
|
||||
self.title_layout.addWidget(self.title_widget)
|
||||
|
||||
self.title_layout.addStretch(2)
|
||||
|
||||
self.copy_button = QPushButton()
|
||||
self.copy_button = QPushButtonWrapper()
|
||||
self.copy_button.setMinimumSize(button_size, button_size)
|
||||
self.copy_button.setMaximumSize(button_size, button_size)
|
||||
self.copy_button.setFlat(True)
|
||||
@@ -92,7 +93,7 @@ class FieldContainer(QWidget):
|
||||
self.title_layout.addWidget(self.copy_button)
|
||||
self.copy_button.setHidden(True)
|
||||
|
||||
self.edit_button = QPushButton()
|
||||
self.edit_button = QPushButtonWrapper()
|
||||
self.edit_button.setMinimumSize(button_size, button_size)
|
||||
self.edit_button.setMaximumSize(button_size, button_size)
|
||||
self.edit_button.setFlat(True)
|
||||
@@ -101,7 +102,7 @@ class FieldContainer(QWidget):
|
||||
self.title_layout.addWidget(self.edit_button)
|
||||
self.edit_button.setHidden(True)
|
||||
|
||||
self.remove_button = QPushButton()
|
||||
self.remove_button = QPushButtonWrapper()
|
||||
self.remove_button.setMinimumSize(button_size, button_size)
|
||||
self.remove_button.setMaximumSize(button_size, button_size)
|
||||
self.remove_button.setFlat(True)
|
||||
@@ -118,45 +119,40 @@ class FieldContainer(QWidget):
|
||||
self.field_layout.setObjectName("fieldLayout")
|
||||
self.field_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.field_container.setLayout(self.field_layout)
|
||||
# self.field_container.setStyleSheet('background-color:#666600;')
|
||||
self.inner_layout.addWidget(self.field_container)
|
||||
|
||||
# self.set_inner_widget(mode)
|
||||
|
||||
def set_copy_callback(self, callback: Optional[MethodType]):
|
||||
try:
|
||||
if self.copy_button.is_connected:
|
||||
self.copy_button.clicked.disconnect()
|
||||
except RuntimeError:
|
||||
pass
|
||||
self.copy_button.is_connected = False
|
||||
|
||||
self.copy_callback = callback
|
||||
self.copy_button.clicked.connect(callback)
|
||||
if callback is not None:
|
||||
self.copy_button.is_connected = True
|
||||
|
||||
def set_edit_callback(self, callback: Optional[MethodType]):
|
||||
try:
|
||||
if self.edit_button.is_connected:
|
||||
self.edit_button.clicked.disconnect()
|
||||
except RuntimeError:
|
||||
pass
|
||||
self.edit_button.is_connected = False
|
||||
|
||||
self.edit_callback = callback
|
||||
self.edit_button.clicked.connect(callback)
|
||||
if callback is not None:
|
||||
self.edit_button.is_connected = True
|
||||
|
||||
def set_remove_callback(self, callback: Optional[Callable]):
|
||||
try:
|
||||
if self.remove_button.is_connected:
|
||||
self.remove_button.clicked.disconnect()
|
||||
except RuntimeError:
|
||||
pass
|
||||
self.remove_button.is_connected = False
|
||||
|
||||
self.remove_callback = callback
|
||||
self.remove_button.clicked.connect(callback)
|
||||
if callback is not None:
|
||||
self.remove_button.is_connected = True
|
||||
|
||||
def set_inner_widget(self, widget: "FieldWidget"):
|
||||
# widget.setStyleSheet('background-color:green;')
|
||||
# self.inner_container.dumpObjectTree()
|
||||
# logging.info('')
|
||||
if self.field_layout.itemAt(0):
|
||||
# logging.info(f'Removing {self.field_layout.itemAt(0)}')
|
||||
# self.field_layout.removeItem(self.field_layout.itemAt(0))
|
||||
self.field_layout.itemAt(0).widget().deleteLater()
|
||||
self.field_layout.addWidget(widget)
|
||||
|
||||
@@ -172,12 +168,7 @@ class FieldContainer(QWidget):
|
||||
def set_inline(self, inline: bool):
|
||||
self.inline = inline
|
||||
|
||||
# def set_editable(self, editable:bool):
|
||||
# self.editable = editable
|
||||
|
||||
def enterEvent(self, event: QEnterEvent) -> None:
|
||||
# if self.field_layout.itemAt(1):
|
||||
# self.field_layout.itemAt(1).
|
||||
# NOTE: You could pass the hover event to the FieldWidget if needed.
|
||||
if self.copy_callback:
|
||||
self.copy_button.setHidden(False)
|
||||
@@ -202,5 +193,4 @@ class FieldWidget(QWidget):
|
||||
|
||||
def __init__(self, title) -> None:
|
||||
super().__init__()
|
||||
# self.item = item
|
||||
self.title = title
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import time
|
||||
import typing
|
||||
from types import FunctionType
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
import platform
|
||||
|
||||
from PIL import Image, ImageQt
|
||||
from PySide6.QtCore import Qt, QSize, QEvent
|
||||
from PySide6.QtGui import QPixmap, QEnterEvent, QAction
|
||||
from PySide6.QtCore import Qt, QSize, QEvent, QMimeData, QUrl
|
||||
from PySide6.QtGui import QPixmap, QEnterEvent, QAction, QDrag
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
@@ -23,9 +23,13 @@ from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
)
|
||||
|
||||
|
||||
from src.core.enums import FieldID
|
||||
from src.core.library import ItemType, Library, Entry
|
||||
from src.core.constants import AUDIO_TYPES, VIDEO_TYPES, IMAGE_TYPES
|
||||
from src.core.constants import (
|
||||
TAG_FAVORITE,
|
||||
TAG_ARCHIVED,
|
||||
)
|
||||
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.widgets.thumb_renderer import ThumbRenderer
|
||||
@@ -38,9 +42,6 @@ ERROR = f"[ERROR]"
|
||||
WARNING = f"[WARNING]"
|
||||
INFO = f"[INFO]"
|
||||
|
||||
DEFAULT_META_TAG_FIELD = 8
|
||||
TAG_FAVORITE = 1
|
||||
TAG_ARCHIVED = 0
|
||||
|
||||
logging.basicConfig(format="%(message)s", level=logging.INFO)
|
||||
|
||||
@@ -63,27 +64,29 @@ class ItemThumb(FlowWidget):
|
||||
tag_group_icon_128.load()
|
||||
|
||||
small_text_style = (
|
||||
f"background-color:rgba(0, 0, 0, 192);"
|
||||
f"font-family:Oxanium;"
|
||||
f"font-weight:bold;"
|
||||
f"font-size:12px;"
|
||||
f"border-radius:3px;"
|
||||
f"padding-top: 4px;"
|
||||
f"padding-right: 1px;"
|
||||
f"padding-bottom: 1px;"
|
||||
f"padding-left: 1px;"
|
||||
"background-color:rgba(0, 0, 0, 192);"
|
||||
"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;"
|
||||
)
|
||||
|
||||
med_text_style = (
|
||||
f"background-color:rgba(0, 0, 0, 192);"
|
||||
f"font-family:Oxanium;"
|
||||
f"font-weight:bold;"
|
||||
f"font-size:18px;"
|
||||
f"border-radius:3px;"
|
||||
f"padding-top: 4px;"
|
||||
f"padding-right: 1px;"
|
||||
f"padding-bottom: 1px;"
|
||||
f"padding-left: 1px;"
|
||||
"background-color:rgba(0, 0, 0, 192);"
|
||||
"color:#FFFFFF;"
|
||||
"font-family:Oxanium;"
|
||||
"font-weight:bold;"
|
||||
"font-size:18px;"
|
||||
"border-radius:3px;"
|
||||
"padding-top: 4px;"
|
||||
"padding-right: 1px;"
|
||||
"padding-bottom: 1px;"
|
||||
"padding-left: 1px;"
|
||||
)
|
||||
|
||||
def __init__(
|
||||
@@ -104,6 +107,7 @@ class ItemThumb(FlowWidget):
|
||||
self.thumb_size: tuple[int, int] = thumb_size
|
||||
self.setMinimumSize(*thumb_size)
|
||||
self.setMaximumSize(*thumb_size)
|
||||
self.setMouseTracking(True)
|
||||
check_size = 24
|
||||
# self.setStyleSheet('background-color:red;')
|
||||
|
||||
@@ -193,10 +197,26 @@ class ItemThumb(FlowWidget):
|
||||
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)
|
||||
|
||||
system = platform.system()
|
||||
open_explorer_action = QAction(
|
||||
"Open in explorer", self
|
||||
) # Default (mainly going to be for linux)
|
||||
if system == "Darwin":
|
||||
open_explorer_action = QAction("Reveal in Finder", self)
|
||||
elif system == "Windows":
|
||||
open_explorer_action = QAction("Open in Explorer", self)
|
||||
|
||||
open_explorer_action.triggered.connect(self.opener.open_explorer)
|
||||
|
||||
trash_term: str = "Trash"
|
||||
if platform.system() == "Windows":
|
||||
trash_term = "Recycle Bin"
|
||||
self.delete_action = QAction(f"Send file to {trash_term}", self)
|
||||
|
||||
self.thumb_button.addAction(open_file_action)
|
||||
self.thumb_button.addAction(open_explorer_action)
|
||||
self.thumb_button.addAction(self.delete_action)
|
||||
|
||||
# Static Badges ========================================================
|
||||
|
||||
@@ -311,6 +331,7 @@ class ItemThumb(FlowWidget):
|
||||
|
||||
def set_mode(self, mode: Optional[ItemType]) -> None:
|
||||
if mode is None:
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
|
||||
self.unsetCursor()
|
||||
self.thumb_button.setHidden(True)
|
||||
# self.check_badges.setHidden(True)
|
||||
@@ -318,6 +339,7 @@ class ItemThumb(FlowWidget):
|
||||
# self.item_type_badge.setHidden(True)
|
||||
pass
|
||||
elif mode == ItemType.ENTRY and self.mode != ItemType.ENTRY:
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False)
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.thumb_button.setHidden(False)
|
||||
self.cb_container.setHidden(False)
|
||||
@@ -327,6 +349,7 @@ class ItemThumb(FlowWidget):
|
||||
self.count_badge.setHidden(True)
|
||||
self.ext_badge.setHidden(True)
|
||||
elif mode == ItemType.COLLATION and self.mode != ItemType.COLLATION:
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False)
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.thumb_button.setHidden(False)
|
||||
self.cb_container.setHidden(True)
|
||||
@@ -335,6 +358,7 @@ class ItemThumb(FlowWidget):
|
||||
self.count_badge.setHidden(False)
|
||||
self.item_type_badge.setHidden(False)
|
||||
elif mode == ItemType.TAG_GROUP and self.mode != ItemType.TAG_GROUP:
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False)
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.thumb_button.setHidden(False)
|
||||
# self.cb_container.setHidden(True)
|
||||
@@ -352,10 +376,27 @@ 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"]:
|
||||
if (
|
||||
ext
|
||||
and (MediaType.IMAGE not in MediaCategories.get_types(ext))
|
||||
or (MediaType.IMAGE_RAW in MediaCategories.get_types(ext))
|
||||
or (MediaType.IMAGE_VECTOR in MediaCategories.get_types(ext))
|
||||
or (MediaType.ADOBE_PHOTOSHOP in MediaCategories.get_types(ext))
|
||||
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 MediaCategories.get_types(ext)) or (
|
||||
MediaType.AUDIO in MediaCategories.get_types(ext)
|
||||
):
|
||||
self.count_badge.setHidden(False)
|
||||
else:
|
||||
if self.mode == ItemType.ENTRY:
|
||||
@@ -390,19 +431,22 @@ class ItemThumb(FlowWidget):
|
||||
def update_clickable(self, clickable: typing.Callable):
|
||||
"""Updates attributes of a thumbnail element."""
|
||||
# logging.info(f'[GUI] Updating Click Event for element {id(element)}: {id(clickable) if clickable else None}')
|
||||
try:
|
||||
if self.thumb_button.is_connected:
|
||||
self.thumb_button.clicked.disconnect()
|
||||
except RuntimeError:
|
||||
pass
|
||||
if clickable:
|
||||
self.thumb_button.clicked.connect(clickable)
|
||||
self.thumb_button.is_connected = True
|
||||
|
||||
def update_badges(self):
|
||||
if self.mode == ItemType.ENTRY:
|
||||
# logging.info(f'[UPDATE BADGES] ENTRY: {self.lib.get_entry(self.item_id)}')
|
||||
# logging.info(f'[UPDATE BADGES] ARCH: {self.lib.get_entry(self.item_id).has_tag(self.lib, 0)}, FAV: {self.lib.get_entry(self.item_id).has_tag(self.lib, 1)}')
|
||||
self.assign_archived(self.lib.get_entry(self.item_id).has_tag(self.lib, 0))
|
||||
self.assign_favorite(self.lib.get_entry(self.item_id).has_tag(self.lib, 1))
|
||||
self.assign_archived(
|
||||
self.lib.get_entry(self.item_id).has_tag(self.lib, TAG_ARCHIVED)
|
||||
)
|
||||
self.assign_favorite(
|
||||
self.lib.get_entry(self.item_id).has_tag(self.lib, TAG_FAVORITE)
|
||||
)
|
||||
|
||||
def set_item_id(self, id: int):
|
||||
"""
|
||||
@@ -471,7 +515,7 @@ class ItemThumb(FlowWidget):
|
||||
entry.add_tag(
|
||||
self.panel.driver.lib,
|
||||
tag_id,
|
||||
field_id=DEFAULT_META_TAG_FIELD,
|
||||
field_id=FieldID.META_TAGS,
|
||||
field_index=-1,
|
||||
)
|
||||
else:
|
||||
@@ -491,3 +535,26 @@ class ItemThumb(FlowWidget):
|
||||
if self.panel.isOpen:
|
||||
self.panel.update_widgets()
|
||||
self.panel.driver.update_badges()
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
if event.buttons() is not Qt.MouseButton.LeftButton:
|
||||
return
|
||||
|
||||
drag = QDrag(self.panel.driver)
|
||||
paths = []
|
||||
mimedata = QMimeData()
|
||||
|
||||
selected_ids = list(map(lambda x: x[1], self.panel.driver.selected))
|
||||
if self.item_id not in selected_ids:
|
||||
selected_ids = [self.item_id]
|
||||
|
||||
for id in selected_ids:
|
||||
entry = self.lib.get_entry(id)
|
||||
url = QUrl.fromLocalFile(
|
||||
Path(self.lib.library_dir) / entry.path / entry.filename
|
||||
)
|
||||
paths.append(url)
|
||||
|
||||
mimedata.setUrls(paths)
|
||||
drag.setMimeData(mimedata)
|
||||
drag.exec(Qt.DropAction.CopyAction)
|
||||
|
||||
@@ -3,17 +3,18 @@
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import time
|
||||
import typing
|
||||
from datetime import datetime as dt
|
||||
|
||||
import cv2
|
||||
import rawpy
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from PIL import Image, UnidentifiedImageError, ImageFont
|
||||
from PIL.Image import DecompressionBombError
|
||||
from PySide6.QtCore import Signal, Qt, QSize
|
||||
from PySide6.QtGui import QResizeEvent, QAction
|
||||
from PySide6.QtCore import QModelIndex, Signal, Qt, QSize, QByteArray, QBuffer
|
||||
from PySide6.QtGui import QGuiApplication, QResizeEvent, QAction, QMovie
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
@@ -27,10 +28,13 @@ from PySide6.QtWidgets import (
|
||||
QMessageBox,
|
||||
)
|
||||
from humanfriendly import format_size
|
||||
|
||||
from src.core.enums import SettingItems, Theme
|
||||
from src.core.library import Entry, ItemType, Library
|
||||
from src.core.constants import VIDEO_TYPES, IMAGE_TYPES, RAW_IMAGE_TYPES, TS_FOLDER_NAME
|
||||
from src.core.constants import (
|
||||
TS_FOLDER_NAME,
|
||||
)
|
||||
from src.core.media_types import MediaCategories, MediaType
|
||||
from src.qt.helpers.rounded_pixmap_style import RoundedPixmapStyle
|
||||
from src.qt.helpers.file_opener import FileOpenerLabel, FileOpenerHelper, open_file
|
||||
from src.qt.modals.add_field import AddFieldModal
|
||||
from src.qt.widgets.thumb_renderer import ThumbRenderer
|
||||
@@ -40,7 +44,10 @@ from src.qt.widgets.text import TextWidget
|
||||
from src.qt.widgets.panel import PanelModal
|
||||
from src.qt.widgets.text_box_edit import EditTextBox
|
||||
from src.qt.widgets.text_line_edit import EditTextLine
|
||||
from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper
|
||||
from src.qt.widgets.video_player import VideoPlayer
|
||||
from src.qt.helpers.file_tester import is_readable_video
|
||||
from src.qt.resource_manager import ResourceManager
|
||||
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
@@ -61,6 +68,7 @@ class PreviewPanel(QWidget):
|
||||
|
||||
def __init__(self, library: Library, driver: "QtDriver"):
|
||||
super().__init__()
|
||||
self.is_connected = False
|
||||
self.lib = library
|
||||
self.driver: QtDriver = driver
|
||||
self.initialized = False
|
||||
@@ -76,22 +84,71 @@ 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)
|
||||
|
||||
self.open_file_action = QAction("Open file", self)
|
||||
self.open_explorer_action = QAction("Open file in explorer", self)
|
||||
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.preview_img = QPushButton()
|
||||
self.open_file_action = QAction("Open file", self)
|
||||
self.trash_term: str = "Trash"
|
||||
if platform.system() == "Windows":
|
||||
self.trash_term = "Recycle Bin"
|
||||
self.delete_action = QAction(f"Send file to {self.trash_term}", self)
|
||||
|
||||
self.open_explorer_action = QAction(
|
||||
"Open in explorer", self
|
||||
) # Default text (Linux, etc.)
|
||||
if platform.system() == "Darwin":
|
||||
self.open_explorer_action = QAction("Reveal in Finder", self)
|
||||
elif platform.system() == "Windows":
|
||||
self.open_explorer_action = QAction("Open in Explorer", 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_img.addAction(self.delete_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.addAction(self.delete_action)
|
||||
self.preview_gif.hide()
|
||||
self.gif_buffer: QBuffer = QBuffer()
|
||||
|
||||
self.preview_vid = VideoPlayer(driver)
|
||||
self.preview_vid.hide()
|
||||
self.preview_vid.addAction(self.delete_action)
|
||||
self.thumb_renderer = ThumbRenderer()
|
||||
self.thumb_renderer.updated.connect(
|
||||
lambda ts, i, s: (self.preview_img.setIcon(i))
|
||||
@@ -111,33 +168,31 @@ class PreviewPanel(QWidget):
|
||||
|
||||
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("filename")
|
||||
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("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("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("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()
|
||||
@@ -166,15 +221,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)
|
||||
|
||||
@@ -218,7 +282,7 @@ class PreviewPanel(QWidget):
|
||||
self.afb_layout = QVBoxLayout(self.afb_container)
|
||||
self.afb_layout.setContentsMargins(0, 12, 0, 0)
|
||||
|
||||
self.add_field_button = QPushButton()
|
||||
self.add_field_button = QPushButtonWrapper()
|
||||
self.add_field_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.add_field_button.setMinimumSize(96, 28)
|
||||
self.add_field_button.setMaximumSize(96, 28)
|
||||
@@ -273,19 +337,20 @@ class PreviewPanel(QWidget):
|
||||
clear_layout(layout)
|
||||
|
||||
label = QLabel("Recent Libraries")
|
||||
label.setStyleSheet("font-weight:bold;")
|
||||
label.setAlignment(Qt.AlignCenter) # type: ignore
|
||||
|
||||
row_layout = QHBoxLayout()
|
||||
row_layout.addWidget(label)
|
||||
layout.addLayout(row_layout)
|
||||
|
||||
def set_button_style(btn: QPushButton, extras: list[str] | None = None):
|
||||
def set_button_style(
|
||||
btn: QPushButtonWrapper | QPushButton, extras: list[str] | None = None
|
||||
):
|
||||
base_style = [
|
||||
f"background-color:{Theme.COLOR_BG.value};",
|
||||
f"background-color:{self.panel_bg_color};",
|
||||
"border-radius:6px;",
|
||||
"text-align: left;",
|
||||
"padding-top: 3px;",
|
||||
"padding-left: 6px;",
|
||||
"padding-bottom: 4px;",
|
||||
]
|
||||
|
||||
@@ -316,12 +381,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: (
|
||||
@@ -390,20 +454,14 @@ class PreviewPanel(QWidget):
|
||||
self.preview_vid.resizeVideo(adj_size)
|
||||
self.preview_vid.setMaximumSize(adj_size)
|
||||
self.preview_vid.setMinimumSize(adj_size)
|
||||
# self.preview_img.setMinimumSize(adj_size)
|
||||
|
||||
# if self.preview_img.iconSize().toTuple()[0] < self.preview_img.size().toTuple()[0] + 10:
|
||||
# if type(self.item) == Entry:
|
||||
# filepath = os.path.normpath(f'{self.lib.library_dir}/{self.item.path}/{self.item.filename}')
|
||||
# self.thumb_renderer.render(time.time(), filepath, self.preview_img.size().toTuple(), self.devicePixelRatio(),update_on_ratio_change=True)
|
||||
|
||||
# logging.info(f' Img Aspect Ratio: {self.image_ratio}')
|
||||
# logging.info(f' Max Button Size: {size}')
|
||||
# logging.info(f'Container Size: {(self.image_container.size().width(), self.image_container.size().height())}')
|
||||
# logging.info(f'Final Button Size: {(adj_width, adj_height)}')
|
||||
# logging.info(f'')
|
||||
# logging.info(f' Icon Size: {self.preview_img.icon().actualSize().toTuple()}')
|
||||
# logging.info(f'Button Size: {self.preview_img.size().toTuple()}')
|
||||
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)
|
||||
@@ -411,25 +469,51 @@ class PreviewPanel(QWidget):
|
||||
self.afb_container, Qt.AlignmentFlag.AlignHCenter
|
||||
)
|
||||
|
||||
try:
|
||||
if self.afm.is_connected:
|
||||
self.afm.done.disconnect()
|
||||
if self.add_field_button.is_connected:
|
||||
self.add_field_button.clicked.disconnect()
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
# self.afm.done.connect(lambda f: (self.lib.add_field_to_entry(self.selected[0][1], f), self.update_widgets()))
|
||||
self.afm.done.connect(
|
||||
lambda f: (self.add_field_to_selected(f), self.update_widgets())
|
||||
)
|
||||
self.afm.is_connected = True
|
||||
self.add_field_button.clicked.connect(self.afm.show)
|
||||
|
||||
def add_field_to_selected(self, field_id: int):
|
||||
"""Adds an entry field to one or more selected items."""
|
||||
added = set()
|
||||
for item_pair in self.selected:
|
||||
if item_pair[0] == ItemType.ENTRY and item_pair[1] not in added:
|
||||
self.lib.add_field_to_entry(item_pair[1], field_id)
|
||||
added.add(item_pair[1])
|
||||
def add_field_to_selected(self, field_list: list[QModelIndex]):
|
||||
"""Add list of entry fields to one or more selected items."""
|
||||
for item_type, item_id in self.selected:
|
||||
if item_type != ItemType.ENTRY:
|
||||
continue
|
||||
for field_item in field_list:
|
||||
self.lib.add_field_to_entry(item_id, field_item.row())
|
||||
|
||||
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]
|
||||
else:
|
||||
created = dt.fromtimestamp(filepath.stat().st_ctime)
|
||||
modified: dt = dt.fromtimestamp(filepath.stat().st_mtime)
|
||||
self.date_created_label.setText(
|
||||
f"<b>Date Created:</b> {dt.strftime(created, "%a, %x, %X")}"
|
||||
)
|
||||
self.date_modified_label.setText(
|
||||
f"<b>Date Modified:</b> {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("<b>Date Created:</b> <i>N/A</i>")
|
||||
self.date_modified_label.setText("<b>Date Modified:</b> <i>N/A</i>")
|
||||
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, item: Union[Entry, Collation, Tag]):
|
||||
def update_widgets(self):
|
||||
@@ -447,11 +531,12 @@ class PreviewPanel(QWidget):
|
||||
# 0 Selected Items
|
||||
if not self.driver.selected:
|
||||
if self.selected or not self.initialized:
|
||||
self.file_label.setText("No Items Selected")
|
||||
self.file_label.setText("<i>No Items Selected</i>")
|
||||
self.file_label.setFilePath("")
|
||||
self.file_label.setCursor(Qt.CursorShape.ArrowCursor)
|
||||
|
||||
self.dimensions_label.setText("")
|
||||
self.update_date_label()
|
||||
self.preview_img.setContextMenuPolicy(
|
||||
Qt.ContextMenuPolicy.NoContextMenu
|
||||
)
|
||||
@@ -466,15 +551,22 @@ class PreviewPanel(QWidget):
|
||||
True,
|
||||
update_on_ratio_change=True,
|
||||
)
|
||||
try:
|
||||
if self.preview_img.is_connected:
|
||||
self.preview_img.clicked.disconnect()
|
||||
except RuntimeError:
|
||||
self.preview_img.is_connected = False
|
||||
|
||||
try:
|
||||
self.delete_action.triggered.disconnect()
|
||||
except RuntimeWarning:
|
||||
pass
|
||||
self.delete_action.setEnabled(False)
|
||||
|
||||
for i, c in enumerate(self.containers):
|
||||
c.setHidden(True)
|
||||
self.preview_img.show()
|
||||
self.preview_vid.stop()
|
||||
self.preview_vid.hide()
|
||||
self.preview_gif.hide()
|
||||
self.selected = list(self.driver.selected)
|
||||
self.add_field_button.setHidden(True)
|
||||
|
||||
@@ -485,6 +577,7 @@ class PreviewPanel(QWidget):
|
||||
self.preview_img.show()
|
||||
self.preview_vid.stop()
|
||||
self.preview_vid.hide()
|
||||
self.preview_gif.hide()
|
||||
item: Entry = self.lib.get_entry(self.driver.selected[0][1])
|
||||
# If a new selection is made, update the thumbnail and filepath.
|
||||
if not self.selected or self.selected != self.driver.selected:
|
||||
@@ -499,7 +592,17 @@ class PreviewPanel(QWidget):
|
||||
ratio,
|
||||
update_on_ratio_change=True,
|
||||
)
|
||||
self.file_label.setText("\u200b".join(str(filepath)))
|
||||
file_str: str = ""
|
||||
separator: str = (
|
||||
f"<a style='color: #777777'><b>{os.path.sep}</a>" # 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}</b>"
|
||||
else:
|
||||
file_str += f"<br><b>{"\u200b".join(part_)}</b>"
|
||||
self.file_label.setText(file_str)
|
||||
self.file_label.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
|
||||
self.preview_img.setContextMenuPolicy(
|
||||
@@ -507,18 +610,63 @@ class PreviewPanel(QWidget):
|
||||
)
|
||||
self.preview_img.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
|
||||
try:
|
||||
self.delete_action.triggered.disconnect()
|
||||
except RuntimeError:
|
||||
pass
|
||||
self.delete_action.setText(f"Send file to {self.trash_term}")
|
||||
self.delete_action.triggered.connect(
|
||||
lambda checked=False,
|
||||
f=filepath: self.driver.delete_files_callback(f)
|
||||
)
|
||||
self.delete_action.setEnabled(True)
|
||||
|
||||
self.opener = FileOpenerHelper(filepath)
|
||||
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:
|
||||
image = None
|
||||
if filepath.suffix.lower() in IMAGE_TYPES:
|
||||
if filepath.suffix.lower() in [".gif"]:
|
||||
with open(filepath, mode="rb") as f:
|
||||
if self.preview_gif.movie():
|
||||
self.preview_gif.movie().stop()
|
||||
self.gif_buffer.close()
|
||||
|
||||
ba = f.read()
|
||||
self.gif_buffer.setData(ba)
|
||||
movie = QMovie(self.gif_buffer, QByteArray())
|
||||
self.preview_gif.setMovie(movie)
|
||||
movie.start()
|
||||
|
||||
image = Image.open(str(filepath))
|
||||
elif filepath.suffix.lower() in RAW_IMAGE_TYPES:
|
||||
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 (
|
||||
(MediaType.IMAGE in MediaCategories.get_types(ext))
|
||||
and (
|
||||
MediaType.IMAGE_RAW
|
||||
not in MediaCategories.get_types(ext)
|
||||
)
|
||||
and (
|
||||
MediaType.IMAGE_VECTOR
|
||||
not in MediaCategories.get_types(ext)
|
||||
)
|
||||
):
|
||||
image = Image.open(str(filepath))
|
||||
elif MediaType.IMAGE_RAW in MediaCategories.get_types(ext):
|
||||
try:
|
||||
with rawpy.imread(str(filepath)) as raw:
|
||||
rgb = raw.postprocess()
|
||||
@@ -530,70 +678,97 @@ class PreviewPanel(QWidget):
|
||||
rawpy._rawpy.LibRawFileUnsupportedError,
|
||||
):
|
||||
pass
|
||||
elif filepath.suffix.lower() in VIDEO_TYPES:
|
||||
video = cv2.VideoCapture(str(filepath))
|
||||
video.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
||||
success, frame = video.read()
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
image = Image.fromarray(frame)
|
||||
if success:
|
||||
self.preview_img.hide()
|
||||
self.preview_vid.play(
|
||||
filepath, QSize(image.width, image.height)
|
||||
elif MediaType.VIDEO in MediaCategories.get_types(ext):
|
||||
if 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),
|
||||
)
|
||||
self.resizeEvent(
|
||||
QResizeEvent(
|
||||
QSize(image.width, image.height),
|
||||
QSize(image.width, image.height),
|
||||
success, frame = video.read()
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
image = Image.fromarray(frame)
|
||||
if success:
|
||||
self.preview_img.hide()
|
||||
self.preview_vid.play(
|
||||
filepath, QSize(image.width, image.height)
|
||||
)
|
||||
)
|
||||
self.preview_vid.show()
|
||||
self.resizeEvent(
|
||||
QResizeEvent(
|
||||
QSize(image.width, image.height),
|
||||
QSize(image.width, image.height),
|
||||
)
|
||||
)
|
||||
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 (
|
||||
(MediaType.IMAGE in MediaCategories.get_types(ext))
|
||||
or (MediaType.VIDEO in MediaCategories.get_types(ext, True))
|
||||
or (
|
||||
MediaType.IMAGE_RAW
|
||||
in MediaCategories.get_types(ext, True)
|
||||
)
|
||||
):
|
||||
self.dimensions_label.setText(
|
||||
f"{filepath.suffix.upper()[1:]} • {format_size(filepath.stat().st_size)}\n{image.width} x {image.height} px"
|
||||
f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}\n{image.width} x {image.height} px"
|
||||
)
|
||||
elif MediaType.FONT in MediaCategories.get_types(ext, True):
|
||||
try:
|
||||
font = ImageFont.truetype(filepath)
|
||||
self.dimensions_label.setText(
|
||||
f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}\n{font.getname()[0]} ({font.getname()[1]}) "
|
||||
)
|
||||
except OSError:
|
||||
self.dimensions_label.setText(
|
||||
f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}"
|
||||
)
|
||||
logging.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:]} • {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 as e:
|
||||
self.dimensions_label.setText(f"{filepath.suffix.upper()[1:]}")
|
||||
self.dimensions_label.setText(f"{ext.upper()[1:]}")
|
||||
logging.info(
|
||||
f"[PreviewPanel][ERROR] Couldn't Render thumbnail for {filepath} (because of {e})"
|
||||
)
|
||||
self.update_date_label()
|
||||
|
||||
except (FileNotFoundError, cv2.error) as e:
|
||||
self.dimensions_label.setText(f"{filepath.suffix.upper()}")
|
||||
self.dimensions_label.setText(f"{ext.upper()[1:]}")
|
||||
logging.info(
|
||||
f"[PreviewPanel][ERROR] Couldn't Render thumbnail for {filepath} (because of {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)}"
|
||||
)
|
||||
logging.info(
|
||||
f"[PreviewPanel][ERROR] Couldn't Render thumbnail for {filepath} (because of {e})"
|
||||
)
|
||||
self.update_date_label(filepath)
|
||||
|
||||
try:
|
||||
# TODO: Implement a clickable label to use for the GIF preview.
|
||||
if self.preview_img.is_connected:
|
||||
self.preview_img.clicked.disconnect()
|
||||
except RuntimeError:
|
||||
pass
|
||||
self.preview_img.is_connected = False
|
||||
self.preview_img.clicked.connect(
|
||||
lambda checked=False, filepath=filepath: open_file(filepath)
|
||||
)
|
||||
|
||||
self.preview_img.is_connected = True
|
||||
self.selected = list(self.driver.selected)
|
||||
for i, f in enumerate(item.fields):
|
||||
self.write_container(i, f)
|
||||
@@ -617,10 +792,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.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"<b>{len(self.driver.selected)}</b> Items Selected"
|
||||
)
|
||||
self.file_label.setCursor(Qt.CursorShape.ArrowCursor)
|
||||
self.file_label.setFilePath("")
|
||||
self.dimensions_label.setText("")
|
||||
@@ -630,6 +809,16 @@ class PreviewPanel(QWidget):
|
||||
)
|
||||
self.preview_img.setCursor(Qt.CursorShape.ArrowCursor)
|
||||
|
||||
try:
|
||||
self.delete_action.triggered.disconnect()
|
||||
except RuntimeError:
|
||||
pass
|
||||
self.delete_action.setText(f"Send files to {self.trash_term}")
|
||||
self.delete_action.triggered.connect(
|
||||
lambda checked=False, f=None: self.driver.delete_files_callback(f)
|
||||
)
|
||||
self.delete_action.setEnabled(True)
|
||||
|
||||
ratio: float = self.devicePixelRatio()
|
||||
self.thumb_renderer.render(
|
||||
time.time(),
|
||||
@@ -639,10 +828,9 @@ class PreviewPanel(QWidget):
|
||||
True,
|
||||
update_on_ratio_change=True,
|
||||
)
|
||||
try:
|
||||
if self.preview_img.is_connected:
|
||||
self.preview_img.clicked.disconnect()
|
||||
except RuntimeError:
|
||||
pass
|
||||
self.preview_img.is_connected = False
|
||||
|
||||
self.common_fields = []
|
||||
self.mixed_fields = []
|
||||
@@ -771,12 +959,12 @@ class PreviewPanel(QWidget):
|
||||
"""
|
||||
Replacement for tag_callback.
|
||||
"""
|
||||
try:
|
||||
if self.is_connected:
|
||||
self.tags_updated.disconnect()
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
logging.info("[UPDATE CONTAINER] Setting tags updated slot")
|
||||
self.tags_updated.connect(slot)
|
||||
self.is_connected = True
|
||||
|
||||
# def write_container(self, item:Union[Entry, Collation, Tag], index, field):
|
||||
def write_container(self, index, field, mixed=False):
|
||||
@@ -1065,7 +1253,31 @@ class PreviewPanel(QWidget):
|
||||
)
|
||||
# remove_mb.setStandardButtons(QMessageBox.StandardButton.Cancel)
|
||||
remove_mb.setDefaultButton(cancel_button)
|
||||
remove_mb.setEscapeButton(cancel_button)
|
||||
result = remove_mb.exec_()
|
||||
# logging.info(result)
|
||||
if result == 1:
|
||||
if result == 3:
|
||||
callback()
|
||||
|
||||
def stop_file_use(self):
|
||||
"""Stops the use of the currently previewed file. Used to release file permissions."""
|
||||
logging.info("[PreviewPanel] Stopping file use in video playback...")
|
||||
# This swaps the video out for a placeholder so the previous video's file
|
||||
# is no longer in use by this object.
|
||||
self.preview_vid.play(ResourceManager.get_path("placeholder_mp4"), QSize(8, 8))
|
||||
self.preview_vid.hide()
|
||||
|
||||
# NOTE: I'm keeping this here until #357 is merged in the case it still needs to be used.
|
||||
# logging.info("[PreviewPanel] Stopping file use for animated image playback...")
|
||||
# logging.info(self.preview_gif.movie())
|
||||
# if self.preview_gif.movie():
|
||||
# self.preview_gif.movie().stop()
|
||||
# with open(ResourceManager.get_path("placeholder_gif"), mode="rb") as f:
|
||||
# ba = f.read()
|
||||
# self.gif_buffer.setData(ba)
|
||||
# movie = QMovie(self.gif_buffer, QByteArray())
|
||||
# self.preview_gif.setMovie(movie)
|
||||
# movie.start()
|
||||
|
||||
# self.preview_gif.hide()
|
||||
# logging.info(self.preview_gif.movie())
|
||||
|
||||
@@ -74,8 +74,6 @@ class TagWidget(QWidget):
|
||||
# search_for_tag_action.triggered.connect(on_click_callback)
|
||||
search_for_tag_action.triggered.connect(self.on_click.emit)
|
||||
self.bg_button.addAction(search_for_tag_action)
|
||||
add_to_search_action = QAction("Add to Search", self)
|
||||
self.bg_button.addAction(add_to_search_action)
|
||||
|
||||
self.inner_layout = QHBoxLayout()
|
||||
self.inner_layout.setObjectName("innerLayout")
|
||||
|
||||
@@ -9,7 +9,9 @@ import typing
|
||||
|
||||
from PySide6.QtCore import Signal, Qt
|
||||
from PySide6.QtWidgets import QPushButton
|
||||
from PySide6.QtGui import QGuiApplication
|
||||
|
||||
from src.core.constants import TAG_FAVORITE, TAG_ARCHIVED
|
||||
from src.core.library import Library, Tag
|
||||
from src.qt.flowlayout import FlowLayout
|
||||
from src.qt.widgets.fields import FieldWidget
|
||||
@@ -48,6 +50,22 @@ class TagBoxWidget(FieldWidget):
|
||||
self.base_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.setLayout(self.base_layout)
|
||||
|
||||
bg_color: str = (
|
||||
"#1E1E1E"
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else "#EEEEEE"
|
||||
)
|
||||
fg_color: str = (
|
||||
"#FFFFFF"
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else "#444444"
|
||||
)
|
||||
ol_color: str = (
|
||||
"#333333"
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else "#F5F5F5"
|
||||
)
|
||||
|
||||
self.add_button = QPushButton()
|
||||
self.add_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.add_button.setMinimumSize(23, 23)
|
||||
@@ -55,10 +73,10 @@ class TagBoxWidget(FieldWidget):
|
||||
self.add_button.setText("+")
|
||||
self.add_button.setStyleSheet(
|
||||
f"QPushButton{{"
|
||||
f"background: #1e1e1e;"
|
||||
f"color: #FFFFFF;"
|
||||
f"background: {bg_color};"
|
||||
f"color: {fg_color};"
|
||||
f"font-weight: bold;"
|
||||
f"border-color: #333333;"
|
||||
f"border-color: {ol_color};"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width:{math.ceil(1*self.devicePixelRatio())}px;"
|
||||
@@ -75,7 +93,7 @@ class TagBoxWidget(FieldWidget):
|
||||
f"}}"
|
||||
)
|
||||
tsp = TagSearchPanel(self.lib)
|
||||
tsp.tag_chosen.connect(lambda x: self.add_tag_callback(x))
|
||||
tsp.tag_created.connect(lambda x: self.add_tag_callback(x))
|
||||
self.add_modal = PanelModal(tsp, title, "Add Tags")
|
||||
self.add_button.clicked.connect(
|
||||
lambda: (tsp.update_tags(), self.add_modal.show()) # type: ignore
|
||||
@@ -141,7 +159,7 @@ class TagBoxWidget(FieldWidget):
|
||||
# panel.tag_updated.connect(lambda tag: self.lib.update_tag(tag))
|
||||
self.edit_modal.show()
|
||||
|
||||
def add_tag_callback(self, tag_id):
|
||||
def add_tag_callback(self, tag_id: int):
|
||||
# self.base_layout.addWidget(TagWidget(self.lib, self.lib.get_tag(tag), True))
|
||||
# self.tags.append(tag)
|
||||
logging.info(
|
||||
@@ -154,7 +172,7 @@ class TagBoxWidget(FieldWidget):
|
||||
self.driver.lib, tag_id, field_id=id, field_index=-1
|
||||
)
|
||||
self.updated.emit()
|
||||
if tag_id == 0 or tag_id == 1:
|
||||
if tag_id in (TAG_FAVORITE, TAG_ARCHIVED):
|
||||
self.driver.update_badges()
|
||||
|
||||
# if type((x[0]) == ThumbButton):
|
||||
@@ -180,7 +198,7 @@ class TagBoxWidget(FieldWidget):
|
||||
self.driver.lib, tag_id, field_index=index[0]
|
||||
)
|
||||
self.updated.emit()
|
||||
if tag_id == 0 or tag_id == 1:
|
||||
if tag_id in (TAG_FAVORITE, TAG_ARCHIVED):
|
||||
self.driver.update_badges()
|
||||
|
||||
# def show_add_button(self, value:bool):
|
||||
|
||||
@@ -5,18 +5,51 @@
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtCore import QEvent
|
||||
from PySide6.QtGui import QEnterEvent, QPainter, QColor, QPen, QPainterPath, QPaintEvent
|
||||
from PySide6.QtWidgets import QWidget, QPushButton
|
||||
from PySide6.QtGui import (
|
||||
QEnterEvent,
|
||||
QPainter,
|
||||
QColor,
|
||||
QPen,
|
||||
QPainterPath,
|
||||
QPaintEvent,
|
||||
QPalette,
|
||||
)
|
||||
from PySide6.QtWidgets import QWidget
|
||||
from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper
|
||||
|
||||
|
||||
class ThumbButton(QPushButton):
|
||||
class ThumbButton(QPushButtonWrapper):
|
||||
def __init__(self, parent: QWidget, thumb_size: tuple[int, int]) -> None:
|
||||
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:
|
||||
super().paintEvent(event)
|
||||
@@ -24,7 +57,6 @@ class ThumbButton(QPushButton):
|
||||
painter = QPainter()
|
||||
painter.begin(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
# painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Source)
|
||||
path = QPainterPath()
|
||||
width = 3
|
||||
radius = 6
|
||||
@@ -39,27 +71,21 @@ class ThumbButton(QPushButton):
|
||||
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)
|
||||
@@ -67,10 +93,10 @@ class ThumbButton(QPushButton):
|
||||
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:
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import logging
|
||||
import os
|
||||
import typing
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
# os.environ["QT_MEDIA_BACKEND"] = "ffmpeg"
|
||||
import logging
|
||||
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import typing
|
||||
|
||||
from PySide6.QtCore import (
|
||||
Qt,
|
||||
@@ -18,7 +21,6 @@ from PySide6.QtMultimedia import QMediaPlayer, QAudioOutput, QMediaDevices
|
||||
from PySide6.QtMultimediaWidgets import QGraphicsVideoItem
|
||||
from PySide6.QtWidgets import QGraphicsView, QGraphicsScene
|
||||
from PySide6.QtGui import (
|
||||
QInputMethodEvent,
|
||||
QPen,
|
||||
QColor,
|
||||
QBrush,
|
||||
@@ -29,10 +31,7 @@ from PySide6.QtGui import (
|
||||
QBitmap,
|
||||
)
|
||||
from PySide6.QtSvgWidgets import QSvgWidget
|
||||
from PIL import Image
|
||||
from src.qt.helpers.file_opener import FileOpenerHelper
|
||||
|
||||
from src.core.constants import VIDEO_TYPES, AUDIO_TYPES
|
||||
from PIL import Image, ImageDraw
|
||||
from src.core.enums import SettingItems
|
||||
|
||||
@@ -41,26 +40,26 @@ if typing.TYPE_CHECKING:
|
||||
|
||||
|
||||
class VideoPlayer(QGraphicsView):
|
||||
"""A simple video player for the TagStudio application."""
|
||||
"""A basic video player."""
|
||||
|
||||
resolution = QSize(1280, 720)
|
||||
hover_fix_timer = QTimer()
|
||||
video_preview = None
|
||||
play_pause = None
|
||||
mute_button = None
|
||||
content_visible = False
|
||||
filepath = None
|
||||
|
||||
def __init__(self, driver: "QtDriver") -> None:
|
||||
# Set up the base class.
|
||||
super().__init__()
|
||||
self.driver = driver
|
||||
self.resolution = QSize(1280, 720)
|
||||
self.animation = QVariantAnimation(self)
|
||||
self.animation.valueChanged.connect(
|
||||
lambda value: self.setTintTransparency(value)
|
||||
)
|
||||
self.hover_fix_timer = QTimer()
|
||||
self.hover_fix_timer.timeout.connect(lambda: self.checkIfStillHovered())
|
||||
self.hover_fix_timer.setSingleShot(True)
|
||||
self.content_visible = False
|
||||
self.filepath = None
|
||||
|
||||
# Set up the video player.
|
||||
self.installEventFilter(self)
|
||||
self.setScene(QGraphicsScene(self))
|
||||
@@ -82,6 +81,9 @@ class VideoPlayer(QGraphicsView):
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
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,
|
||||
@@ -91,44 +93,31 @@ class VideoPlayer(QGraphicsView):
|
||||
QPen(QColor(0, 0, 0, 0)),
|
||||
QBrush(QColor(0, 0, 0, 0)),
|
||||
)
|
||||
# self.video_tint.setParentItem(self.video_preview)
|
||||
# self.album_art = QGraphicsPixmapItem(self.video_preview)
|
||||
# self.scene().addItem(self.album_art)
|
||||
# self.album_art.setPixmap(
|
||||
# QPixmap("./tagstudio/resources/qt/images/thumb_file_default_512.png")
|
||||
# )
|
||||
# self.album_art.setOpacity(0.0)
|
||||
|
||||
# Set up the buttons.
|
||||
self.play_pause = QSvgWidget("./tagstudio/resources/pause.svg")
|
||||
self.play_pause = QSvgWidget()
|
||||
self.play_pause.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
|
||||
self.play_pause.setMouseTracking(True)
|
||||
self.play_pause.installEventFilter(self)
|
||||
self.scene().addWidget(self.play_pause)
|
||||
self.play_pause.resize(100, 100)
|
||||
self.play_pause.resize(72, 72)
|
||||
self.play_pause.move(
|
||||
int(self.width() / 2 - self.play_pause.size().width() / 2),
|
||||
int(self.height() / 2 - self.play_pause.size().height() / 2),
|
||||
)
|
||||
self.play_pause.hide()
|
||||
|
||||
self.mute_button = QSvgWidget("./tagstudio/resources/volume_muted.svg")
|
||||
self.mute_button = QSvgWidget()
|
||||
self.mute_button.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
|
||||
self.mute_button.setMouseTracking(True)
|
||||
self.mute_button.installEventFilter(self)
|
||||
self.scene().addWidget(self.mute_button)
|
||||
self.mute_button.resize(40, 40)
|
||||
self.mute_button.resize(32, 32)
|
||||
self.mute_button.move(
|
||||
int(self.width() - self.mute_button.size().width() / 2),
|
||||
int(self.height() - self.mute_button.size().height() / 2),
|
||||
)
|
||||
self.mute_button.hide()
|
||||
# self.fullscreen_button = QSvgWidget('./tagstudio/resources/pause.svg', self)
|
||||
# self.fullscreen_button.setMouseTracking(True)
|
||||
# self.fullscreen_button.installEventFilter(self)
|
||||
# self.scene().addWidget(self.fullscreen_button)
|
||||
# self.fullscreen_button.resize(40, 40)
|
||||
# self.fullscreen_button.move(self.fullscreen_button.size().width()/2, self.height() - self.fullscreen_button.size().height()/2)
|
||||
# self.fullscreen_button.hide()
|
||||
|
||||
self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
|
||||
self.opener = FileOpenerHelper(filepath=self.filepath)
|
||||
@@ -143,7 +132,16 @@ class VideoPlayer(QGraphicsView):
|
||||
|
||||
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)
|
||||
|
||||
system = platform.system()
|
||||
open_explorer_action = QAction(
|
||||
"Open in explorer", self
|
||||
) # Default (mainly going to be for linux)
|
||||
if system == "Darwin":
|
||||
open_explorer_action = QAction("Reveal in Finder", self)
|
||||
elif system == "Windows":
|
||||
open_explorer_action = QAction("Open in Explorer", self)
|
||||
|
||||
open_explorer_action.triggered.connect(self.opener.open_explorer)
|
||||
self.addAction(open_file_action)
|
||||
self.addAction(open_explorer_action)
|
||||
@@ -157,22 +155,17 @@ class VideoPlayer(QGraphicsView):
|
||||
self.driver.settings.sync()
|
||||
|
||||
def checkMediaStatus(self, media_status: QMediaPlayer.MediaStatus) -> None:
|
||||
# logging.info(media_status)
|
||||
if media_status == QMediaPlayer.MediaStatus.EndOfMedia:
|
||||
# Switches current video to with video at filepath. Reason for this is because Pyside6 is dumb and can't handle setting a new source and freezes.
|
||||
# Switches current video to with video at filepath.
|
||||
# Reason for this is because Pyside6 can't handle setting a new source and freezes.
|
||||
# Even if I stop the player before switching, it breaks.
|
||||
# On the plus side, this adds infinite looping for the video preview.
|
||||
self.player.stop()
|
||||
self.player.setSource(QUrl().fromLocalFile(self.filepath))
|
||||
# logging.info(f'Set source to {self.filepath}.')
|
||||
# self.video_preview.setSize(self.resolution)
|
||||
self.player.setPosition(0)
|
||||
# logging.info(f'Set muted to true.')
|
||||
if self.autoplay.isChecked():
|
||||
# logging.info(self.driver.settings.value("autoplay_videos", True, bool))
|
||||
self.player.play()
|
||||
else:
|
||||
# logging.info("Paused")
|
||||
self.player.pause()
|
||||
self.opener.set_filepath(self.filepath)
|
||||
self.keepControlsInPlace()
|
||||
@@ -180,14 +173,14 @@ class VideoPlayer(QGraphicsView):
|
||||
|
||||
def updateControls(self) -> None:
|
||||
if self.player.audioOutput().isMuted():
|
||||
self.mute_button.load("./tagstudio/resources/volume_muted.svg")
|
||||
self.mute_button.load(self.driver.rm.volume_mute_icon)
|
||||
else:
|
||||
self.mute_button.load("./tagstudio/resources/volume_unmuted.svg")
|
||||
self.mute_button.load(self.driver.rm.volume_icon)
|
||||
|
||||
if self.player.isPlaying():
|
||||
self.play_pause.load("./tagstudio/resources/pause.svg")
|
||||
self.play_pause.load(self.driver.rm.pause_icon)
|
||||
else:
|
||||
self.play_pause.load("./tagstudio/resources/play.svg")
|
||||
self.play_pause.load(self.driver.rm.play_icon)
|
||||
|
||||
def wheelEvent(self, event: QWheelEvent) -> None:
|
||||
return
|
||||
@@ -229,8 +222,10 @@ class VideoPlayer(QGraphicsView):
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
def checkIfStillHovered(self) -> None:
|
||||
# Yet again, Pyside6 is dumb. I don't know why, but the HoverLeave event is not triggered sometimes and does not hide the controls.
|
||||
# So, this is a workaround. This is called by a QTimer every 10ms to check if the mouse is still in the video preview.
|
||||
# I don't know why, but the HoverLeave event is not triggered sometimes
|
||||
# and does not hide the controls.
|
||||
# So, this is a workaround. This is called by a QTimer every 10ms to check if the mouse
|
||||
# is still in the video preview.
|
||||
if not self.video_preview.isUnderMouse():
|
||||
self.releaseMouse()
|
||||
else:
|
||||
@@ -240,55 +235,51 @@ class VideoPlayer(QGraphicsView):
|
||||
self.video_tint.setBrush(QBrush(QColor(0, 0, 0, value)))
|
||||
|
||||
def underMouse(self) -> bool:
|
||||
# logging.info("under mouse")
|
||||
self.animation.setStartValue(self.video_tint.brush().color().alpha())
|
||||
self.animation.setEndValue(100)
|
||||
self.animation.setDuration(500)
|
||||
self.animation.setDuration(250)
|
||||
self.animation.start()
|
||||
self.play_pause.show()
|
||||
self.mute_button.show()
|
||||
# self.fullscreen_button.show()
|
||||
self.keepControlsInPlace()
|
||||
self.updateControls()
|
||||
# rcontent = self.contentsRect()
|
||||
# self.setSceneRect(0, 0, rcontent.width(), rcontent.height())
|
||||
|
||||
return super().underMouse()
|
||||
|
||||
def releaseMouse(self) -> None:
|
||||
# logging.info("release mouse")
|
||||
self.animation.setStartValue(self.video_tint.brush().color().alpha())
|
||||
self.animation.setEndValue(0)
|
||||
self.animation.setDuration(500)
|
||||
self.animation.start()
|
||||
self.play_pause.hide()
|
||||
self.mute_button.hide()
|
||||
# self.fullscreen_button.hide()
|
||||
|
||||
return super().releaseMouse()
|
||||
|
||||
def resetControlsToDefault(self) -> None:
|
||||
# Resets the video controls to their default state.
|
||||
self.play_pause.load("./tagstudio/resources/pause.svg")
|
||||
self.mute_button.load("./tagstudio/resources/volume_muted.svg")
|
||||
self.play_pause.load(self.driver.rm.pause_icon)
|
||||
self.mute_button.load(self.driver.rm.volume_mute_icon)
|
||||
|
||||
def pauseToggle(self) -> None:
|
||||
if self.player.isPlaying():
|
||||
self.player.pause()
|
||||
self.play_pause.load("./tagstudio/resources/play.svg")
|
||||
self.play_pause.load(self.driver.rm.play_icon)
|
||||
else:
|
||||
self.player.play()
|
||||
self.play_pause.load("./tagstudio/resources/pause.svg")
|
||||
self.play_pause.load(self.driver.rm.pause_icon)
|
||||
|
||||
def muteToggle(self) -> None:
|
||||
if self.player.audioOutput().isMuted():
|
||||
self.player.audioOutput().setMuted(False)
|
||||
self.mute_button.load("./tagstudio/resources/volume_unmuted.svg")
|
||||
self.mute_button.load(self.driver.rm.volume_icon)
|
||||
else:
|
||||
self.player.audioOutput().setMuted(True)
|
||||
self.mute_button.load("./tagstudio/resources/volume_muted.svg")
|
||||
self.mute_button.load(self.driver.rm.volume_mute_icon)
|
||||
|
||||
def play(self, filepath: str, resolution: QSize) -> None:
|
||||
# Sets the filepath and sends the current player position to the very end, so that the new video can be played.
|
||||
# self.player.audioOutput().setMuted(True)
|
||||
# Sets the filepath and sends the current player position to the very end,
|
||||
# so that the new video can be played.
|
||||
logging.info(f"Playing {filepath}")
|
||||
self.resolution = resolution
|
||||
self.filepath = filepath
|
||||
@@ -297,7 +288,6 @@ class VideoPlayer(QGraphicsView):
|
||||
self.player.play()
|
||||
else:
|
||||
self.checkMediaStatus(QMediaPlayer.MediaStatus.EndOfMedia)
|
||||
# logging.info(f"Successfully stopped.")
|
||||
|
||||
def stop(self) -> None:
|
||||
self.filepath = None
|
||||
@@ -310,10 +300,10 @@ class VideoPlayer(QGraphicsView):
|
||||
0, 0, self.video_preview.size().width(), self.video_preview.size().height()
|
||||
)
|
||||
|
||||
rcontent = self.contentsRect()
|
||||
contents = self.contentsRect()
|
||||
self.centerOn(self.video_preview)
|
||||
self.roundCorners()
|
||||
self.setSceneRect(0, 0, rcontent.width(), rcontent.height())
|
||||
self.setSceneRect(0, 0, contents.width(), contents.height())
|
||||
self.keepControlsInPlace()
|
||||
|
||||
def roundCorners(self) -> None:
|
||||
@@ -346,7 +336,6 @@ class VideoPlayer(QGraphicsView):
|
||||
int(self.width() - self.mute_button.size().width() - 10),
|
||||
int(self.height() - self.mute_button.size().height() - 10),
|
||||
)
|
||||
# self.fullscreen_button.move(-self.fullscreen_button.size().width()-10, self.height() - self.fullscreen_button.size().height()-10)
|
||||
|
||||
def resizeEvent(self, event: QResizeEvent) -> None:
|
||||
# Keeps the video preview in the center of the screen.
|
||||
@@ -358,7 +347,6 @@ class VideoPlayer(QGraphicsView):
|
||||
)
|
||||
)
|
||||
return
|
||||
# return super().resizeEvent(event)\
|
||||
|
||||
|
||||
class VideoPreview(QGraphicsVideoItem):
|
||||
@@ -367,7 +355,8 @@ class VideoPreview(QGraphicsVideoItem):
|
||||
|
||||
def paint(self, painter, option, widget):
|
||||
# painter.brush().setColor(QColor(0, 0, 0, 255))
|
||||
# You can set any shape you want here. RoundedRect is the standard rectangle with rounded corners
|
||||
# You can set any shape you want here.
|
||||
# RoundedRect is the standard rectangle with rounded corners.
|
||||
# With 2nd and 3rd parameter you can tweak the curve until you get what you expect
|
||||
|
||||
super().paint(painter, option, widget)
|
||||
|
||||
@@ -17,14 +17,9 @@ def main():
|
||||
|
||||
# Parse arguments.
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--open",
|
||||
dest="open",
|
||||
type=str,
|
||||
help="Path to a TagStudio Library folder to open on start.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--open",
|
||||
dest="open",
|
||||
type=str,
|
||||
help="Path to a TagStudio Library folder to open on start.",
|
||||
|
||||
42
tagstudio/tests/conftest.py
Normal file
@@ -0,0 +1,42 @@
|
||||
import sys
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
from syrupy.extensions.json import JSONSnapshotExtension
|
||||
|
||||
CWD = pathlib.Path(__file__).parent
|
||||
|
||||
sys.path.insert(0, str(CWD.parent))
|
||||
|
||||
from src.core.library import Tag, Library
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_tag():
|
||||
yield Tag(
|
||||
id=1,
|
||||
name="Tag Name",
|
||||
shorthand="TN",
|
||||
aliases=["First A", "Second A"],
|
||||
subtags_ids=[2, 3, 4],
|
||||
color="",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_library():
|
||||
lib_dir = CWD / "fixtures" / "library"
|
||||
|
||||
lib = Library()
|
||||
ret_code = lib.open_library(lib_dir)
|
||||
assert ret_code == 1
|
||||
# create files for the entries
|
||||
for entry in lib.entries:
|
||||
(lib_dir / entry.filename).touch()
|
||||
|
||||
yield lib
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def snapshot_json(snapshot):
|
||||
return snapshot.with_defaults(extension_class=JSONSnapshotExtension)
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1,6 @@
|
||||
[
|
||||
[
|
||||
"<ItemType.ENTRY: 0>",
|
||||
2
|
||||
]
|
||||
]
|
||||
@@ -0,0 +1,6 @@
|
||||
[
|
||||
[
|
||||
"<ItemType.ENTRY: 0>",
|
||||
1
|
||||
]
|
||||
]
|
||||
@@ -0,0 +1,4 @@
|
||||
[
|
||||
"{'id': 1, 'filename': 'foo.txt', 'path': '.', 'fields': [{6: [1001]}]}",
|
||||
"{'id': 2, 'filename': 'bar.txt', 'path': '.', 'fields': [{6: [1000]}]}"
|
||||
]
|
||||
18
tagstudio/tests/core/test_lib.py
Normal file
@@ -0,0 +1,18 @@
|
||||
import pytest
|
||||
|
||||
|
||||
def test_open_library(test_library, snapshot_json):
|
||||
assert test_library.entries == snapshot_json
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
["query"],
|
||||
[
|
||||
("First",),
|
||||
("Second",),
|
||||
("--nomatch--",),
|
||||
],
|
||||
)
|
||||
def test_library_search(test_library, query, snapshot_json):
|
||||
res = test_library.search_library(query)
|
||||
assert res == snapshot_json
|
||||
@@ -1,18 +1,8 @@
|
||||
from src.core.library import Tag
|
||||
def test_subtag(test_tag):
|
||||
test_tag.remove_subtag(2)
|
||||
test_tag.remove_subtag(2)
|
||||
|
||||
|
||||
def test_construction():
|
||||
tag = Tag(
|
||||
id=1,
|
||||
name="Tag Name",
|
||||
shorthand="TN",
|
||||
aliases=["First A", "Second A"],
|
||||
subtags_ids=[2, 3, 4],
|
||||
color="",
|
||||
)
|
||||
assert tag
|
||||
|
||||
|
||||
def test_empty_construction():
|
||||
tag = Tag(id=1, name="", shorthand="", aliases=[], subtags_ids=[], color="")
|
||||
assert tag
|
||||
test_tag.add_subtag(5)
|
||||
# repeated add should not add the subtag
|
||||
test_tag.add_subtag(5)
|
||||
assert test_tag.subtag_ids == [3, 4, 5]
|
||||
|
||||
69
tagstudio/tests/fixtures/library/.TagStudio/ts_library.json
vendored
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"ts-version": "9.3.1",
|
||||
"ext_list": [
|
||||
".json",
|
||||
".xmp",
|
||||
".aae"
|
||||
],
|
||||
"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": "first",
|
||||
"shorthand": "first",
|
||||
"color": "magenta"
|
||||
},
|
||||
{
|
||||
"id": 1001,
|
||||
"name": "second",
|
||||
"shorthand": "second",
|
||||
"color": "blue"
|
||||
}
|
||||
],
|
||||
"collations": [],
|
||||
"fields": [],
|
||||
"macros": [],
|
||||
"entries": [
|
||||
{
|
||||
"id": 1,
|
||||
"filename": "foo.txt",
|
||||
"path": ".",
|
||||
"fields": [
|
||||
{
|
||||
"6": [
|
||||
1001
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"filename": "bar.txt",
|
||||
"path": ".",
|
||||
"fields": [
|
||||
{
|
||||
"6": [
|
||||
1000
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||