mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2026-01-29 14:20:48 +00:00
Compare commits
39 Commits
extend-455
...
v9.5.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c5b8e51e6 | ||
|
|
efb4c4a6ed | ||
|
|
f88200f38e | ||
|
|
7f7d861800 | ||
|
|
cccd858078 | ||
|
|
46996d50e5 | ||
|
|
1939f118f1 | ||
|
|
3b5a9605d1 | ||
|
|
7dd1905b6e | ||
|
|
9c24272caf | ||
|
|
d7d7e21d13 | ||
|
|
33e6bc180d | ||
|
|
13afb0f664 | ||
|
|
27fb54ed65 | ||
|
|
e3b2eaf96a | ||
|
|
33dd330c73 | ||
|
|
1c02e75dd9 | ||
|
|
6400236823 | ||
|
|
a4e61f9387 | ||
|
|
adb996e1d2 | ||
|
|
e112788466 | ||
|
|
64dc88afa9 | ||
|
|
477173661a | ||
|
|
9d60c78adc | ||
|
|
5b5e878a69 | ||
|
|
d1eb7d646e | ||
|
|
880ca07a6f | ||
|
|
0701a45e75 | ||
|
|
35f409089a | ||
|
|
010a4524d6 | ||
|
|
861df898e2 | ||
|
|
0ac06a125a | ||
|
|
b8ee63ef73 | ||
|
|
a5e535ba78 | ||
|
|
ed6ac246f4 | ||
|
|
31833245a4 | ||
|
|
93dcfdd51c | ||
|
|
f680ecb648 | ||
|
|
234ddec78b |
@@ -1,18 +0,0 @@
|
||||
# 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.6; then
|
||||
source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.0.6/direnvrc" "sha256-RYcUJaRMf8oF5LznDrlCXbkOQrywm0HDv1VjYGaJGdM="
|
||||
fi
|
||||
|
||||
if [ -f .venv/bin/activate ]; then
|
||||
# If the file is watched when it does not exist,
|
||||
# direnv will execute again when it gets created.
|
||||
watch_file .venv/bin/activate
|
||||
fi
|
||||
watch_file nix/shell.nix
|
||||
watch_file pyproject.toml
|
||||
|
||||
use flake
|
||||
|
||||
# vi: ft=bash
|
||||
@@ -1,7 +1,26 @@
|
||||
---
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.6.4
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: ruff-format
|
||||
- id: mypy
|
||||
name: mypy
|
||||
entry: mypy
|
||||
language: system
|
||||
types_or: [python, pyi]
|
||||
require_serial: true
|
||||
|
||||
- id: ruff
|
||||
name: ruff
|
||||
entry: ruff check
|
||||
language: system
|
||||
types_or: [python, pyi, jupyter]
|
||||
args: [--force-exclude]
|
||||
require_serial: true
|
||||
|
||||
- id: ruff-format
|
||||
name: ruff-format
|
||||
entry: ruff format
|
||||
language: system
|
||||
types_or: [python, pyi, jupyter]
|
||||
args: [--force-exclude, --check]
|
||||
require_serial: true
|
||||
|
||||
@@ -103,7 +103,7 @@ Most of the style guidelines can be checked, fixed, and enforced via Ruff. Older
|
||||
- If you're modifying an existing function that does _not_ have docstrings, you don't _have_ to add docstrings to it... but it would be pretty cool if you did ;)
|
||||
- Imports should be ordered alphabetically.
|
||||
- Lists of values should be ordered using their [natural sort order](https://en.wikipedia.org/wiki/Natural_sort_order).
|
||||
- Some files have their methods ordered alphabetically as well (i.e. [`thumb_renderer`](https://github.com/TagStudioDev/TagStudio/blob/main/tagstudio/src/qt/widgets/thumb_renderer.py)). If you're working in a file and notice this, please try and keep to the pattern.
|
||||
- Some files have their methods ordered alphabetically as well (i.e. [`thumb_renderer`](https://github.com/TagStudioDev/TagStudio/blob/main/src/tagstudio/qt/widgets/thumb_renderer.py)). If you're working in a file and notice this, please try and keep to the pattern.
|
||||
- When writing text for window titles or form titles, use "[Title Case](https://apastyle.apa.org/style-grammar-guidelines/capitalization/title-case)" capitalization. Your IDE may have a command to format this for you automatically, although some may incorrectly capitalize short prepositions. In a pinch you can use a website such as [capitalizemytitle.com](https://capitalizemytitle.com/) to check.
|
||||
- If it wasn't mentioned above, then stick to [**PEP-8**](https://peps.python.org/pep-0008/)!
|
||||
|
||||
|
||||
19
contrib/.envrc-nix
Normal file
19
contrib/.envrc-nix
Normal file
@@ -0,0 +1,19 @@
|
||||
# shellcheck shell=bash
|
||||
|
||||
# If you wish to use this file, symlink or copy it to `.envrc` for direnv to read it.
|
||||
# This will use the Nix flake development shell.
|
||||
#
|
||||
# ln -s contrib/.envrc-nix .envrc
|
||||
|
||||
if ! has nix_direnv_version || ! nix_direnv_version 3.0.6; then
|
||||
source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.0.6/direnvrc" "sha256-RYcUJaRMf8oF5LznDrlCXbkOQrywm0HDv1VjYGaJGdM="
|
||||
fi
|
||||
|
||||
watch_file nix/shell.nix pyproject.toml
|
||||
|
||||
use flake
|
||||
|
||||
# Only watch now, or direnv will execute again if created or modified by itself.
|
||||
watch_file "${UV_PROJECT_ENVIRONMENT:-.venv}"/bin/activate
|
||||
|
||||
# vi: ft=bash
|
||||
32
contrib/.envrc-uv
Normal file
32
contrib/.envrc-uv
Normal file
@@ -0,0 +1,32 @@
|
||||
# shellcheck shell=bash
|
||||
|
||||
# If you wish to use this file, symlink or copy it to `.envrc` for direnv to read it.
|
||||
# This will use a virtual environment created by uv.
|
||||
#
|
||||
# ln -s contrib/.envrc-uv .envrc
|
||||
|
||||
watch_file .python-version pyproject.toml uv.lock
|
||||
|
||||
venv="$(expand_path "${UV_PROJECT_ENVIRONMENT:-.venv}")"
|
||||
|
||||
if [ ! -f "${venv}"/bin/activate ]; then
|
||||
printf '%s\n' 'Generating virtual environment...' >&2
|
||||
rm -rf "${venv}"
|
||||
uv venv "${venv}"
|
||||
fi
|
||||
|
||||
# Only watch now, or direnv will execute again if created or modified by itself.
|
||||
watch_file "${venv}"/bin/activate
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source "${venv}"/bin/activate
|
||||
|
||||
if [ ! -f "${venv}"/pyproject.toml ] || ! diff --brief pyproject.toml "${venv}"/pyproject.toml >/dev/null; then
|
||||
printf '%s\n' 'Installing dependencies, pyproject.toml changed...' >&2
|
||||
uv pip install --quiet --editable '.[dev]'
|
||||
cp pyproject.toml "${venv}"/pyproject.toml
|
||||
fi
|
||||
|
||||
pre-commit install
|
||||
|
||||
# vi: ft=bash
|
||||
17
contrib/.vscode/launch.json
vendored
Normal file
17
contrib/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "TagStudio",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"program": "${workspaceRoot}/src/tagstudio/main.py",
|
||||
"console": "integratedTerminal",
|
||||
"justMyCode": true,
|
||||
"args": [
|
||||
"-o",
|
||||
"~/Documents/Example"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
180
docs/develop.md
Normal file
180
docs/develop.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# Developing
|
||||
|
||||
If you wish to develop for TagStudio, you'll need to create a development environment by installing the required dependencies. You have a number of options depending on your level of experience and familiarity with existing Python toolchains.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! tip "Contributing"
|
||||
If you wish to contribute to TagStudio's development, please read our [CONTRIBUTING.md](https://github.com/TagStudioDev/TagStudio/blob/main/CONTRIBUTING.md)!
|
||||
|
||||
## Install Python
|
||||
|
||||
Python [3.12](https://www.python.org/downloads) is required to develop for TagStudio. Any version matching "Python 3.12.x" should work, with "x" being any number. Alternatively you can use a tool such as [pyenv](https://github.com/pyenv/pyenv) to install this version of Python without affecting any existing Python installations on your system. Tools such as [uv](#installing-with-uv) can also install Python versions.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! info "Python Aliases"
|
||||
Depending on your system, Python may be called `python`, `py`, `python3`, or `py3`. These instructions use the alias `python` for consistency.
|
||||
|
||||
If you already have Python installed on your system, you can check the version by running the following command:
|
||||
|
||||
```sh
|
||||
python --version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Installing with pyenv
|
||||
|
||||
If you choose to install Python using pyenv, please refer to the following instructions:
|
||||
|
||||
1. Follow pyenv's [install instructions](https://github.com/pyenv/pyenv/?tab=readme-ov-file#installation) for your system.
|
||||
2. Install the appropriate Python version with pyenv by running `pyenv install 3.12` (This will **not** mess with your existing Python installation).
|
||||
3. Navigate to the repository root folder in your terminal and run `pyenv local 3.12`. You could alternatively use `pyenv shell 3.12` or `pyenv global 3.12` instead to set the Python version for the current terminal session or the entire system respectively, however using `local` is recommended.
|
||||
|
||||
---
|
||||
|
||||
### Installing Dependencies
|
||||
|
||||
To install the required dependencies, you can use a dependency manager such as [uv](https://docs.astral.sh/uv) or [Poetry 2.0](https://python-poetry.org). Alternatively you can create a virtual environment and manually install the dependencies yourself.
|
||||
|
||||
#### Installing with uv
|
||||
|
||||
If using [uv](https://docs.astral.sh/uv), you can install the dependencies for TagStudio with the following command:
|
||||
|
||||
```sh
|
||||
uv pip install -e .[dev]
|
||||
```
|
||||
|
||||
A reference `.envrc` is provided for use with [direnv](#direnv), see [`contrib/.envrc-uv`](https://github.com/TagStudioDev/TagStudio/blob/main/contrib/.envrc-uv).
|
||||
|
||||
---
|
||||
|
||||
#### Installing with Poetry
|
||||
|
||||
If using [Poetry](https://python-poetry.org), you can install the dependencies for TagStudio with the following command:
|
||||
|
||||
```sh
|
||||
poetry install --with dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Manual Installation
|
||||
|
||||
If you choose to manually set up a virtual environment and install dependencies instead of using a dependency manager, please refer to the following instructions:
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! tip "Virtual Environments"
|
||||
Learn more about setting up a virtual environment with Python's [official tutorial](https://docs.python.org/3/tutorial/venv.html).
|
||||
|
||||
1. In the root repository directory, create a python virtual environment:
|
||||
|
||||
```sh
|
||||
python -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`
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! info "Supported Shells"
|
||||
Depending on your system, the regular activation script _might_ not work on alternative shells. In this case, refer to the table below for supported shells:
|
||||
|
||||
| Shell | Script |
|
||||
| ---------: | :------------------------ |
|
||||
| Bash/ZSH | `.venv/bin/activate` |
|
||||
| Fish | `.venv/bin/activate.fish` |
|
||||
| CSH/TCSH | `.venv/bin/activate.csh` |
|
||||
| PowerShell | `.venv/bin/activate.ps1` |
|
||||
|
||||
3. Use the following PIP command to create an editable installation and install the required development dependencies:
|
||||
|
||||
```sh
|
||||
pip install -e .[dev]
|
||||
```
|
||||
|
||||
## Nix(OS)
|
||||
|
||||
If using [Nix](https://nixos.org/), there is a development environment already provided in the [flake](https://wiki.nixos.org/wiki/Flakes) that is accessible with the following command:
|
||||
|
||||
```sh
|
||||
nix develop
|
||||
```
|
||||
|
||||
A reference `.envrc` is provided for use with [direnv](#direnv), see [`contrib/.envrc-nix`](https://github.com/TagStudioDev/TagStudio/blob/main/contrib/.envrc-nix).
|
||||
|
||||
## Tooling
|
||||
|
||||
### Editor Integration
|
||||
|
||||
The entry point for TagStudio is `src/tagstudio/main.py`. You can target this file from your IDE to run or connect a debug session. The example(s) below show off example launch scripts for different IDEs. Here you can also take advantage of [launch arguments](./usage.md/#launch-arguments) to pass your own test [libraries](./library/index.md) to use while developing. You can find more editor configurations in [`contrib`](https://github.com/TagStudioDev/TagStudio/tree/main/contrib).
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
=== "VS Code"
|
||||
```json title=".vscode/launch.json"
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "TagStudio",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"program": "${workspaceRoot}/src/tagstudio/main.py",
|
||||
"console": "integratedTerminal",
|
||||
"justMyCode": true,
|
||||
"args": ["-o", "~/Documents/Example"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### pre-commit
|
||||
|
||||
There is a [pre-commit](https://pre-commit.com/) configuration that will run through some checks before code is committed. Namely, mypy and the Ruff linter and formatter will check your code, catching those nits right away.
|
||||
|
||||
Once you have pre-commit installed, just run:
|
||||
|
||||
```sh
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
From there, Git will automatically run through the hooks during commit actions!
|
||||
|
||||
### direnv
|
||||
|
||||
You can automatically enter this development shell, and keep your user shell, with a tool like [direnv](https://direnv.net/). Some reference `.envrc` files are provided in the repository at [`contrib`](https://github.com/TagStudioDev/TagStudio/tree/main/contrib).
|
||||
|
||||
Two currently available are for [Nix](#nixos) and [uv](#installing-with-uv), to use one:
|
||||
|
||||
```sh
|
||||
ln -s .envrc-$variant .envrc
|
||||
```
|
||||
|
||||
You will have to allow usage of it.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! warning "direnv Security Framework"
|
||||
These files are generally a good idea to check, as they execute commands on directory load. direnv has a security framework to only run `.envrc` files you have allowed, and does keep track on if it has changed. So, with that being said, the file may need to be allowed again if modifications are made.
|
||||
|
||||
```sh
|
||||
cat .envrc # You are checking them, right?
|
||||
direnv allow
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To build your own executables of TagStudio, first follow the steps in "[Installing Dependencies](#installing-dependencies)." Once that's complete, run the following PyInstaller command:
|
||||
|
||||
```
|
||||
pyinstaller tagstudio.spec
|
||||
```
|
||||
|
||||
If you're on Windows or Linux and wish to build a portable executable, then pass the following flag:
|
||||
|
||||
```
|
||||
pyinstaller tagstudio.spec -- --portable
|
||||
```
|
||||
|
||||
The resulting executable file(s) will be located in a new folder named "dist".
|
||||
191
docs/install.md
191
docs/install.md
@@ -1,20 +1,24 @@
|
||||
# Installation
|
||||
|
||||
## Releases
|
||||
TagStudio provides [releases](https://github.com/TagStudioDev/TagStudio/releases) as well as full access to its [source code](https://github.com/TagStudioDev/TagStudio) under the [GPLv3](https://github.com/TagStudioDev/TagStudio/blob/main/LICENSE) license.
|
||||
|
||||
TagStudio provides executable [releases](https://github.com/TagStudioDev/TagStudio/releases) as well as full access to its [source code](https://github.com/TagStudioDev/TagStudio) under the [GPLv3](https://github.com/TagStudioDev/TagStudio/blob/main/LICENSE) license.
|
||||
## Executables
|
||||
|
||||
To download executable builds of TagStudio, visit the [Releases](https://github.com/TagStudioDev/TagStudio/releases) page of the GitHub repository and download the latest release for your system under the "Assets" section at the bottom of the release.
|
||||
|
||||
TagStudio has builds for **Windows**, **macOS** _(Apple Silicon & Intel)_, and **Linux**. We also offer portable releases for Windows and Linux which are self-contained and easier to move around.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! info "For macOS Users"
|
||||
!!! info "Third-Party Dependencies"
|
||||
You may need to install [third-party dependencies](#third-party-dependencies) such as [FFmpeg](https://ffmpeg.org/download.html) to use the full feature set of TagStudio.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! warning "For macOS Users"
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
### Package Managers
|
||||
## Package Managers
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! danger "Unofficial Releases"
|
||||
@@ -44,7 +48,7 @@ pip install .
|
||||
```sh
|
||||
pip install -e .[dev]
|
||||
```
|
||||
_See more under "[Creating a Development Environment](#creating-a-development-environment)"_
|
||||
_See more under "[Developing](./develop.md)"_
|
||||
|
||||
TagStudio can now be launched via the `tagstudio` command in your terminal.
|
||||
|
||||
@@ -201,183 +205,8 @@ Finally, `inputs` can be used in a module to add the package to your packages li
|
||||
|
||||
Don't forget to rebuild!
|
||||
|
||||
---
|
||||
|
||||
## Creating a Development Environment
|
||||
|
||||
If you wish to develop for TagStudio, you'll need to create a development environment by installing the required dependencies. You have a number of options depending on your level of experience and familiarly with existing Python toolchains.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! tip "Contributing"
|
||||
If you wish to contribute to TagStudio's development, please read our [CONTRIBUTING.md](https://github.com/TagStudioDev/TagStudio/blob/main/CONTRIBUTING.md)!
|
||||
|
||||
### Install Python
|
||||
|
||||
Python [3.12](https://www.python.org/downloads/) is required to develop for TagStudio. Any version matching "Python 3.12.x" should work, with "x" being any number. Alternatively you can use a tool such as [pyenv](https://github.com/pyenv/pyenv/) to install this version of Python without affecting any existing Python installations on your system.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! info "Python Aliases"
|
||||
Depending on your system, Python may be called `python`, `py`, `python3`, or `py3`. These instructions use the alias `python` for consistency.
|
||||
|
||||
If you already have Python installed on your system, you can check the version by running the following command:
|
||||
|
||||
```sh
|
||||
python --version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Installing with pyenv
|
||||
|
||||
If you choose to install Python using pyenv, please refer to the following instructions:
|
||||
|
||||
1. Follow pyenv's [install instructions](https://github.com/pyenv/pyenv/?tab=readme-ov-file#installation) for your system.
|
||||
2. Install the appropriate Python version with pyenv by running `pyenv install 3.12` (This will **not** mess with your existing Python installation).
|
||||
3. Navigate to the repository root folder in your terminal and run `pyenv local 3.12`. You could alternatively use `pyenv shell 3.12` or `pyenv global 3.12` instead to set the Python version for the current terminal session or the entire system respectively, however using `local` is recommended.
|
||||
|
||||
---
|
||||
|
||||
### Installing Dependencies
|
||||
|
||||
To install the required dependencies, you can use a dependency manager such as [uv](https://docs.astral.sh/uv) or [Poetry 2.0](https://python-poetry.org). Alternatively you can create a virtual environment and manually install the dependencies yourself.
|
||||
|
||||
#### Installing with uv
|
||||
|
||||
If using [uv](https://docs.astral.sh/uv), you can install the dependencies for TagStudio with the following command:
|
||||
|
||||
```sh
|
||||
uv pip install -e .[dev]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Installing with Poetry
|
||||
|
||||
If using [Poetry](https://python-poetry.org), you can install the dependencies for TagStudio with the following command:
|
||||
|
||||
```sh
|
||||
poetry install --with dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Installing with Nix
|
||||
|
||||
If using [Nix](https://nixos.org/), there is a development environment already provided in the [flake](https://wiki.nixos.org/wiki/Flakes) that is accessible with the following command:
|
||||
|
||||
```sh
|
||||
nix develop
|
||||
```
|
||||
|
||||
You can automatically enter this development shell, and keep your user shell, with a tool like [direnv](https://direnv.net/). A reference `.envrc` is provided in the repository; to use it:
|
||||
|
||||
```sh
|
||||
ln -s .envrc.recommended .envrc
|
||||
```
|
||||
|
||||
You will have to allow usage of it.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! warning "`.envrc` Security"
|
||||
These files are generally a good idea to check, as they execute commands on directory load. direnv has a security framework to only run `.envrc` files you have allowed, and does keep track on if it has changed. So, with that being said, the file may need to be allowed again if modifications are made.
|
||||
|
||||
```sh
|
||||
cat .envrc # You are checking them, right?
|
||||
direnv allow
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Manual Installation
|
||||
|
||||
If you choose to manually set up a virtual environment and install dependencies instead of using a dependency manager, please refer to the following instructions:
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! tip "Virtual Environments"
|
||||
Learn more about setting up a virtual environment with Python's [official tutorial](https://docs.python.org/3/tutorial/venv.html).
|
||||
|
||||
1. In the root repository directory, create a python virtual environment:
|
||||
|
||||
```sh
|
||||
python -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`
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! info "Supported Shells"
|
||||
Depending on your system, the regular activation script _might_ not work on alternative shells. In this case, refer to the table below for supported shells:
|
||||
|
||||
| Shell | Script |
|
||||
| ---------: | :------------------------ |
|
||||
| Bash/ZSH | `.venv/bin/activate` |
|
||||
| Fish | `.venv/bin/activate.fish` |
|
||||
| CSH/TCSH | `.venv/bin/activate.csh` |
|
||||
| PowerShell | `.venv/bin/activate.ps1` |
|
||||
|
||||
3. Use the following PIP command to create an editable installation and install the required development dependencies:
|
||||
|
||||
```sh
|
||||
pip install -e .[dev]
|
||||
```
|
||||
|
||||
### Launching
|
||||
|
||||
The entry point for TagStudio is `src/tagstudio/main.py`. You can target this file from your IDE to run or connect a debug session. The example(s) below show off example launch scripts for different IDEs. Here you can also take advantage of [launch arguments](#launch-arguments) to pass your own test [libraries](./library/index.md) to use while developing.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
=== "VS Code"
|
||||
```json title=".vscode/launch.json"
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "TagStudio",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"program": "${workspaceRoot}/src/tagstudio/main.py",
|
||||
"console": "integratedTerminal",
|
||||
"justMyCode": true,
|
||||
"args": ["-o", "~/Documents/Example"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To build your own executables of TagStudio, first follow the steps in "[Installing with PIP](#installing-with-pip)" including the developer dependencies step. Once that's complete, run the following PyInstaller command:
|
||||
|
||||
```
|
||||
pyinstaller tagstudio.spec
|
||||
```
|
||||
|
||||
If you're on Windows or Linux and wish to build a portable executable, then pass the following flag:
|
||||
|
||||
```
|
||||
pyinstaller tagstudio.spec -- --portable
|
||||
```
|
||||
|
||||
The resulting executable file(s) will be located in a new folder named "dist".
|
||||
|
||||
## Third-Party Dependencies
|
||||
|
||||
For audio/video thumbnails and playback you'll also need [FFmpeg](https://ffmpeg.org/download.html) installed on your system. If you encounter any issues with this, please reference our [FFmpeg Help](./help/ffmpeg.md) guide.
|
||||
For audio/video thumbnails and playback you'll need [FFmpeg](https://ffmpeg.org/download.html) installed on your system. If you encounter any issues with this, please reference our [FFmpeg Help](./help/ffmpeg.md) guide.
|
||||
|
||||
You can check to see if FFmpeg and FFprobe are correctly located by launching TagStudio and going to "About TagStudio" in the menu bar.
|
||||
|
||||
## Launch Arguments
|
||||
|
||||
There are a handful of launch arguments you can pass to TagStudio via the command line or a desktop shortcut.
|
||||
|
||||
| Argument | Short | Description |
|
||||
| ---------------------- | ----- | ---------------------------------------------------- |
|
||||
| `--open <path>` | `-o` | Path to a TagStudio Library folder to open on start. |
|
||||
| `--config-file <path>` | `-c` | Path to the TagStudio config file to load. |
|
||||
|
||||
@@ -4,15 +4,15 @@ File entries are the individual representations of your files inside a TagStudio
|
||||
|
||||
## Storage
|
||||
|
||||
File entry data is storied within the `ts_library.sqlite` file inside each library's `.TagStudio` folder. No modifications are made to your actual files on disk, and nothing like sidecar files are generated for your files.
|
||||
File entry data is stored within the `ts_library.sqlite` file inside each library's `.TagStudio` folder. No modifications are made to your actual files on disk, and nothing like sidecar files are generated for your files.
|
||||
|
||||
## Appearance
|
||||
|
||||
File entries appear as file previews both inside the thumbnail grid. The preview panel shows a more detailed preview of the file, along with extra file stats and all attached TagStudio tags and fields.
|
||||
File entries appear as thumbnails inside the grid display. The preview panel shows a more detailed preview of the file, along with extra file stats and all attached TagStudio tags and fields.
|
||||
|
||||
## Unlinked File Entries
|
||||
|
||||
If the file that an entry is referencing has been moved, renamed, or deleted on disk, then TagStudio will display a red chain-link icon for the thumbnail image. Certain uncached stats such as the file size and image dimensions will also be unavailable to see in the preview panel when a file becomes unlinked.
|
||||
If the file that an entry is referencing has been moved, renamed, or deleted on disk, then TagStudio will display its unlinked status with a red chain-link icon instead of its thumbnail image. Certain uncached stats such as the file size and image dimensions will also be unavailable to see in the preview panel.
|
||||
|
||||
To fix file entries that have become unlinked, select the "Fix Unlinked Entries" option from the Tools menu. From there, refresh the unlinked entry count and choose whether to search and relink you files, and/or delete the file entries from your library. This will NOT delete or modify any files on disk.
|
||||
|
||||
@@ -32,11 +32,11 @@ To fix file entries that have become unlinked, select the "Fix Unlinked Entries"
|
||||
- `date_created` (`DATETIME`/`Datetime`)
|
||||
- _Not currently used, will be implemented in an upcoming update._
|
||||
- The creation date of the file (not the entry).
|
||||
- Generates from `st_birthtime` on Windows and Mac, and `st_ctime` on Linux.
|
||||
- Generated from `st_birthtime` on Windows and Mac, and `st_ctime` on Linux.
|
||||
- `date_modified` (`DATETIME`/`Datetime`)
|
||||
- _Not currently used, will be implemented in an upcoming update._
|
||||
- The latest modification date of the file (not the entry).
|
||||
- Generates from `st_mtime`.
|
||||
- Generated from `st_mtime`.
|
||||
- `date_added` (`DATETIME`/`Datetime`)
|
||||
- The date the file entry was added to the TagStudio library.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Fields
|
||||
|
||||
Fields are additional types of metadata that you can attach to [file entries](./entry.md). Like [tags](./tag.md), fields are not stored inside files themselves nor in sidecar files, but rather inside the respective TagStudio [library](index.md) save file.
|
||||
Fields are additional types of metadata that you can attach to [file entries](./entry.md). Like [tags](./tag.md), fields are not stored inside files themselves nor in sidecar files, but rather inside the respective TagStudio [library](./index.md) save file.
|
||||
|
||||
## Field Types
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ Searches can be grouped and nested by using parentheses to surround parts of you
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! example
|
||||
Searching for "(Tag1 `OR` Tag2) `AND` Tag3" will return results any results that contain Tag3, plus one or the other (or both) of Tag1 and Tag2.
|
||||
Searching for "(Tag1 `OR` Tag2) `AND` Tag3" will return any results that contain Tag3, plus one or the other (or both) of Tag1 and Tag2.
|
||||
|
||||
### Escaping Characters
|
||||
|
||||
@@ -54,13 +54,13 @@ Sometimes search queries have ambiguous characters and need to be "escaped". Thi
|
||||
|
||||
## Tags
|
||||
|
||||
[Tag](#tags) search is the default mode of file entry search in TagStudio. No keyword prefix is required, however using `tag:` will also work. The tag search attempts to match tag [names](tag.md#name), [shorthands](tag.md#shorthand), [aliases](tag.md#aliases), as well as allows for tags to [substitute](tag.md#intuition-via-substitution) in for any of their [parent tags](tag.md#parent-tags).
|
||||
[Tag](#tags) search is the default mode of file entry search in TagStudio. No keyword prefix is required, however using `tag:` will also work. The tag search attempts to match tag [names](./tag.md#name), [shorthands](./tag.md#shorthand), [aliases](./tag.md#aliases), as well as allows for tags to [substitute](./tag.md#intuition-via-substitution) in for any of their [parent tags](./tag.md#parent-tags).
|
||||
|
||||
You may also see the `tag_id:` prefix keyword show up with using the right-click "Search for Tag" option on tags. This is meant for internal use, and eventually will not be displayed or accessible to the user.
|
||||
You may also see the `tag_id:` prefix keyword show up when using the right-click "Search for Tag" option on tags. This is meant for internal use, and eventually will not be displayed or accessible to the user.
|
||||
|
||||
## Fields
|
||||
|
||||
_[Field](field.md) search is currently not in the program, however is coming in a future version._
|
||||
_[Field](./field.md) search is currently not in the program, however is coming in a future version._
|
||||
|
||||
## File Entry Search
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ This is a special type of alias that's used for shortening the tag name under sp
|
||||
|
||||
Aliases are alternate names that the tag can go by. This may include individual first names for people, alternate spellings, shortened names, and more. If there's a common abbreviation or shortened name for your tag, it's recommended to use the [shorthand](#shorthand) field for this instead.
|
||||
|
||||
When searching for a tag, aliases (including the shorthand) can also be used to find the tag. This not only includes searching for tags themselves, but for tagged [file entries](entry.md) as well!
|
||||
When searching for a tag, aliases (including the shorthand) can also be used to find the tag. This not only includes searching for tags themselves, but for tagged [file entries](./entry.md) as well!
|
||||
|
||||
### Automatic Disambiguation
|
||||
|
||||
@@ -32,7 +32,7 @@ Given a tag named "Freddy", we may confuse it with other "Freddy" tags in our li
|
||||
|
||||

|
||||
|
||||
So if the "Five Night's at Freddy's" tag is added as a parent tag on the "Freddy" tag, and the disambiguation box next to it is checked, then our tag name will automatically be displayed as "Freddy (Five Nights at Freddy's)". Better yet, if the "Five Night's at Freddy's" tag has a shorthand such as "FNAF", then our "Freddy" tag will be displayed as "Freddy (FNAF)". This process preserves our base tag name ("Freddy") and provides an option to get a clean and consistent method to display disambiguating parent categories, rather than having to type this information in manually for each applicable tag.
|
||||
So if the "Five Nights at Freddy's" tag is added as a parent tag on the "Freddy" tag, and the disambiguation box next to it is checked, then our tag name will automatically be displayed as "Freddy (Five Nights at Freddy's)". Better yet, if the "Five Nights at Freddy's" tag has a shorthand such as "FNAF", then our "Freddy" tag will be displayed as "Freddy (FNAF)". This process preserves our base tag name ("Freddy") and provides an option to get a clean and consistent method to display disambiguating parent categories, rather than having to type this information in manually for each applicable tag.
|
||||
|
||||
## Tag Relationships
|
||||
|
||||
@@ -62,7 +62,7 @@ Lastly, when searching your files with broader categories such as `Character` or
|
||||
|
||||
**_[Coming in version 9.6](../updates/roadmap.md#v96)_**
|
||||
|
||||
Component tags will be built from a composition-based, or "HAS" type relationship between tags. This takes care of instances where an attribute may "have" another attribute, but doesn't inherit from it. Shrek may be an `Orge`, he may be a `Character`, but he is NOT a `Leather Vest` - even if he's commonly seen _with_ it. Component tags, along with the upcoming [Tag Override](tag_overrides.md) feature, are built to handle these cases in a way that still simplifies the tagging process without adding too much undue complexity for the user.
|
||||
Component tags will be built from a composition-based, or "HAS" type relationship between tags. This takes care of instances where an attribute may "have" another attribute, but doesn't inherit from it. Shrek may be an `Ogre`, he may be a `Character`, but he is NOT a `Leather Vest` - even if he's commonly seen _with_ it. Component tags, along with the upcoming [Tag Override](./tag_overrides.md) feature, are built to handle these cases in a way that still simplifies the tagging process without adding too much undue complexity for the user.
|
||||
|
||||
## Tag Appearance
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ tags:
|
||||
|
||||
# Tag Categories
|
||||
|
||||
The "Is Category" property of tags determines if a tag should be treated as a category itself when being organized inside the preview panel. Tags marked as categories will show themselves and all tags inheriting from it (including recursively) underneath a field-like section with the tag's name. This means that duplicates of tags can appear on entries if the tag inherits from multiple parent categories, however this is by design and reflects the nature multiple inheritance. Any tags not inheriting from a category tag will simply show under a default "Tag" section.
|
||||
The "Is Category" property of tags determines if a tag should be treated as a category itself when being organized inside the preview panel. Tags marked as categories will show themselves and all tags inheriting from it (including recursively) underneath a field-like section with the tag's name. This means that duplicates of tags can appear on entries if the tag inherits from multiple parent categories, however this is by design and reflects the nature of multiple inheritance. Any tags not inheriting from a category tag will simply show under a default "Tag" section.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ TagStudio features a variety of built-in tag colors, alongside the ability for u
|
||||
|
||||
## Tag Color Manager
|
||||
|
||||
The Tag Color Manager is where you can create and manage your custom tag colors and associated namespaces. To open the Tag Color Manager, go to "File -> Manage Tag Colors" option in the menu bar.
|
||||
The Tag Color Manager is where you can create and manage your custom tag colors and associated namespaces. You can access the Tag Color Manager from the "File -> Manage Tag Colors" option in the menu bar.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ These version milestones are rough estimations for when the previous core featur
|
||||
- [ ] Field content search [HIGH]
|
||||
- [ ] Sort by date created [HIGH]
|
||||
- [ ] Sort by date modified [HIGH]
|
||||
- [ ] Sort by filename [HIGH]
|
||||
- [x] Sort by filename [HIGH]
|
||||
- [ ] HAS operator for composition tags [HIGH]
|
||||
- [ ] Search bar rework
|
||||
- [ ] Improved tag autocomplete [HIGH]
|
||||
@@ -106,14 +106,14 @@ These version milestones are rough estimations for when the previous core featur
|
||||
- [ ] 3D Model Previews [MEDIUM]
|
||||
- [ ] STL Previews [HIGH]
|
||||
- [ ] Word count/line count on text thumbnails [LOW]
|
||||
- [ ] Settings Menu [HIGH]
|
||||
- [ ] Application Settings [HIGH]
|
||||
- [ ] Stored in system user folder/designated folder [HIGH]
|
||||
- [x] Settings Menu [HIGH]
|
||||
- [x] Application Settings [HIGH]
|
||||
- [x] Stored in system user folder/designated folder [HIGH]
|
||||
- [ ] Library Settings [HIGH]
|
||||
- [ ] Stored in `.TagStudio` folder [HIGH]
|
||||
- [ ] Tagging Panel [HIGH]
|
||||
|
||||
Togglebale persistent main window panel or popout. Replaces the current tag manager.
|
||||
Toggleable persistent main window panel or pop-out. Replaces the current tag manager.
|
||||
|
||||
- [ ] Top Tags [HIGH]
|
||||
- [ ] Recent Tags [HIGH]
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
# Save Format Changes
|
||||
|
||||
This page outlines the various changes made the TagStudio save file format over time, sometimes referred to as the "database" or "database file".
|
||||
This page outlines the various changes made to the TagStudio save file format over time, sometimes referred to as the "database" or "database file".
|
||||
|
||||
---
|
||||
|
||||
## JSON
|
||||
|
||||
| First Used | Last Used | Format | Location |
|
||||
| ---------- | ----------------------------------------------------------------------- | ------ | --------------------------------------------- |
|
||||
| v1.0.0 | [v9.4.2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.4.2) | JSON | `<Library Folder>`/.TagStudio/ts_library.json |
|
||||
| Used From | Used Until | Format | Location |
|
||||
| --------- | ----------------------------------------------------------------------- | ------ | --------------------------------------------- |
|
||||
| v1.0.0 | [v9.4.2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.4.2) | JSON | `<Library Folder>`/.TagStudio/ts_library.json |
|
||||
|
||||
The legacy database format for public TagStudio releases [v9.1](https://github.com/TagStudioDev/TagStudio/tree/Alpha-v9.1) through [v9.4.2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.4.2). Variations of this format had been used privately since v1.0.0.
|
||||
|
||||
Replaced by the new SQLite format introduced in TagStudio [v9.5.0 Pre-Release 1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr1).
|
||||
|
||||
---
|
||||
|
||||
## DB_VERSION 6
|
||||
|
||||
| First Used | Last Used | Format | Location |
|
||||
| Used From | Used Until | Format | Location |
|
||||
| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| [v9.5.0-PR1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr1) | [v9.5.0-PR1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr1) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
|
||||
@@ -22,25 +26,35 @@ The first public version of the SQLite save file format.
|
||||
|
||||
Migration from the legacy JSON format is provided via a walkthrough when opening a legacy library in TagStudio [v9.5.0 Pre-Release 1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr1) or later.
|
||||
|
||||
---
|
||||
|
||||
## DB_VERSION 7
|
||||
|
||||
| First Used | Last Used | Format | Location |
|
||||
| Used From | Used Until | Format | Location |
|
||||
| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| [v9.5.0-PR2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr2) | [v9.5.0-PR3](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr3) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
|
||||
### Changes
|
||||
|
||||
- Repairs "Description" fields to use a TEXT_LINE key instead of a TEXT_BOX key.
|
||||
- Repairs tags that may have a disambiguation_id pointing towards a deleted tag.
|
||||
|
||||
---
|
||||
|
||||
## DB_VERSION 8
|
||||
|
||||
| First Used | Last Used | Format | Location |
|
||||
| ------------------------------------------------------------------------------- | --------- | ------ | ----------------------------------------------- |
|
||||
| [v9.5.0-PR4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr4) | _Current_ | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
| Used From | Used Until | Format | Location |
|
||||
| ------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| [v9.5.0-PR4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr4) | [v9.5.1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.1) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
|
||||
### Changes
|
||||
|
||||
- Adds the `color_border` column to `tag_colors` table. Used for instructing the [secondary color](../library/tag_color.md#secondary-color) to apply to a tag's border as a new optional behavior.
|
||||
- Adds the `color_border` column to the `tag_colors` table. Used for instructing the [secondary color](../library/tag_color.md#secondary-color) to apply to a tag's border as a new optional behavior.
|
||||
- Adds three new default colors: "Burgundy (TagStudio Shades)", "Dark Teal (TagStudio Shades)", and "Dark Lavender (TagStudio Shades)".
|
||||
- Updates Neon colors to use the new `color_border` property.
|
||||
|
||||
---
|
||||
|
||||
## DB_VERSION 9
|
||||
|
||||
| Used From | Used Until | Format | Location |
|
||||
| ----------------------------------------------------------------------- | ---------- | ------ | ----------------------------------------------- |
|
||||
| [v9.5.2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.2) | _Current_ | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
|
||||
- Adds the `filename` column to the `entries` table. Used for sorting entries by filename in search results.
|
||||
|
||||
@@ -12,7 +12,7 @@ Libraries under 10,000 files automatically scan for new or modified files when o
|
||||
|
||||
Access the "Add Tag" search box by either clicking on the "Add Tag" button at the bottom of the right sidebar, accessing the "Add Tags to Selected" option from the File menu, or by pressing <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>T</kbd>.
|
||||
|
||||
From here you can search for existing tags or create a new one if the one you're looking for doesn't exist. Click the "+" button next to any tags you want to the currently selected file entries. To quickly add the top result, press the <kbd>Enter</kbd>/<kbd>Return</kbd> key to add the the topmost tag and reset the tag search. Press <kbd>Enter</kbd>/<kbd>Return</kbd> once more to close the dialog box. By using this method, you can quickly add various tags in quick succession just by using the keyboard!
|
||||
From here you can search for existing tags or create a new one if the one you're looking for doesn't exist. Click the "+" button next to any tags you want to the currently selected file entries. To quickly add the top result, press the <kbd>Enter</kbd>/<kbd>Return</kbd> key to add the topmost tag and reset the tag search. Press <kbd>Enter</kbd>/<kbd>Return</kbd> once more to close the dialog box. By using this method, you can quickly add various tags in quick succession just by using the keyboard!
|
||||
|
||||
To remove a tag from a file entry, hover over the tag in the preview panel and click on the "-" icon that appears.
|
||||
|
||||
@@ -41,7 +41,7 @@ Create a new tag by accessing the "New Tag" option from the Edit menu or by pres
|
||||
|
||||
### Tag Manager
|
||||
|
||||
You can manage your library of tags from opening the "Tag Manager" panel from Edit -> "Manage Tags". From here you can create, search for, edit, and permanently delete any tags you've created in your library.
|
||||
You can manage your library of tags by opening the "Tag Manager" panel from Edit -> "Manage Tags". From here you can create, search for, edit, and permanently delete any tags you've created in your library.
|
||||
|
||||
## Editing Tags
|
||||
|
||||
@@ -62,3 +62,12 @@ Inevitably some of the files inside your library will be renamed, moved, or dele
|
||||
### Saving the Library
|
||||
|
||||
As of version 9.5, libraries are saved automatically as you go. To save a backup of your library, select File -> Save Library Backup from the menu bar.
|
||||
|
||||
## Launch Arguments
|
||||
|
||||
There are a handful of launch arguments you can pass to TagStudio via the command line or a desktop shortcut.
|
||||
|
||||
| Argument | Short | Description |
|
||||
| ---------------------- | ----- | ---------------------------------------------------- |
|
||||
| `--open <path>` | `-o` | Path to a TagStudio Library folder to open on start. |
|
||||
| `--config-file <path>` | `-c` | Path to the TagStudio config file to load. |
|
||||
|
||||
@@ -35,11 +35,11 @@ This tool is a preview of an upcoming feature. When selected, TagStudio will gen
|
||||
|
||||
### Auto-fill [WIP]
|
||||
|
||||
Tool is in development and will be documented in future update.
|
||||
Tool is in development and will be documented in a future update.
|
||||
|
||||
### Sort fields
|
||||
|
||||
Tool is in development, will allow for user-defined sorting of [fields](../library/field.md).
|
||||
Tool is in development. Will allow for user-defined sorting of [fields](../library/field.md).
|
||||
|
||||
### Folders to Tags
|
||||
|
||||
|
||||
17
flake.lock
generated
17
flake.lock
generated
@@ -36,27 +36,10 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-qt6": {
|
||||
"locked": {
|
||||
"lastModified": 1734856068,
|
||||
"narHash": "sha256-Q+CB1ajsJg4Z9HGHTBAGY1q18KpnnkmF/eCTLUY6FQ0=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "93ff48c9be84a76319dac293733df09bbbe3f25c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "93ff48c9be84a76319dac293733df09bbbe3f25c",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-parts": "flake-parts",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"nixpkgs-qt6": "nixpkgs-qt6",
|
||||
"systems": "systems"
|
||||
}
|
||||
},
|
||||
|
||||
12
flake.nix
12
flake.nix
@@ -9,8 +9,6 @@
|
||||
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
|
||||
nixpkgs-qt6.url = "github:NixOS/nixpkgs/93ff48c9be84a76319dac293733df09bbbe3f25c";
|
||||
|
||||
systems.url = "github:nix-systems/default";
|
||||
};
|
||||
|
||||
@@ -32,19 +30,19 @@
|
||||
{
|
||||
packages =
|
||||
let
|
||||
python = pkgs.python312Packages;
|
||||
pythonPackages = pkgs.python312Packages;
|
||||
|
||||
pillow-jxl-plugin = python.callPackage ./nix/package/pillow-jxl-plugin.nix {
|
||||
pillow-jxl-plugin = pythonPackages.callPackage ./nix/package/pillow-jxl-plugin.nix {
|
||||
inherit (pkgs) cmake;
|
||||
inherit pyexiv2;
|
||||
inherit (pkgs) rustPlatform;
|
||||
};
|
||||
pyexiv2 = python.callPackage ./nix/package/pyexiv2.nix { inherit (pkgs) exiv2; };
|
||||
vtf2img = python.callPackage ./nix/package/vtf2img.nix { };
|
||||
pyexiv2 = pythonPackages.callPackage ./nix/package/pyexiv2.nix { inherit (pkgs) exiv2; };
|
||||
vtf2img = pythonPackages.callPackage ./nix/package/vtf2img.nix { };
|
||||
in
|
||||
rec {
|
||||
default = tagstudio;
|
||||
tagstudio = pkgs.python312Packages.callPackage ./nix/package {
|
||||
tagstudio = pythonPackages.callPackage ./nix/package {
|
||||
inherit pillow-jxl-plugin vtf2img;
|
||||
};
|
||||
tagstudio-jxl = tagstudio.override { withJXLSupport = true; };
|
||||
|
||||
62
mkdocs.yml
62
mkdocs.yml
@@ -9,6 +9,7 @@
|
||||
# To run the preview server:
|
||||
# mkdocs serve
|
||||
|
||||
---
|
||||
site_name: TagStudio
|
||||
site_description: "A User-Focused Photo & File Management System"
|
||||
site_url: https://docs.tagstud.io/
|
||||
@@ -25,30 +26,30 @@ extra:
|
||||
tags:
|
||||
Upcoming Feature: upcoming
|
||||
|
||||
# by default the navigation is an alphanumerically sorted,
|
||||
# nested list of all the Markdown files found within the /docs directory
|
||||
# where index files are always first
|
||||
# uncomment the following to configure the navigation manually:
|
||||
|
||||
# nav:
|
||||
# - Home:
|
||||
# - index.md
|
||||
# - install.md
|
||||
# - usage.md
|
||||
# - Library:
|
||||
# - library/index.md
|
||||
# - library/entry.md
|
||||
# - library/entry_groups.md
|
||||
# - library/field.md
|
||||
# - library/tag.md
|
||||
# - library/tag_categories.md
|
||||
# - library/tag_overrides.md
|
||||
# - Utilities:
|
||||
# - utilities/macro.md
|
||||
# - Updates:
|
||||
# - updates/changelog.md
|
||||
# - updates/roadmap.md
|
||||
# - updates/db_migration.md
|
||||
nav:
|
||||
- Home:
|
||||
- index.md
|
||||
- install.md
|
||||
- usage.md
|
||||
- develop.md
|
||||
- Help:
|
||||
- help/ffmpeg.md
|
||||
- Library:
|
||||
- library/index.md
|
||||
- library/entry.md
|
||||
- library/entry_groups.md
|
||||
- library/field.md
|
||||
- library/library_search.md
|
||||
- library/tag.md
|
||||
- library/tag_categories.md
|
||||
- library/tag_color.md
|
||||
- library/tag_overrides.md
|
||||
- Utilities:
|
||||
- utilities/macro.md
|
||||
- Updates:
|
||||
- updates/changelog.md
|
||||
- updates/roadmap.md
|
||||
- updates/schema_changes.md
|
||||
|
||||
theme:
|
||||
name: material
|
||||
@@ -59,7 +60,7 @@ theme:
|
||||
icon: material/brightness-auto
|
||||
name: Switch to light mode
|
||||
# Palette toggle for light mode
|
||||
- media: "(prefers-color-scheme: light)"
|
||||
- media: "(prefers-color-scheme: light)"
|
||||
scheme: default
|
||||
primary: deep purple
|
||||
accent: deep purple
|
||||
@@ -67,7 +68,7 @@ theme:
|
||||
icon: material/brightness-7
|
||||
name: Switch to dark mode
|
||||
# Palette toggle for dark mode
|
||||
- media: "(prefers-color-scheme: dark)"
|
||||
- media: "(prefers-color-scheme: dark)"
|
||||
scheme: slate
|
||||
primary: deep purple
|
||||
accent: deep purple
|
||||
@@ -76,7 +77,7 @@ theme:
|
||||
name: SSwitch to system preference
|
||||
logo: assets/icon.png
|
||||
favicon: assets/icon.ico
|
||||
font: false # use system fonts
|
||||
font: false # use system fonts
|
||||
language: en
|
||||
features:
|
||||
- navigation.instant
|
||||
@@ -84,9 +85,6 @@ theme:
|
||||
- navigation.tracking
|
||||
- navigation.expand
|
||||
- navigation.sections
|
||||
#- navigation.tabs
|
||||
#- content.tabs.link
|
||||
#- navigation.top
|
||||
- search.suggest
|
||||
- content.code.annotate
|
||||
- content.code.copy
|
||||
@@ -135,8 +133,8 @@ markdown_extensions:
|
||||
plugins:
|
||||
- search
|
||||
- tags
|
||||
- social: # social embed cards
|
||||
enabled: !ENV [CI, false] # enabled only when running in CI (eg GitHub Actions)
|
||||
- social: # social embed cards
|
||||
enabled: !ENV [CI, false] # enabled only when running in CI (eg GitHub Actions)
|
||||
|
||||
extra_css:
|
||||
- stylesheets/extra.css
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
pillow-heif,
|
||||
pillow-jxl-plugin,
|
||||
pipewire,
|
||||
pydantic,
|
||||
pydub,
|
||||
pyside6,
|
||||
pytest-qt,
|
||||
@@ -26,8 +27,10 @@
|
||||
stdenv,
|
||||
structlog,
|
||||
syrupy,
|
||||
toml,
|
||||
ujson,
|
||||
vtf2img,
|
||||
wrapGAppsHook,
|
||||
|
||||
withJXLSupport ? false,
|
||||
}:
|
||||
@@ -45,6 +48,11 @@ buildPythonApplication {
|
||||
nativeBuildInputs = [
|
||||
pythonRelaxDepsHook
|
||||
qt6.wrapQtAppsHook
|
||||
|
||||
# INFO: Should be unnecessary once PR is pulled.
|
||||
# PR: https://github.com/NixOS/nixpkgs/pull/271037
|
||||
# Issue: https://github.com/NixOS/nixpkgs/issues/149812
|
||||
wrapGAppsHook
|
||||
];
|
||||
buildInputs = [
|
||||
qt6.qtbase
|
||||
@@ -58,6 +66,14 @@ buildPythonApplication {
|
||||
syrupy
|
||||
];
|
||||
|
||||
# TODO: Install more icon resolutions when available.
|
||||
preInstall = ''
|
||||
mkdir -p $out/share/applications $out/share/icons/hicolor/512x512/apps
|
||||
|
||||
cp $src/src/tagstudio/resources/tagstudio.desktop $out/share/applications
|
||||
cp $src/src/tagstudio/resources/icon.png $out/share/icons/hicolor/512x512/apps/tagstudio.png
|
||||
'';
|
||||
|
||||
makeWrapperArgs =
|
||||
[ "--prefix PATH : ${lib.makeBinPath [ ffmpeg-headless ]}" ]
|
||||
++ lib.optional stdenv.hostPlatform.isLinux "--prefix LD_LIBRARY_PATH : ${
|
||||
@@ -77,19 +93,21 @@ buildPythonApplication {
|
||||
opencv-python
|
||||
pillow
|
||||
pillow-heif
|
||||
pydantic
|
||||
pydub
|
||||
pyside6
|
||||
rawpy
|
||||
send2trash
|
||||
sqlalchemy
|
||||
structlog
|
||||
toml
|
||||
ujson
|
||||
vtf2img
|
||||
] ++ lib.optional withJXLSupport pillow-jxl-plugin;
|
||||
|
||||
# INFO: These tests require modifications to a library, which does not work
|
||||
# in a read-only environment.
|
||||
disabledTests = [
|
||||
# INFO: These tests require modifications to a library, which does not work
|
||||
# in a read-only environment.
|
||||
"test_build_tag_panel_add_alias_callback"
|
||||
"test_build_tag_panel_add_aliases"
|
||||
"test_build_tag_panel_add_sub_tag_callback"
|
||||
@@ -101,6 +119,9 @@ buildPythonApplication {
|
||||
"test_build_tag_panel_set_tag"
|
||||
"test_json_migration"
|
||||
"test_library_migrations"
|
||||
|
||||
# INFO: This test requires modification of a configuration file.
|
||||
"test_filepath_setting"
|
||||
];
|
||||
|
||||
meta = {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
inputs,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
{ lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
qt6Pkgs = import inputs.nixpkgs-qt6 { inherit (pkgs) system; };
|
||||
# INFO: PySide6 from PIP is compiled for 6.8.0 of Qt, must pin to match version.
|
||||
# Long term this can be solved with an alternative like uv2nix for the devshell.
|
||||
qt6Pkgs = import (builtins.fetchTarball {
|
||||
name = "nixos-unstable-qt6-pinned";
|
||||
url = "https://github.com/NixOS/nixpkgs/archive/93ff48c9be84a76319dac293733df09bbbe3f25c.tar.gz";
|
||||
sha256 = "038m7932v4z0zn2lk7k7mbqbank30q84r1viyhchw9pcm3aq3q23";
|
||||
}) { inherit (pkgs) system; };
|
||||
|
||||
pythonLibraryPath = lib.makeLibraryPath (
|
||||
(with pkgs; [
|
||||
@@ -58,12 +59,18 @@ in
|
||||
pkgs.mkShellNoCC {
|
||||
nativeBuildInputs = with pkgs; [
|
||||
coreutils
|
||||
uv
|
||||
|
||||
ruff
|
||||
];
|
||||
buildInputs = [ pythonWrapped ] ++ (with pkgs; [ ffmpeg-headless ]);
|
||||
|
||||
env.QT_QPA_PLATFORM = "wayland;xcb";
|
||||
env = {
|
||||
QT_QPA_PLATFORM = "wayland;xcb";
|
||||
|
||||
UV_NO_SYNC = "1";
|
||||
UV_PYTHON_DOWNLOADS = "never";
|
||||
};
|
||||
|
||||
shellHook =
|
||||
let
|
||||
@@ -71,19 +78,23 @@ pkgs.mkShellNoCC {
|
||||
in
|
||||
# bash
|
||||
''
|
||||
if [ ! -f .venv/bin/activate ] || [ "$(readlink -f .venv/bin/python)" != "$(readlink -f ${python})" ]; then
|
||||
venv="''${UV_PROJECT_ENVIRONMENT:-.venv}"
|
||||
|
||||
if [ ! -f "''${venv}"/bin/activate ] || [ "$(readlink -f "''${venv}"/bin/python)" != "$(readlink -f ${python})" ]; then
|
||||
printf '%s\n' 'Regenerating virtual environment, Python interpreter changed...' >&2
|
||||
rm -rf .venv
|
||||
${python} -m venv .venv
|
||||
rm -rf "''${venv}"
|
||||
uv venv --python ${python} "''${venv}"
|
||||
fi
|
||||
|
||||
source .venv/bin/activate
|
||||
source "''${venv}"/bin/activate
|
||||
|
||||
if [ ! -f .venv/pyproject.toml ] || [ "$(cat .venv/pyproject.toml)" != "$(cat pyproject.toml)" ]; then
|
||||
if [ ! -f "''${venv}"/pyproject.toml ] || ! diff --brief pyproject.toml "''${venv}"/pyproject.toml >/dev/null; then
|
||||
printf '%s\n' 'Installing dependencies, pyproject.toml changed...' >&2
|
||||
pip install --quiet --editable '.[mkdocs,mypy,pytest]'
|
||||
cp pyproject.toml .venv/pyproject.toml
|
||||
uv pip install --quiet --editable '.[mkdocs,mypy,pre-commit,pytest]'
|
||||
cp pyproject.toml "''${venv}"/pyproject.toml
|
||||
fi
|
||||
|
||||
pre-commit install
|
||||
'';
|
||||
|
||||
meta = {
|
||||
|
||||
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
|
||||
[project]
|
||||
name = "TagStudio"
|
||||
description = "A User-Focused Photo & File Management System."
|
||||
version = "9.5.1"
|
||||
version = "9.5.2"
|
||||
license = "GPL-3.0-only"
|
||||
readme = "README.md"
|
||||
dependencies = [
|
||||
@@ -27,12 +27,15 @@ dependencies = [
|
||||
"typing_extensions>=3.10.0.0,<4.11.0",
|
||||
"ujson>=5.8.0,<5.9.0",
|
||||
"vtf2img==0.1.0",
|
||||
"toml==0.10.2",
|
||||
"pydantic==2.9.2",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pre-commit==3.7.0", "tagstudio[mkdocs,mypy,pyinstaller,pytest,ruff]"]
|
||||
dev = ["tagstudio[mkdocs,mypy,pre-commit,pyinstaller,pytest,ruff]"]
|
||||
mkdocs = ["mkdocs-material[imaging]==9.*"]
|
||||
mypy = ["mypy==1.11.2", "mypy-extensions==1.*", "types-ujson>=5.8.0,<5.9.0"]
|
||||
pre-commit = ["pre-commit==3.7.0"]
|
||||
pyinstaller = ["Pyinstaller==6.6.0"]
|
||||
pytest = [
|
||||
"pytest==8.2.0",
|
||||
@@ -82,10 +85,13 @@ qt_api = "pyside6"
|
||||
ignore = [".venv/**"]
|
||||
include = ["src/tagstudio/**"]
|
||||
reportAny = false
|
||||
reportIgnoreCommentWithoutRule = false
|
||||
reportImplicitStringConcatenation = false
|
||||
reportMissingTypeArgument = false
|
||||
# reportOptionalMemberAccess = false
|
||||
reportUnannotatedClassAttribute = false
|
||||
reportUnknownArgumentType = false
|
||||
reportUnknownLambdaType = false
|
||||
reportUnknownMemberType = false
|
||||
reportUnusedCallResult = false
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
VERSION: str = "9.5.1" # Major.Minor.Patch
|
||||
VERSION: str = "9.5.2" # Major.Minor.Patch
|
||||
VERSION_BRANCH: str = "" # Usually "" or "Pre-Release"
|
||||
|
||||
# The folder & file names where TagStudio keeps its data relative to a library.
|
||||
|
||||
@@ -5,13 +5,15 @@ from PySide6.QtCore import QSettings
|
||||
|
||||
from tagstudio.core.constants import TS_FOLDER_NAME
|
||||
from tagstudio.core.enums import SettingItems
|
||||
from tagstudio.core.global_settings import GlobalSettings
|
||||
from tagstudio.core.library.alchemy.library import LibraryStatus
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class DriverMixin:
|
||||
settings: QSettings
|
||||
cached_values: QSettings
|
||||
settings: GlobalSettings
|
||||
|
||||
def evaluate_path(self, open_path: str | None) -> LibraryStatus:
|
||||
"""Check if the path of library is valid."""
|
||||
@@ -21,17 +23,17 @@ class DriverMixin:
|
||||
if not library_path.exists():
|
||||
logger.error("Path does not exist.", open_path=open_path)
|
||||
return LibraryStatus(success=False, message="Path does not exist.")
|
||||
elif self.settings.value(
|
||||
SettingItems.START_LOAD_LAST, defaultValue=True, type=bool
|
||||
) and self.settings.value(SettingItems.LAST_LIBRARY):
|
||||
library_path = Path(str(self.settings.value(SettingItems.LAST_LIBRARY)))
|
||||
elif self.settings.open_last_loaded_on_startup and self.cached_values.value(
|
||||
SettingItems.LAST_LIBRARY
|
||||
):
|
||||
library_path = Path(str(self.cached_values.value(SettingItems.LAST_LIBRARY)))
|
||||
if not (library_path / TS_FOLDER_NAME).exists():
|
||||
logger.error(
|
||||
"TagStudio folder does not exist.",
|
||||
library_path=library_path,
|
||||
ts_folder=TS_FOLDER_NAME,
|
||||
)
|
||||
self.settings.setValue(SettingItems.LAST_LIBRARY, "")
|
||||
self.cached_values.setValue(SettingItems.LAST_LIBRARY, "")
|
||||
# dont consider this a fatal error, just skip opening the library
|
||||
library_path = None
|
||||
|
||||
|
||||
@@ -10,14 +10,18 @@ from uuid import uuid4
|
||||
class SettingItems(str, enum.Enum):
|
||||
"""List of setting item names."""
|
||||
|
||||
START_LOAD_LAST = "start_load_last"
|
||||
LAST_LIBRARY = "last_library"
|
||||
LIBS_LIST = "libs_list"
|
||||
WINDOW_SHOW_LIBS = "window_show_libs"
|
||||
SHOW_FILENAMES = "show_filenames"
|
||||
AUTOPLAY = "autoplay_videos"
|
||||
THUMB_CACHE_SIZE_LIMIT = "thumb_cache_size_limit"
|
||||
LANGUAGE = "language"
|
||||
|
||||
|
||||
class ShowFilepathOption(int, enum.Enum):
|
||||
"""Values representing the options for the "show_filenames" setting."""
|
||||
|
||||
SHOW_FULL_PATHS = 0
|
||||
SHOW_RELATIVE_PATHS = 1
|
||||
SHOW_FILENAMES_ONLY = 2
|
||||
DEFAULT = SHOW_RELATIVE_PATHS
|
||||
|
||||
|
||||
class Theme(str, enum.Enum):
|
||||
@@ -71,5 +75,4 @@ class LibraryPrefs(DefaultEnum):
|
||||
|
||||
IS_EXCLUDE_LIST = True
|
||||
EXTENSION_LIST = [".json", ".xmp", ".aae"]
|
||||
PAGE_SIZE = 500
|
||||
DB_VERSION = 8
|
||||
DB_VERSION = 9
|
||||
|
||||
71
src/tagstudio/core/global_settings.py
Normal file
71
src/tagstudio/core/global_settings.py
Normal file
@@ -0,0 +1,71 @@
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
import platform
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import override
|
||||
|
||||
import structlog
|
||||
import toml
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from tagstudio.core.enums import ShowFilepathOption
|
||||
|
||||
if platform.system() == "Windows":
|
||||
DEFAULT_GLOBAL_SETTINGS_PATH = (
|
||||
Path.home() / "Appdata" / "Roaming" / "TagStudio" / "settings.toml"
|
||||
)
|
||||
else:
|
||||
DEFAULT_GLOBAL_SETTINGS_PATH = Path.home() / ".config" / "TagStudio" / "settings.toml"
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class TomlEnumEncoder(toml.TomlEncoder):
|
||||
@override
|
||||
def dump_value(self, v):
|
||||
if isinstance(v, Enum):
|
||||
return super().dump_value(v.value)
|
||||
return super().dump_value(v)
|
||||
|
||||
|
||||
class Theme(Enum):
|
||||
DARK = 0
|
||||
LIGHT = 1
|
||||
SYSTEM = 2
|
||||
DEFAULT = SYSTEM
|
||||
|
||||
|
||||
# NOTE: pydantic also has a BaseSettings class (from pydantic-settings) that allows any settings
|
||||
# properties to be overwritten with environment variables. as tagstudio is not currently using
|
||||
# environment variables, i did not base it on that, but that may be useful in the future.
|
||||
class GlobalSettings(BaseModel):
|
||||
language: str = Field(default="en")
|
||||
open_last_loaded_on_startup: bool = Field(default=True)
|
||||
autoplay: bool = Field(default=True)
|
||||
loop: bool = Field(default=True)
|
||||
show_filenames_in_grid: bool = Field(default=True)
|
||||
page_size: int = Field(default=100)
|
||||
show_filepath: ShowFilepathOption = Field(default=ShowFilepathOption.DEFAULT)
|
||||
theme: Theme = Field(default=Theme.SYSTEM)
|
||||
|
||||
@staticmethod
|
||||
def read_settings(path: Path = DEFAULT_GLOBAL_SETTINGS_PATH) -> "GlobalSettings":
|
||||
if path.exists():
|
||||
with open(path) as file:
|
||||
filecontents = file.read()
|
||||
if len(filecontents.strip()) != 0:
|
||||
logger.info("[Settings] Reading Global Settings File", path=path)
|
||||
settings_data = toml.loads(filecontents)
|
||||
settings = GlobalSettings(**settings_data)
|
||||
return settings
|
||||
|
||||
return GlobalSettings()
|
||||
|
||||
def save(self, path: Path = DEFAULT_GLOBAL_SETTINGS_PATH) -> None:
|
||||
if not path.parent.exists():
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(path, "w") as f:
|
||||
toml.dump(dict(self), f, encoder=TomlEnumEncoder())
|
||||
@@ -67,6 +67,8 @@ class ItemType(enum.Enum):
|
||||
|
||||
class SortingModeEnum(enum.Enum):
|
||||
DATE_ADDED = "file.date_added"
|
||||
FILE_NAME = "generic.filename"
|
||||
PATH = "file.path"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -74,14 +76,14 @@ class FilterState:
|
||||
"""Represent a state of the Library grid view."""
|
||||
|
||||
# these should remain
|
||||
page_index: int | None = 0
|
||||
page_size: int | None = 500
|
||||
page_size: int
|
||||
page_index: int = 0
|
||||
sorting_mode: SortingModeEnum = SortingModeEnum.DATE_ADDED
|
||||
ascending: bool = True
|
||||
|
||||
# these should be erased on update
|
||||
# Abstract Syntax Tree Of the current Search Query
|
||||
ast: AST = None
|
||||
ast: AST | None = None
|
||||
|
||||
@property
|
||||
def limit(self):
|
||||
@@ -92,35 +94,32 @@ class FilterState:
|
||||
return self.page_size * self.page_index
|
||||
|
||||
@classmethod
|
||||
def show_all(cls) -> "FilterState":
|
||||
return FilterState()
|
||||
def show_all(cls, page_size: int) -> "FilterState":
|
||||
return FilterState(page_size=page_size)
|
||||
|
||||
@classmethod
|
||||
def from_search_query(cls, search_query: str) -> "FilterState":
|
||||
return cls(ast=Parser(search_query).parse())
|
||||
def from_search_query(cls, search_query: str, page_size: int) -> "FilterState":
|
||||
return cls(ast=Parser(search_query).parse(), page_size=page_size)
|
||||
|
||||
@classmethod
|
||||
def from_tag_id(cls, tag_id: int | str) -> "FilterState":
|
||||
return cls(ast=Constraint(ConstraintType.TagID, str(tag_id), []))
|
||||
def from_tag_id(cls, tag_id: int | str, page_size: int) -> "FilterState":
|
||||
return cls(ast=Constraint(ConstraintType.TagID, str(tag_id), []), page_size=page_size)
|
||||
|
||||
@classmethod
|
||||
def from_path(cls, path: Path | str) -> "FilterState":
|
||||
return cls(ast=Constraint(ConstraintType.Path, str(path).strip(), []))
|
||||
def from_path(cls, path: Path | str, page_size: int) -> "FilterState":
|
||||
return cls(ast=Constraint(ConstraintType.Path, str(path).strip(), []), page_size=page_size)
|
||||
|
||||
@classmethod
|
||||
def from_mediatype(cls, mediatype: str) -> "FilterState":
|
||||
return cls(ast=Constraint(ConstraintType.MediaType, mediatype, []))
|
||||
def from_mediatype(cls, mediatype: str, page_size: int) -> "FilterState":
|
||||
return cls(ast=Constraint(ConstraintType.MediaType, mediatype, []), page_size=page_size)
|
||||
|
||||
@classmethod
|
||||
def from_filetype(cls, filetype: str) -> "FilterState":
|
||||
return cls(ast=Constraint(ConstraintType.FileType, filetype, []))
|
||||
def from_filetype(cls, filetype: str, page_size: int) -> "FilterState":
|
||||
return cls(ast=Constraint(ConstraintType.FileType, filetype, []), page_size=page_size)
|
||||
|
||||
@classmethod
|
||||
def from_tag_name(cls, tag_name: str) -> "FilterState":
|
||||
return cls(ast=Constraint(ConstraintType.Tag, tag_name, []))
|
||||
|
||||
def with_page_size(self, page_size: int) -> "FilterState":
|
||||
return replace(self, page_size=page_size)
|
||||
def from_tag_name(cls, tag_name: str, page_size: int) -> "FilterState":
|
||||
return cls(ast=Constraint(ConstraintType.Tag, tag_name, []), page_size=page_size)
|
||||
|
||||
def with_sorting_mode(self, mode: SortingModeEnum) -> "FilterState":
|
||||
return replace(self, sorting_mode=mode)
|
||||
|
||||
@@ -96,7 +96,7 @@ TAG_CHILDREN_QUERY = text("""
|
||||
-- Note for this entire query that tag_parents.child_id is the parent id and tag_parents.parent_id is the child id due to bad naming
|
||||
WITH RECURSIVE ChildTags AS (
|
||||
SELECT :tag_id AS child_id
|
||||
UNION ALL
|
||||
UNION
|
||||
SELECT tp.parent_id AS child_id
|
||||
FROM tag_parents tp
|
||||
INNER JOIN ChildTags c ON tp.child_id = c.child_id
|
||||
@@ -472,12 +472,25 @@ class Library:
|
||||
|
||||
# Apply any post-SQL migration patches.
|
||||
if not is_new:
|
||||
# save backup if patches will be applied
|
||||
if LibraryPrefs.DB_VERSION.default != db_version:
|
||||
self.library_dir = library_dir
|
||||
self.save_library_backup_to_disk()
|
||||
self.library_dir = None
|
||||
|
||||
# schema changes first
|
||||
if db_version < 8:
|
||||
self.apply_db8_schema_changes(session)
|
||||
if db_version < 9:
|
||||
self.apply_db9_schema_changes(session)
|
||||
|
||||
# now the data changes
|
||||
if db_version == 6:
|
||||
self.apply_repairs_for_db6(session)
|
||||
if db_version >= 6 and db_version < 8:
|
||||
self.apply_db8_default_data(session)
|
||||
if db_version < 9:
|
||||
self.apply_db9_filename_population(session)
|
||||
|
||||
# Update DB_VERSION
|
||||
if LibraryPrefs.DB_VERSION.default > db_version:
|
||||
@@ -580,6 +593,29 @@ class Library:
|
||||
)
|
||||
session.rollback()
|
||||
|
||||
def apply_db9_schema_changes(self, session: Session):
|
||||
"""Apply database schema changes introduced in DB_VERSION 9."""
|
||||
add_filename_column = text(
|
||||
"ALTER TABLE entries ADD COLUMN filename TEXT NOT NULL DEFAULT ''"
|
||||
)
|
||||
try:
|
||||
session.execute(add_filename_column)
|
||||
session.commit()
|
||||
logger.info("[Library][Migration] Added filename column to entries table")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"[Library][Migration] Could not create filename column in entries table!",
|
||||
error=e,
|
||||
)
|
||||
session.rollback()
|
||||
|
||||
def apply_db9_filename_population(self, session: Session):
|
||||
"""Populate the filename column introduced in DB_VERSION 9."""
|
||||
for entry in self.get_entries():
|
||||
session.merge(entry).filename = entry.path.name
|
||||
session.commit()
|
||||
logger.info("[Library][Migration] Populated filename column in entries table")
|
||||
|
||||
@property
|
||||
def default_fields(self) -> list[BaseField]:
|
||||
with Session(self.engine) as session:
|
||||
@@ -852,7 +888,7 @@ class Library:
|
||||
statement = statement.distinct(Entry.id)
|
||||
start_time = time.time()
|
||||
query_count = select(func.count()).select_from(statement.alias("entries"))
|
||||
count_all: int = session.execute(query_count).scalar()
|
||||
count_all: int = session.execute(query_count).scalar() or 0
|
||||
end_time = time.time()
|
||||
logger.info(f"finished counting ({format_timespan(end_time - start_time)})")
|
||||
|
||||
@@ -860,6 +896,10 @@ class Library:
|
||||
match search.sorting_mode:
|
||||
case SortingModeEnum.DATE_ADDED:
|
||||
sort_on = Entry.id
|
||||
case SortingModeEnum.FILE_NAME:
|
||||
sort_on = func.lower(Entry.filename)
|
||||
case SortingModeEnum.PATH:
|
||||
sort_on = func.lower(Entry.path)
|
||||
|
||||
statement = statement.order_by(asc(sort_on) if search.ascending else desc(sort_on))
|
||||
statement = statement.limit(search.limit).offset(search.offset)
|
||||
@@ -1371,6 +1411,8 @@ class Library:
|
||||
target_path,
|
||||
)
|
||||
|
||||
logger.info("Library backup saved to disk.", path=target_path)
|
||||
|
||||
return target_path
|
||||
|
||||
def get_tag(self, tag_id: int) -> Tag | None:
|
||||
|
||||
@@ -187,6 +187,7 @@ class Entry(Base):
|
||||
folder: Mapped[Folder] = relationship("Folder")
|
||||
|
||||
path: Mapped[Path] = mapped_column(PathType, unique=True)
|
||||
filename: Mapped[str] = mapped_column()
|
||||
suffix: Mapped[str] = mapped_column()
|
||||
date_created: Mapped[dt | None]
|
||||
date_modified: Mapped[dt | None]
|
||||
@@ -232,6 +233,7 @@ class Entry(Base):
|
||||
self.path = path
|
||||
self.folder = folder
|
||||
self.id = id
|
||||
self.filename = path.name
|
||||
self.suffix = path.suffix.lstrip(".").lower()
|
||||
|
||||
# The date the file associated with this entry was created.
|
||||
|
||||
@@ -36,7 +36,7 @@ TAG_CHILDREN_ID_QUERY = text("""
|
||||
-- Note for this entire query that tag_parents.child_id is the parent id and tag_parents.parent_id is the child id due to bad naming
|
||||
WITH RECURSIVE ChildTags AS (
|
||||
SELECT :tag_id AS child_id
|
||||
UNION ALL
|
||||
UNION
|
||||
SELECT tp.parent_id AS child_id
|
||||
FROM tag_parents tp
|
||||
INNER JOIN ChildTags c ON tp.child_id = c.child_id
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
import logging
|
||||
import mimetypes
|
||||
from dataclasses import dataclass
|
||||
@@ -10,7 +11,16 @@ from pathlib import Path
|
||||
|
||||
logging.basicConfig(format="%(message)s", level=logging.INFO)
|
||||
|
||||
FILETYPE_EQUIVALENTS = [set(["jpg", "jpeg"])]
|
||||
FILETYPE_EQUIVALENTS = [
|
||||
set(["aif", "aiff", "aifc"]),
|
||||
set(["html", "htm", "xhtml", "shtml", "dhtml"]),
|
||||
set(["jfif", "jpeg_large", "jpeg", "jpg_large", "jpg"]),
|
||||
set(["json", "jsonc", "json5"]),
|
||||
set(["md", "markdown", "mkd", "rmd"]),
|
||||
set(["tar.gz", "tgz"]),
|
||||
set(["xml", "xul"]),
|
||||
set(["yaml", "yml"]),
|
||||
]
|
||||
|
||||
|
||||
class MediaType(str, Enum):
|
||||
@@ -22,6 +32,7 @@ class MediaType(str, Enum):
|
||||
AUDIO_MIDI = "audio_midi"
|
||||
AUDIO = "audio"
|
||||
BLENDER = "blender"
|
||||
CODE = "code"
|
||||
DATABASE = "database"
|
||||
DISK_IMAGE = "disk_image"
|
||||
DOCUMENT = "document"
|
||||
@@ -32,6 +43,7 @@ class MediaType(str, Enum):
|
||||
IMAGE_VECTOR = "image_vector"
|
||||
IMAGE = "image"
|
||||
INSTALLER = "installer"
|
||||
IWORK = "iwork"
|
||||
MATERIAL = "material"
|
||||
MODEL = "model"
|
||||
OPEN_DOCUMENT = "open_document"
|
||||
@@ -40,6 +52,7 @@ class MediaType(str, Enum):
|
||||
PLAINTEXT = "plaintext"
|
||||
PRESENTATION = "presentation"
|
||||
PROGRAM = "program"
|
||||
SHADER = "shader"
|
||||
SHORTCUT = "shortcut"
|
||||
SOURCE_ENGINE = "source_engine"
|
||||
SPREADSHEET = "spreadsheet"
|
||||
@@ -96,6 +109,7 @@ class MediaCategories:
|
||||
_AUDIO_SET: set[str] = {
|
||||
".aac",
|
||||
".aif",
|
||||
".aifc",
|
||||
".aiff",
|
||||
".alac",
|
||||
".flac",
|
||||
@@ -143,9 +157,71 @@ class MediaCategories:
|
||||
".blend31",
|
||||
".blend32",
|
||||
}
|
||||
_CODE_SET: set[str] = {
|
||||
".bat",
|
||||
".cfg",
|
||||
".conf",
|
||||
".cpp",
|
||||
".cs",
|
||||
".csh",
|
||||
".css",
|
||||
".d",
|
||||
".dhtml",
|
||||
".fgd",
|
||||
".fish",
|
||||
".gitignore",
|
||||
".h",
|
||||
".hpp",
|
||||
".htm",
|
||||
".html",
|
||||
".inf",
|
||||
".ini",
|
||||
".js",
|
||||
".json",
|
||||
".json5",
|
||||
".jsonc",
|
||||
".jsx",
|
||||
".kv3",
|
||||
".lua",
|
||||
".meta",
|
||||
".nix",
|
||||
".nu",
|
||||
".nut",
|
||||
".php",
|
||||
".plist",
|
||||
".prefs",
|
||||
".ps1",
|
||||
".py",
|
||||
".pyi",
|
||||
".qml",
|
||||
".qrc",
|
||||
".qss",
|
||||
".rs",
|
||||
".sh",
|
||||
".shtml",
|
||||
".sip",
|
||||
".spec",
|
||||
".tcl",
|
||||
".timestamp",
|
||||
".toml",
|
||||
".ts",
|
||||
".tsx",
|
||||
".vcfg",
|
||||
".vdf",
|
||||
".vmt",
|
||||
".vqlayout",
|
||||
".vsc",
|
||||
".vsnd_template",
|
||||
".xhtml",
|
||||
".xml",
|
||||
".xul",
|
||||
".yaml",
|
||||
".yml",
|
||||
}
|
||||
_DATABASE_SET: set[str] = {
|
||||
".accdb",
|
||||
".mdb",
|
||||
".pdb",
|
||||
".sqlite",
|
||||
".sqlite3",
|
||||
}
|
||||
@@ -166,23 +242,23 @@ class MediaCategories:
|
||||
".wps",
|
||||
}
|
||||
_EBOOK_SET: set[str] = {
|
||||
".azw",
|
||||
".azw3",
|
||||
".cb7",
|
||||
".cba",
|
||||
".cbr",
|
||||
".cbt",
|
||||
".cbz",
|
||||
".djvu",
|
||||
".epub",
|
||||
# ".azw",
|
||||
# ".azw3",
|
||||
# ".cb7",
|
||||
# ".cba",
|
||||
# ".cbr",
|
||||
# ".cbt",
|
||||
# ".cbz",
|
||||
# ".djvu",
|
||||
# ".fb2",
|
||||
# ".ibook",
|
||||
# ".inf",
|
||||
# ".kfx",
|
||||
# ".lit",
|
||||
# ".mobi",
|
||||
# ".pdb"
|
||||
# ".prc",
|
||||
".fb2",
|
||||
".ibook",
|
||||
".inf",
|
||||
".kfx",
|
||||
".lit",
|
||||
".mobi",
|
||||
".pdb",
|
||||
".prc",
|
||||
}
|
||||
_FONT_SET: set[str] = {
|
||||
".fon",
|
||||
@@ -209,7 +285,7 @@ class MediaCategories:
|
||||
".raw",
|
||||
".rw2",
|
||||
}
|
||||
_IMAGE_VECTOR_SET: set[str] = {".svg"}
|
||||
_IMAGE_VECTOR_SET: set[str] = {".eps", ".epsf", ".epsi", ".svg", ".svgz"}
|
||||
_IMAGE_RASTER_SET: set[str] = {
|
||||
".apng",
|
||||
".avif",
|
||||
@@ -218,6 +294,7 @@ class MediaCategories:
|
||||
".gif",
|
||||
".heic",
|
||||
".heif",
|
||||
".icns",
|
||||
".j2k",
|
||||
".jfif",
|
||||
".jp2",
|
||||
@@ -235,6 +312,7 @@ class MediaCategories:
|
||||
".webp",
|
||||
}
|
||||
_INSTALLER_SET: set[str] = {".appx", ".msi", ".msix"}
|
||||
_IWORK_SET: set[str] = {".key", ".pages", ".numbers"}
|
||||
_MATERIAL_SET: set[str] = {".mtl"}
|
||||
_MODEL_SET: set[str] = {".3ds", ".fbx", ".obj", ".stl"}
|
||||
_OPEN_DOCUMENT_SET: set[str] = {
|
||||
@@ -258,51 +336,21 @@ class MediaCategories:
|
||||
".pkg",
|
||||
".xapk",
|
||||
}
|
||||
_PDF_SET: set[str] = {
|
||||
".pdf",
|
||||
}
|
||||
_PDF_SET: set[str] = {".pdf"}
|
||||
_PLAINTEXT_SET: set[str] = {
|
||||
".bat",
|
||||
".cfg",
|
||||
".conf",
|
||||
".cpp",
|
||||
".cs",
|
||||
".css",
|
||||
".csv",
|
||||
".fgd",
|
||||
".gi",
|
||||
".h",
|
||||
".hpp",
|
||||
".htm",
|
||||
".html",
|
||||
".inf",
|
||||
".ini",
|
||||
".js",
|
||||
".json",
|
||||
".jsonc",
|
||||
".kv3",
|
||||
".lua",
|
||||
".i3u",
|
||||
".lang",
|
||||
".lock",
|
||||
".log",
|
||||
".markdown",
|
||||
".md",
|
||||
".nut",
|
||||
".php",
|
||||
".plist",
|
||||
".prefs",
|
||||
".py",
|
||||
".pyc",
|
||||
".qss",
|
||||
".sh",
|
||||
".toml",
|
||||
".ts",
|
||||
".mkd",
|
||||
".rmd",
|
||||
".txt",
|
||||
".vcfg",
|
||||
".vdf",
|
||||
".vmt",
|
||||
".vqlayout",
|
||||
".vsc",
|
||||
".vsnd_template",
|
||||
".xml",
|
||||
".yaml",
|
||||
".yml",
|
||||
"contributing",
|
||||
"license",
|
||||
"readme",
|
||||
}
|
||||
_PRESENTATION_SET: set[str] = {
|
||||
".key",
|
||||
@@ -310,9 +358,16 @@ class MediaCategories:
|
||||
".ppt",
|
||||
".pptx",
|
||||
}
|
||||
_PROGRAM_SET: set[str] = {".app", ".exe"}
|
||||
_SOURCE_ENGINE_SET: set[str] = {
|
||||
".vtf",
|
||||
_PROGRAM_SET: set[str] = {".app", ".bin", ".exe"}
|
||||
_SOURCE_ENGINE_SET: set[str] = {".vtf"}
|
||||
_SHADER_SET: set[str] = {
|
||||
".effect",
|
||||
".frag",
|
||||
".fsh",
|
||||
".glsl",
|
||||
".shader",
|
||||
".vert",
|
||||
".vsh",
|
||||
}
|
||||
_SHORTCUT_SET: set[str] = {".desktop", ".lnk", ".url"}
|
||||
_SPREADSHEET_SET: set[str] = {
|
||||
@@ -373,6 +428,12 @@ class MediaCategories:
|
||||
is_iana=False,
|
||||
name="blender",
|
||||
)
|
||||
CODE_TYPES = MediaCategory(
|
||||
media_type=MediaType.CODE,
|
||||
extensions=_CODE_SET,
|
||||
is_iana=False,
|
||||
name="code",
|
||||
)
|
||||
DATABASE_TYPES = MediaCategory(
|
||||
media_type=MediaType.DATABASE,
|
||||
extensions=_DATABASE_SET,
|
||||
@@ -439,6 +500,12 @@ class MediaCategories:
|
||||
is_iana=False,
|
||||
name="installer",
|
||||
)
|
||||
IWORK_TYPES = MediaCategory(
|
||||
media_type=MediaType.IWORK,
|
||||
extensions=_IWORK_SET,
|
||||
is_iana=False,
|
||||
name="iwork",
|
||||
)
|
||||
MATERIAL_TYPES = MediaCategory(
|
||||
media_type=MediaType.MATERIAL,
|
||||
extensions=_MATERIAL_SET,
|
||||
@@ -471,7 +538,7 @@ class MediaCategories:
|
||||
)
|
||||
PLAINTEXT_TYPES = MediaCategory(
|
||||
media_type=MediaType.PLAINTEXT,
|
||||
extensions=_PLAINTEXT_SET,
|
||||
extensions=_PLAINTEXT_SET | _CODE_SET,
|
||||
is_iana=False,
|
||||
name="plaintext",
|
||||
)
|
||||
@@ -487,6 +554,12 @@ class MediaCategories:
|
||||
is_iana=False,
|
||||
name="program",
|
||||
)
|
||||
SHADER_TYPES = MediaCategory(
|
||||
media_type=MediaType.SHADER,
|
||||
extensions=_SHADER_SET,
|
||||
is_iana=False,
|
||||
name="shader",
|
||||
)
|
||||
SHORTCUT_TYPES = MediaCategory(
|
||||
media_type=MediaType.SHORTCUT,
|
||||
extensions=_SHORTCUT_SET,
|
||||
@@ -535,6 +608,7 @@ class MediaCategories:
|
||||
IMAGE_TYPES,
|
||||
IMAGE_VECTOR_TYPES,
|
||||
INSTALLER_TYPES,
|
||||
IWORK_TYPES,
|
||||
MATERIAL_TYPES,
|
||||
MODEL_TYPES,
|
||||
OPEN_DOCUMENT_TYPES,
|
||||
@@ -543,6 +617,8 @@ class MediaCategories:
|
||||
PLAINTEXT_TYPES,
|
||||
PRESENTATION_TYPES,
|
||||
PROGRAM_TYPES,
|
||||
CODE_TYPES,
|
||||
SHADER_TYPES,
|
||||
SHORTCUT_TYPES,
|
||||
SOURCE_ENGINE_TYPES,
|
||||
SPREADSHEET_TYPES,
|
||||
|
||||
@@ -52,7 +52,7 @@ class DupeRegistry:
|
||||
continue
|
||||
|
||||
results = self.library.search_library(
|
||||
FilterState.from_path(path_relative),
|
||||
FilterState.from_path(path_relative, page_size=500),
|
||||
)
|
||||
|
||||
if not results:
|
||||
|
||||
@@ -33,11 +33,18 @@ def main():
|
||||
help="Path to a TagStudio Library folder to open on start.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-c",
|
||||
"--config-file",
|
||||
dest="config_file",
|
||||
"-s",
|
||||
"--settings-file",
|
||||
dest="settings_file",
|
||||
type=str,
|
||||
help="Path to a TagStudio .ini or .plist config file to use.",
|
||||
help="Path to a TagStudio .toml global settings file to use.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-c",
|
||||
"--cache-file",
|
||||
dest="cache_file",
|
||||
type=str,
|
||||
help="Path to a TagStudio .ini or .plist cache file to use.",
|
||||
)
|
||||
|
||||
# parser.add_argument('--browse', dest='browse', action='store_true',
|
||||
@@ -50,12 +57,6 @@ def main():
|
||||
action="store_true",
|
||||
help="Reveals additional internal data useful for debugging.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ui",
|
||||
dest="ui",
|
||||
type=str,
|
||||
help="User interface option for TagStudio. Options: qt, cli (Default: qt)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
driver = QtDriver(args)
|
||||
|
||||
@@ -32,7 +32,7 @@ class CacheManager(metaclass=Singleton):
|
||||
self.last_lib_path: Path | None = None
|
||||
|
||||
@staticmethod
|
||||
def clear_cache(library_dir: Path) -> bool:
|
||||
def clear_cache(library_dir: Path | None) -> bool:
|
||||
"""Clear all files and folders within the cached folder.
|
||||
|
||||
Returns:
|
||||
|
||||
@@ -5,24 +5,28 @@
|
||||
|
||||
"""PySide6 port of the widgets/layouts/flowlayout example from Qt v6.x."""
|
||||
|
||||
from typing import Literal, override
|
||||
|
||||
from PySide6.QtCore import QMargins, QPoint, QRect, QSize, Qt
|
||||
from PySide6.QtWidgets import QLayout, QSizePolicy, QWidget
|
||||
from PySide6.QtWidgets import QLayout, QLayoutItem, QSizePolicy, QWidget
|
||||
|
||||
IGNORE_SIZE = "ignore_size"
|
||||
|
||||
|
||||
class FlowWidget(QWidget):
|
||||
def __init__(self, parent=None) -> None:
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.ignore_size: bool = False
|
||||
self.setProperty(IGNORE_SIZE, False) # noqa: FBT003
|
||||
|
||||
|
||||
class FlowLayout(QLayout):
|
||||
def __init__(self, parent=None):
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
|
||||
if parent is not None:
|
||||
self.setContentsMargins(QMargins(0, 0, 0, 0))
|
||||
|
||||
self._item_list = []
|
||||
self._item_list: list[QLayoutItem] = []
|
||||
self.grid_efficiency = False
|
||||
|
||||
def __del__(self):
|
||||
@@ -30,46 +34,56 @@ class FlowLayout(QLayout):
|
||||
while item:
|
||||
item = self.takeAt(0)
|
||||
|
||||
def addItem(self, item): # noqa: N802
|
||||
self._item_list.append(item)
|
||||
@override
|
||||
def addItem(self, arg__1: QLayoutItem) -> None:
|
||||
self._item_list.append(arg__1)
|
||||
|
||||
def count(self):
|
||||
@override
|
||||
def count(self) -> int:
|
||||
return len(self._item_list)
|
||||
|
||||
def itemAt(self, index): # noqa: N802
|
||||
@override
|
||||
def itemAt(self, index: int) -> QLayoutItem | None: # pyright: ignore[reportIncompatibleMethodOverride]
|
||||
if 0 <= index < len(self._item_list):
|
||||
return self._item_list[index]
|
||||
|
||||
return None
|
||||
|
||||
def takeAt(self, index): # noqa: N802
|
||||
@override
|
||||
def takeAt(self, index: int) -> QLayoutItem | None: # pyright: ignore[reportIncompatibleMethodOverride]
|
||||
if 0 <= index < len(self._item_list):
|
||||
return self._item_list.pop(index)
|
||||
|
||||
return None
|
||||
|
||||
def expandingDirections(self): # noqa: N802
|
||||
return Qt.Orientation(0)
|
||||
@override
|
||||
def expandingDirections(self) -> Qt.Orientation:
|
||||
return Qt.Orientation.Horizontal
|
||||
|
||||
def hasHeightForWidth(self): # noqa: N802
|
||||
@override
|
||||
def hasHeightForWidth(self) -> Literal[True]:
|
||||
return True
|
||||
|
||||
def heightForWidth(self, width): # noqa: N802
|
||||
height = self._do_layout(QRect(0, 0, width, 0), test_only=True)
|
||||
return height
|
||||
@override
|
||||
def heightForWidth(self, arg__1: int) -> int:
|
||||
height = self._do_layout(QRect(0, 0, arg__1, 0), test_only=True)
|
||||
return int(height)
|
||||
|
||||
def setGeometry(self, rect): # noqa: N802
|
||||
super().setGeometry(rect)
|
||||
self._do_layout(rect, test_only=False)
|
||||
@override
|
||||
def setGeometry(self, arg__1: QRect) -> None:
|
||||
super().setGeometry(arg__1)
|
||||
self._do_layout(arg__1, test_only=False)
|
||||
|
||||
def enable_grid_optimizations(self, value: bool):
|
||||
def enable_grid_optimizations(self, value: bool) -> None:
|
||||
"""Enable or Disable efficiencies when all objects are equally sized."""
|
||||
self.grid_efficiency = value
|
||||
|
||||
def sizeHint(self): # noqa: N802
|
||||
@override
|
||||
def sizeHint(self) -> QSize:
|
||||
return self.minimumSize()
|
||||
|
||||
def minimumSize(self): # noqa: N802
|
||||
@override
|
||||
def minimumSize(self) -> QSize:
|
||||
if self.grid_efficiency:
|
||||
if self._item_list:
|
||||
return self._item_list[0].minimumSize()
|
||||
@@ -89,8 +103,8 @@ class FlowLayout(QLayout):
|
||||
y = rect.y()
|
||||
line_height = 0
|
||||
spacing = self.spacing()
|
||||
layout_spacing_x = None
|
||||
layout_spacing_y = None
|
||||
layout_spacing_x = 0
|
||||
layout_spacing_y = 0
|
||||
|
||||
if self.grid_efficiency and self._item_list:
|
||||
item = self._item_list[0]
|
||||
@@ -108,12 +122,12 @@ class FlowLayout(QLayout):
|
||||
|
||||
for item in self._item_list:
|
||||
skip_count = 0
|
||||
if issubclass(type(item.widget()), FlowWidget) and item.widget().ignore_size:
|
||||
ignore_size: bool | None = item.widget().property(IGNORE_SIZE)
|
||||
|
||||
if ignore_size:
|
||||
skip_count += 1
|
||||
|
||||
if (issubclass(type(item.widget()), FlowWidget) and not item.widget().ignore_size) or (
|
||||
not issubclass(type(item.widget()), FlowWidget)
|
||||
):
|
||||
else:
|
||||
if not self.grid_efficiency:
|
||||
style = item.widget().style()
|
||||
layout_spacing_x = style.layoutSpacing(
|
||||
|
||||
@@ -35,7 +35,7 @@ def theme_fg_overlay(image: Image.Image, use_alpha: bool = True) -> Image.Image:
|
||||
return _apply_overlay(image, im)
|
||||
|
||||
|
||||
def gradient_overlay(image: Image.Image, gradient=list[str]) -> Image.Image:
|
||||
def gradient_overlay(image: Image.Image, gradient: list[str]) -> Image.Image:
|
||||
"""Overlay a color gradient onto an image.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -7,10 +7,12 @@ import subprocess
|
||||
import sys
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import override
|
||||
|
||||
import structlog
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QLabel
|
||||
from PySide6.QtGui import QMouseEvent
|
||||
from PySide6.QtWidgets import QLabel, QWidget
|
||||
|
||||
from tagstudio.qt.helpers.silent_popen import silent_Popen
|
||||
|
||||
@@ -115,15 +117,17 @@ class FileOpenerHelper:
|
||||
|
||||
|
||||
class FileOpenerLabel(QLabel):
|
||||
def __init__(self, parent=None):
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
"""Initialize the FileOpenerLabel.
|
||||
|
||||
Args:
|
||||
parent (QWidget, optional): The parent widget. Defaults to None.
|
||||
"""
|
||||
self.filepath: str | Path | None = None
|
||||
|
||||
super().__init__(parent)
|
||||
|
||||
def set_file_path(self, filepath):
|
||||
def set_file_path(self, filepath: str | Path) -> None:
|
||||
"""Set the filepath to open.
|
||||
|
||||
Args:
|
||||
@@ -131,20 +135,22 @@ class FileOpenerLabel(QLabel):
|
||||
"""
|
||||
self.filepath = filepath
|
||||
|
||||
def mousePressEvent(self, event): # noqa: N802
|
||||
@override
|
||||
def mousePressEvent(self, ev: QMouseEvent) -> None:
|
||||
"""Handle mouse press events.
|
||||
|
||||
On a left click, open the file in the default file explorer.
|
||||
On a right click, show a context menu.
|
||||
|
||||
Args:
|
||||
event (QMouseEvent): The mouse press event.
|
||||
ev (QMouseEvent): The mouse press event.
|
||||
"""
|
||||
super().mousePressEvent(event)
|
||||
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
if ev.button() == Qt.MouseButton.LeftButton:
|
||||
assert self.filepath is not None, "File path is not set"
|
||||
opener = FileOpenerHelper(self.filepath)
|
||||
opener.open_explorer()
|
||||
elif event.button() == Qt.MouseButton.RightButton:
|
||||
elif ev.button() == Qt.MouseButton.RightButton:
|
||||
# Show context menu
|
||||
pass
|
||||
else:
|
||||
super().mousePressEvent(ev)
|
||||
|
||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
||||
|
||||
import ffmpeg
|
||||
|
||||
from tagstudio.qt.helpers.vendored.ffmpeg import _probe
|
||||
from tagstudio.qt.helpers.vendored.ffmpeg import probe
|
||||
|
||||
|
||||
def is_readable_video(filepath: Path | str):
|
||||
@@ -19,8 +19,8 @@ def is_readable_video(filepath: Path | str):
|
||||
filepath (Path | str): The filepath of the video to check.
|
||||
"""
|
||||
try:
|
||||
probe = _probe(Path(filepath))
|
||||
for stream in probe["streams"]:
|
||||
result = probe(Path(filepath))
|
||||
for stream in result["streams"]:
|
||||
# DRM check
|
||||
if stream.get("codec_tag_string") in [
|
||||
"drma",
|
||||
|
||||
@@ -49,8 +49,8 @@ def four_corner_gradient(
|
||||
|
||||
|
||||
def linear_gradient(
|
||||
size=tuple[int, int],
|
||||
colors=list[str],
|
||||
size: tuple[int, int],
|
||||
colors: list[str],
|
||||
interpolation: Image.Resampling = Image.Resampling.BICUBIC,
|
||||
) -> Image.Image:
|
||||
seed: Image.Image = Image.new(mode="RGBA", size=(len(colors), 1), color="#000000")
|
||||
|
||||
43
src/tagstudio/qt/helpers/qslider_wrapper.py
Normal file
43
src/tagstudio/qt/helpers/qslider_wrapper.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# Copyright (C) 2025
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
from typing import override
|
||||
|
||||
from PySide6.QtGui import QMouseEvent
|
||||
from PySide6.QtWidgets import QSlider, QStyle, QStyleOptionSlider
|
||||
|
||||
|
||||
class QClickSlider(QSlider):
|
||||
"""Custom QSlider wrapper.
|
||||
|
||||
The purpose of this wrapper is to allow us to set slider positions
|
||||
based on click events.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@override
|
||||
def mousePressEvent(self, ev: QMouseEvent):
|
||||
"""Override to handle mouse clicks.
|
||||
|
||||
Overriding the mousePressEvent allows us to seek
|
||||
directly to the position the user clicked instead
|
||||
of stepping.
|
||||
"""
|
||||
opt = QStyleOptionSlider()
|
||||
self.initStyleOption(opt)
|
||||
handle_rect = self.style().subControlRect(
|
||||
QStyle.ComplexControl.CC_Slider, opt, QStyle.SubControl.SC_SliderHandle, self
|
||||
)
|
||||
|
||||
was_slider_clicked = handle_rect.contains(int(ev.position().x()), int(ev.position().y()))
|
||||
|
||||
if not was_slider_clicked:
|
||||
self.setValue(
|
||||
QStyle.sliderValueFromPosition(self.minimum(), self.maximum(), ev.x(), self.width())
|
||||
)
|
||||
self.mouse_pressed = True
|
||||
|
||||
super().mousePressEvent(ev)
|
||||
@@ -84,3 +84,79 @@ def silent_Popen( # noqa: N802
|
||||
pipesize=pipesize,
|
||||
process_group=process_group,
|
||||
)
|
||||
|
||||
|
||||
def silent_run( # noqa: N802
|
||||
args,
|
||||
bufsize=-1,
|
||||
executable=None,
|
||||
stdin=None,
|
||||
stdout=None,
|
||||
stderr=None,
|
||||
preexec_fn=None,
|
||||
close_fds=True,
|
||||
shell=False,
|
||||
cwd=None,
|
||||
env=None,
|
||||
universal_newlines=None,
|
||||
startupinfo=None,
|
||||
creationflags=0,
|
||||
restore_signals=True,
|
||||
start_new_session=False,
|
||||
pass_fds=(),
|
||||
*,
|
||||
capture_output=False,
|
||||
group=None,
|
||||
extra_groups=None,
|
||||
user=None,
|
||||
umask=-1,
|
||||
encoding=None,
|
||||
errors=None,
|
||||
text=None,
|
||||
pipesize=-1,
|
||||
process_group=None,
|
||||
):
|
||||
"""Call subprocess.run without creating a console window."""
|
||||
if sys.platform == "win32":
|
||||
creationflags |= subprocess.CREATE_NO_WINDOW
|
||||
import ctypes
|
||||
|
||||
ctypes.windll.kernel32.SetDllDirectoryW(None)
|
||||
elif (
|
||||
sys.platform == "linux"
|
||||
or sys.platform.startswith("freebsd")
|
||||
or sys.platform.startswith("openbsd")
|
||||
):
|
||||
# pass clean environment to the subprocess
|
||||
env = os.environ
|
||||
original_env = env.get("LD_LIBRARY_PATH_ORIG")
|
||||
env["LD_LIBRARY_PATH"] = original_env if original_env else ""
|
||||
|
||||
return subprocess.run(
|
||||
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,
|
||||
startupinfo=startupinfo,
|
||||
creationflags=creationflags,
|
||||
restore_signals=restore_signals,
|
||||
start_new_session=start_new_session,
|
||||
pass_fds=pass_fds,
|
||||
capture_output=capture_output,
|
||||
group=group,
|
||||
extra_groups=extra_groups,
|
||||
user=user,
|
||||
umask=umask,
|
||||
encoding=encoding,
|
||||
errors=errors,
|
||||
text=text,
|
||||
pipesize=pipesize,
|
||||
process_group=process_group,
|
||||
)
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
|
||||
def wrap_line( # type: ignore
|
||||
def wrap_line(
|
||||
text: str,
|
||||
font: ImageFont.ImageFont,
|
||||
width: int = 256,
|
||||
draw: ImageDraw.ImageDraw = None,
|
||||
draw: ImageDraw.ImageDraw | None = None,
|
||||
) -> int:
|
||||
"""Take in a single text line and return the index it should be broken up at.
|
||||
|
||||
@@ -27,15 +27,14 @@ def wrap_line( # type: ignore
|
||||
):
|
||||
if draw.textlength(text[:i], font=font) < width:
|
||||
return i
|
||||
else:
|
||||
return -1
|
||||
return -1
|
||||
|
||||
|
||||
def wrap_full_text(
|
||||
text: str,
|
||||
font: ImageFont.ImageFont,
|
||||
width: int = 256,
|
||||
draw: ImageDraw.ImageDraw = None,
|
||||
draw: ImageDraw.ImageDraw | None = None,
|
||||
) -> str:
|
||||
"""Break up a string to fit the canvas given a kerning value, font size, etc."""
|
||||
lines = []
|
||||
|
||||
@@ -2,15 +2,16 @@
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Vendored from ffmpeg-python and ffmpeg-python PR#790 by amamic1803
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
from shutil import which
|
||||
|
||||
import ffmpeg
|
||||
import structlog
|
||||
|
||||
from tagstudio.qt.helpers.silent_popen import silent_Popen
|
||||
from tagstudio.qt.helpers.silent_popen import silent_Popen, silent_run
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -21,10 +22,12 @@ def _get_ffprobe_location() -> str:
|
||||
cmd: str = "ffprobe"
|
||||
if platform.system() == "Darwin":
|
||||
for loc in FFMPEG_MACOS_LOCATIONS:
|
||||
if shutil.which(loc + cmd):
|
||||
if which(loc + cmd):
|
||||
cmd = loc + cmd
|
||||
break
|
||||
logger.info(f"[FFMPEG] Using FFprobe location: {cmd}")
|
||||
logger.info(
|
||||
f"[FFmpeg] Using FFprobe location: {cmd}{' (Found)' if which(cmd) else ' (Not Found)'}"
|
||||
)
|
||||
return cmd
|
||||
|
||||
|
||||
@@ -32,10 +35,12 @@ def _get_ffmpeg_location() -> str:
|
||||
cmd: str = "ffmpeg"
|
||||
if platform.system() == "Darwin":
|
||||
for loc in FFMPEG_MACOS_LOCATIONS:
|
||||
if shutil.which(loc + cmd):
|
||||
if which(loc + cmd):
|
||||
cmd = loc + cmd
|
||||
break
|
||||
logger.info(f"[FFMPEG] Using FFmpeg location: {cmd}")
|
||||
logger.info(
|
||||
f"[FFmpeg] Using FFmpeg location: {cmd}{' (Found)' if which(cmd) else ' (Not Found)'}"
|
||||
)
|
||||
return cmd
|
||||
|
||||
|
||||
@@ -43,7 +48,7 @@ FFPROBE_CMD = _get_ffprobe_location()
|
||||
FFMPEG_CMD = _get_ffmpeg_location()
|
||||
|
||||
|
||||
def _probe(filename, cmd=FFPROBE_CMD, timeout=None, **kwargs):
|
||||
def probe(filename, cmd=FFPROBE_CMD, timeout=None, **kwargs):
|
||||
"""Run ffprobe on the specified file and return a JSON representation of the output.
|
||||
|
||||
Raises:
|
||||
@@ -65,3 +70,22 @@ def _probe(filename, cmd=FFPROBE_CMD, timeout=None, **kwargs):
|
||||
if p.returncode != 0:
|
||||
raise ffmpeg.Error("ffprobe", out, err)
|
||||
return json.loads(out.decode("utf-8"))
|
||||
|
||||
|
||||
def version():
|
||||
"""Checks the version of FFmpeg and FFprobe and returns None if they dont exist."""
|
||||
version: dict[str, str | None] = {"ffmpeg": None, "ffprobe": None}
|
||||
|
||||
if which(FFMPEG_CMD):
|
||||
ret = silent_run([FFMPEG_CMD, "-version"], shell=False, capture_output=True, text=True)
|
||||
if ret.returncode == 0:
|
||||
with contextlib.suppress(Exception):
|
||||
version["ffmpeg"] = str(ret.stdout).split(" ")[2]
|
||||
|
||||
if which(FFPROBE_CMD):
|
||||
ret = silent_run([FFPROBE_CMD, "-version"], shell=False, capture_output=True, text=True)
|
||||
if ret.returncode == 0:
|
||||
with contextlib.suppress(Exception):
|
||||
version["ffprobe"] = str(ret.stdout).split(" ")[2]
|
||||
|
||||
return version
|
||||
|
||||
@@ -21,7 +21,7 @@ from PySide6.QtWidgets import (
|
||||
from tagstudio.core.constants import VERSION, VERSION_BRANCH
|
||||
from tagstudio.core.enums import Theme
|
||||
from tagstudio.core.palette import ColorType, UiColor, get_ui_color
|
||||
from tagstudio.qt.modals.ffmpeg_checker import FfmpegChecker
|
||||
from tagstudio.qt.helpers.vendored import ffmpeg
|
||||
from tagstudio.qt.resource_manager import ResourceManager
|
||||
from tagstudio.qt.translations import Translations
|
||||
|
||||
@@ -31,14 +31,15 @@ class AboutModal(QWidget):
|
||||
super().__init__()
|
||||
self.setWindowTitle(Translations["about.title"])
|
||||
|
||||
self.fc: FfmpegChecker = FfmpegChecker()
|
||||
self.rm: ResourceManager = ResourceManager()
|
||||
|
||||
# TODO: There should be a global button theme somewhere.
|
||||
self.form_content_style = (
|
||||
f"background-color:{Theme.COLOR_BG.value
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else Theme.COLOR_BG_LIGHT.value};"
|
||||
f"background-color:{
|
||||
Theme.COLOR_BG.value
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else Theme.COLOR_BG_LIGHT.value
|
||||
};"
|
||||
"border-radius:3px;"
|
||||
"font-weight: 500;"
|
||||
"padding: 2px;"
|
||||
@@ -80,7 +81,7 @@ class AboutModal(QWidget):
|
||||
self.desc_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
|
||||
# System Info ----------------------------------------------------------
|
||||
ff_version = self.fc.version()
|
||||
ff_version = ffmpeg.version()
|
||||
red = get_ui_color(ColorType.PRIMARY, UiColor.RED)
|
||||
green = get_ui_color(ColorType.PRIMARY, UiColor.GREEN)
|
||||
missing = Translations["generic.missing"]
|
||||
@@ -103,14 +104,14 @@ class AboutModal(QWidget):
|
||||
self.system_info_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
|
||||
# License
|
||||
license_title = QLabel(f'{Translations["about.license"]}')
|
||||
license_title = QLabel(f"{Translations['about.license']}")
|
||||
license_content = QLabel("GPLv3")
|
||||
license_content.setStyleSheet(self.form_content_style)
|
||||
license_content.setMaximumWidth(license_content.sizeHint().width())
|
||||
self.system_info_layout.addRow(license_title, license_content)
|
||||
|
||||
# Config Path
|
||||
config_path_title = QLabel(f'{Translations["about.config_path"]}')
|
||||
config_path_title = QLabel(f"{Translations['about.config_path']}")
|
||||
config_path_content = QLabel(f"{config_path}")
|
||||
config_path_content.setStyleSheet(self.form_content_style)
|
||||
config_path_content.setWordWrap(True)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import contextlib
|
||||
import subprocess
|
||||
from shutil import which
|
||||
|
||||
import structlog
|
||||
@@ -7,7 +5,9 @@ from PySide6.QtCore import Qt, QUrl
|
||||
from PySide6.QtGui import QDesktopServices
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
from tagstudio.core.palette import ColorType, UiColor, get_ui_color
|
||||
from tagstudio.qt.helpers.vendored.ffmpeg import FFMPEG_CMD, FFPROBE_CMD
|
||||
from tagstudio.qt.translations import Translations
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -20,10 +20,11 @@ class FfmpegChecker(QMessageBox):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self.setWindowTitle("Warning: Missing dependency")
|
||||
self.setText("Warning: Could not find FFmpeg installation")
|
||||
ffmpeg = "FFmpeg"
|
||||
ffprobe = "FFprobe"
|
||||
title = Translations.format("dependency.missing.title", dependency=ffmpeg)
|
||||
self.setWindowTitle(title)
|
||||
self.setIcon(QMessageBox.Icon.Warning)
|
||||
# Blocks other application interactions until resolved
|
||||
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
|
||||
self.setStandardButtons(
|
||||
@@ -34,52 +35,19 @@ class FfmpegChecker(QMessageBox):
|
||||
self.setDefaultButton(QMessageBox.StandardButton.Ignore)
|
||||
# Enables the cancel button but hides it to allow for click X to close dialog
|
||||
self.button(QMessageBox.StandardButton.Cancel).hide()
|
||||
self.button(QMessageBox.StandardButton.Help).clicked.connect(
|
||||
lambda: QDesktopServices.openUrl(QUrl(self.HELP_URL))
|
||||
)
|
||||
|
||||
self.ffmpeg = False
|
||||
self.ffprobe = False
|
||||
|
||||
def installed(self):
|
||||
"""Checks if both FFmpeg and FFprobe are installed and in the PATH."""
|
||||
if which(FFMPEG_CMD):
|
||||
self.ffmpeg = True
|
||||
if which(FFPROBE_CMD):
|
||||
self.ffprobe = True
|
||||
|
||||
logger.info("FFmpeg found: {self.ffmpeg}, FFprobe found: {self.ffprobe}")
|
||||
return self.ffmpeg and self.ffprobe
|
||||
|
||||
def version(self):
|
||||
"""Checks the version of ffprobe and ffmpeg and returns None if they dont exist."""
|
||||
version: dict[str, str | None] = {"ffprobe": None, "ffmpeg": None}
|
||||
self.installed()
|
||||
if self.ffprobe:
|
||||
ret = subprocess.run(
|
||||
[FFPROBE_CMD, "-show_program_version"], shell=False, capture_output=True, text=True
|
||||
)
|
||||
if ret.returncode == 0:
|
||||
with contextlib.suppress(Exception):
|
||||
version["ffprobe"] = ret.stdout.split("\n")[1].replace("-", "=").split("=")[1]
|
||||
if self.ffmpeg:
|
||||
ret = subprocess.run(
|
||||
[FFMPEG_CMD, "-version"], shell=False, capture_output=True, text=True
|
||||
)
|
||||
if ret.returncode == 0:
|
||||
with contextlib.suppress(Exception):
|
||||
version["ffmpeg"] = ret.stdout.replace("-", " ").split(" ")[2]
|
||||
return version
|
||||
|
||||
def show_warning(self):
|
||||
"""Displays the warning to the user and awaits response."""
|
||||
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)
|
||||
if selection == QMessageBox.StandardButton.Help:
|
||||
QDesktopServices.openUrl(QUrl(self.HELP_URL))
|
||||
red = get_ui_color(ColorType.PRIMARY, UiColor.RED)
|
||||
green = get_ui_color(ColorType.PRIMARY, UiColor.GREEN)
|
||||
missing = f"<span style='color:{red}'>{Translations["generic.missing"]}</span>"
|
||||
found = f"<span style='color:{green}'>{Translations['about.module.found']}</span>"
|
||||
status = Translations.format(
|
||||
"ffmpeg.missing.status",
|
||||
ffmpeg=ffmpeg,
|
||||
ffmpeg_status=found if which(FFMPEG_CMD) else missing,
|
||||
ffprobe=ffprobe,
|
||||
ffprobe_status=found if which(FFPROBE_CMD) else missing,
|
||||
)
|
||||
self.setText(f"{Translations["ffmpeg.missing.description"]}<br><br>{status}")
|
||||
|
||||
@@ -3,70 +3,208 @@
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QComboBox, QFormLayout, QLabel, QVBoxLayout, QWidget
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from tagstudio.core.enums import SettingItems
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.widgets.panel import PanelWidget
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QFormLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QTabWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from tagstudio.core.enums import ShowFilepathOption
|
||||
from tagstudio.core.global_settings import Theme
|
||||
from tagstudio.qt.translations import DEFAULT_TRANSLATION, LANGUAGES, Translations
|
||||
from tagstudio.qt.widgets.panel import PanelModal, PanelWidget
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
|
||||
FILEPATH_OPTION_MAP: dict[ShowFilepathOption, str] = {
|
||||
ShowFilepathOption.SHOW_FULL_PATHS: Translations["settings.filepath.option.full"],
|
||||
ShowFilepathOption.SHOW_RELATIVE_PATHS: Translations["settings.filepath.option.relative"],
|
||||
ShowFilepathOption.SHOW_FILENAMES_ONLY: Translations["settings.filepath.option.name"],
|
||||
}
|
||||
|
||||
THEME_MAP: dict[Theme, str] = {
|
||||
Theme.DARK: Translations["settings.theme.dark"],
|
||||
Theme.LIGHT: Translations["settings.theme.light"],
|
||||
Theme.SYSTEM: Translations["settings.theme.system"],
|
||||
}
|
||||
|
||||
|
||||
class SettingsPanel(PanelWidget):
|
||||
def __init__(self, driver):
|
||||
driver: "QtDriver"
|
||||
|
||||
def __init__(self, driver: "QtDriver"):
|
||||
super().__init__()
|
||||
self.driver = driver
|
||||
self.setMinimumSize(320, 200)
|
||||
self.setMinimumSize(400, 300)
|
||||
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(6, 0, 6, 0)
|
||||
self.root_layout.setContentsMargins(0, 6, 0, 0)
|
||||
|
||||
self.form_container = QWidget()
|
||||
self.form_layout = QFormLayout(self.form_container)
|
||||
self.form_layout.setContentsMargins(0, 0, 0, 0)
|
||||
# Tabs
|
||||
self.tab_widget = QTabWidget()
|
||||
|
||||
self.__build_global_settings()
|
||||
self.tab_widget.addTab(self.global_settings_container, Translations["settings.global"])
|
||||
|
||||
# self.__build_library_settings()
|
||||
# self.tab_widget.addTab(self.library_settings_container, Translations["settings.library"])
|
||||
|
||||
self.root_layout.addWidget(self.tab_widget)
|
||||
|
||||
# Restart Label
|
||||
self.restart_label = QLabel(Translations["settings.restart_required"])
|
||||
self.restart_label.setHidden(True)
|
||||
self.restart_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
language_label = QLabel(Translations["settings.language"])
|
||||
self.languages = {
|
||||
# "Cantonese (Traditional)": "yue_Hant", # Empty
|
||||
"Chinese (Traditional)": "zh_Hant",
|
||||
# "Czech": "cs", # Minimal
|
||||
# "Danish": "da", # Minimal
|
||||
"Dutch": "nl",
|
||||
"English": "en",
|
||||
"Filipino": "fil",
|
||||
"French": "fr",
|
||||
"German": "de",
|
||||
"Hungarian": "hu",
|
||||
# "Italian": "it", # Minimal
|
||||
"Norwegian Bokmål": "nb_NO",
|
||||
"Polish": "pl",
|
||||
"Portuguese (Brazil)": "pt_BR",
|
||||
# "Portuguese (Portugal)": "pt", # Empty
|
||||
"Russian": "ru",
|
||||
"Spanish": "es",
|
||||
"Swedish": "sv",
|
||||
"Tamil": "ta",
|
||||
"Toki Pona": "tok",
|
||||
"Turkish": "tr",
|
||||
}
|
||||
self.language_combobox = QComboBox()
|
||||
self.language_combobox.addItems(list(self.languages.keys()))
|
||||
current_lang: str = str(
|
||||
driver.settings.value(SettingItems.LANGUAGE, defaultValue="en", type=str)
|
||||
)
|
||||
current_lang = "en" if current_lang not in self.languages.values() else current_lang
|
||||
self.language_combobox.setCurrentIndex(list(self.languages.values()).index(current_lang))
|
||||
self.language_combobox.currentIndexChanged.connect(
|
||||
lambda: self.restart_label.setHidden(False)
|
||||
)
|
||||
self.form_layout.addRow(language_label, self.language_combobox)
|
||||
|
||||
self.root_layout.addWidget(self.form_container)
|
||||
self.root_layout.addStretch(1)
|
||||
self.root_layout.addWidget(self.restart_label)
|
||||
|
||||
def get_language(self) -> str:
|
||||
values: list[str] = list(self.languages.values())
|
||||
return values[self.language_combobox.currentIndex()]
|
||||
self.__update_restart_label()
|
||||
|
||||
def __update_restart_label(self):
|
||||
show_label = (
|
||||
self.language_combobox.currentData() != Translations.current_language
|
||||
or self.theme_combobox.currentData() != self.driver.applied_theme
|
||||
)
|
||||
self.restart_label.setHidden(not show_label)
|
||||
|
||||
def __build_global_settings(self):
|
||||
self.global_settings_container = QWidget()
|
||||
form_layout = QFormLayout(self.global_settings_container)
|
||||
form_layout.setContentsMargins(6, 6, 6, 6)
|
||||
|
||||
# Language
|
||||
self.language_combobox = QComboBox()
|
||||
for k in LANGUAGES:
|
||||
self.language_combobox.addItem(k, LANGUAGES[k])
|
||||
current_lang: str = self.driver.settings.language
|
||||
if current_lang not in LANGUAGES.values():
|
||||
current_lang = DEFAULT_TRANSLATION
|
||||
self.language_combobox.setCurrentIndex(list(LANGUAGES.values()).index(current_lang))
|
||||
self.language_combobox.currentIndexChanged.connect(self.__update_restart_label)
|
||||
form_layout.addRow(Translations["settings.language"], self.language_combobox)
|
||||
|
||||
# Open Last Library on Start
|
||||
self.open_last_lib_checkbox = QCheckBox()
|
||||
self.open_last_lib_checkbox.setChecked(self.driver.settings.open_last_loaded_on_startup)
|
||||
form_layout.addRow(
|
||||
Translations["settings.open_library_on_start"], self.open_last_lib_checkbox
|
||||
)
|
||||
|
||||
# Autoplay
|
||||
self.autoplay_checkbox = QCheckBox()
|
||||
self.autoplay_checkbox.setChecked(self.driver.settings.autoplay)
|
||||
form_layout.addRow(Translations["media_player.autoplay"], self.autoplay_checkbox)
|
||||
|
||||
# Show Filenames in Grid
|
||||
self.show_filenames_checkbox = QCheckBox()
|
||||
self.show_filenames_checkbox.setChecked(self.driver.settings.show_filenames_in_grid)
|
||||
form_layout.addRow(
|
||||
Translations["settings.show_filenames_in_grid"], self.show_filenames_checkbox
|
||||
)
|
||||
|
||||
# Page Size
|
||||
self.page_size_line_edit = QLineEdit()
|
||||
self.page_size_line_edit.setText(str(self.driver.settings.page_size))
|
||||
|
||||
def on_page_size_changed():
|
||||
text = self.page_size_line_edit.text()
|
||||
if not text.isdigit() or int(text) < 1:
|
||||
self.page_size_line_edit.setText(str(self.driver.settings.page_size))
|
||||
|
||||
self.page_size_line_edit.editingFinished.connect(on_page_size_changed)
|
||||
form_layout.addRow(Translations["settings.page_size"], self.page_size_line_edit)
|
||||
|
||||
# Show Filepath
|
||||
self.filepath_combobox = QComboBox()
|
||||
for k in FILEPATH_OPTION_MAP:
|
||||
self.filepath_combobox.addItem(FILEPATH_OPTION_MAP[k], k)
|
||||
filepath_option: ShowFilepathOption = self.driver.settings.show_filepath
|
||||
if filepath_option not in FILEPATH_OPTION_MAP:
|
||||
filepath_option = ShowFilepathOption.DEFAULT
|
||||
self.filepath_combobox.setCurrentIndex(
|
||||
list(FILEPATH_OPTION_MAP.keys()).index(filepath_option)
|
||||
)
|
||||
form_layout.addRow(Translations["settings.filepath.label"], self.filepath_combobox)
|
||||
|
||||
# Dark Mode
|
||||
self.theme_combobox = QComboBox()
|
||||
for k in THEME_MAP:
|
||||
self.theme_combobox.addItem(THEME_MAP[k], k)
|
||||
theme: Theme = self.driver.settings.theme
|
||||
if theme not in THEME_MAP:
|
||||
theme = Theme.DEFAULT
|
||||
self.theme_combobox.setCurrentIndex(list(THEME_MAP.keys()).index(theme))
|
||||
self.theme_combobox.currentIndexChanged.connect(self.__update_restart_label)
|
||||
form_layout.addRow(Translations["settings.theme.label"], self.theme_combobox)
|
||||
|
||||
def __build_library_settings(self):
|
||||
self.library_settings_container = QWidget()
|
||||
form_layout = QFormLayout(self.library_settings_container)
|
||||
form_layout.setContentsMargins(6, 6, 6, 6)
|
||||
|
||||
todo_label = QLabel("TODO")
|
||||
form_layout.addRow(todo_label)
|
||||
|
||||
def __get_language(self) -> str:
|
||||
return list(LANGUAGES.values())[self.language_combobox.currentIndex()]
|
||||
|
||||
def get_settings(self) -> dict:
|
||||
return {
|
||||
"language": self.__get_language(),
|
||||
"open_last_loaded_on_startup": self.open_last_lib_checkbox.isChecked(),
|
||||
"autoplay": self.autoplay_checkbox.isChecked(),
|
||||
"show_filenames_in_grid": self.show_filenames_checkbox.isChecked(),
|
||||
"page_size": int(self.page_size_line_edit.text()),
|
||||
"show_filepath": self.filepath_combobox.currentData(),
|
||||
"theme": self.theme_combobox.currentData(),
|
||||
}
|
||||
|
||||
def update_settings(self, driver: "QtDriver"):
|
||||
settings = self.get_settings()
|
||||
|
||||
driver.settings.language = settings["language"]
|
||||
driver.settings.open_last_loaded_on_startup = settings["open_last_loaded_on_startup"]
|
||||
driver.settings.autoplay = settings["autoplay"]
|
||||
driver.settings.show_filenames_in_grid = settings["show_filenames_in_grid"]
|
||||
driver.settings.page_size = settings["page_size"]
|
||||
driver.settings.show_filepath = settings["show_filepath"]
|
||||
driver.settings.theme = settings["theme"]
|
||||
|
||||
driver.settings.save()
|
||||
|
||||
# Apply changes
|
||||
# Show File Path
|
||||
driver.update_recent_lib_menu()
|
||||
driver.preview_panel.update_widgets()
|
||||
library_directory = driver.lib.library_dir
|
||||
if settings["show_filepath"] == ShowFilepathOption.SHOW_FULL_PATHS:
|
||||
display_path = library_directory or ""
|
||||
else:
|
||||
display_path = library_directory.name if library_directory else ""
|
||||
driver.main_window.setWindowTitle(
|
||||
Translations.format("app.title", base_title=driver.base_title, library_dir=display_path)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def build_modal(cls, driver: "QtDriver") -> PanelModal:
|
||||
settings_panel = cls(driver)
|
||||
|
||||
modal = PanelModal(
|
||||
widget=settings_panel,
|
||||
done_callback=lambda: settings_panel.update_settings(driver),
|
||||
has_save=True,
|
||||
)
|
||||
modal.title_widget.setVisible(False)
|
||||
modal.setWindowTitle(Translations["settings.title"])
|
||||
|
||||
return modal
|
||||
|
||||
@@ -38,11 +38,13 @@ logger = structlog.get_logger(__name__)
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
if TYPE_CHECKING:
|
||||
from tagstudio.qt.modals.build_tag import BuildTagPanel
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
|
||||
|
||||
class TagSearchPanel(PanelWidget):
|
||||
tag_chosen = Signal(int)
|
||||
lib: Library
|
||||
driver: "QtDriver"
|
||||
is_initialized: bool = False
|
||||
first_tag_id: int | None = None
|
||||
is_tag_chooser: bool
|
||||
@@ -290,7 +292,9 @@ class TagSearchPanel(PanelWidget):
|
||||
tag_widget.search_for_tag_action.triggered.connect(
|
||||
lambda checked=False, tag_id=tag.id: (
|
||||
self.driver.main_window.searchField.setText(f"tag_id:{tag_id}"),
|
||||
self.driver.filter_items(FilterState.from_tag_id(tag_id)),
|
||||
self.driver.filter_items(
|
||||
FilterState.from_tag_id(tag_id, page_size=self.driver.settings.page_size)
|
||||
),
|
||||
)
|
||||
)
|
||||
tag_widget.search_for_tag_action.setEnabled(True)
|
||||
|
||||
@@ -43,6 +43,10 @@
|
||||
"path": "qt/images/file_icons/affinity_photo.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"archive": {
|
||||
"path": "qt/images/file_icons/archive.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"audio": {
|
||||
"path": "qt/images/file_icons/audio.png",
|
||||
"mode": "pil"
|
||||
@@ -51,10 +55,18 @@
|
||||
"path": "qt/images/file_icons/blender.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"database": {
|
||||
"path": "qt/images/file_icons/database.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"document": {
|
||||
"path": "qt/images/file_icons/document.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"ebook": {
|
||||
"path": "qt/images/file_icons/ebook.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"file_generic": {
|
||||
"path": "qt/images/file_icons/file_generic.png",
|
||||
"mode": "pil"
|
||||
@@ -87,6 +99,14 @@
|
||||
"path": "qt/images/file_icons/program.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"shader": {
|
||||
"path": "qt/images/file_icons/shader.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"shortcut": {
|
||||
"path": "qt/images/file_icons/shortcut.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"spreadsheet": {
|
||||
"path": "qt/images/file_icons/spreadsheet.png",
|
||||
"mode": "pil"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from platform import system
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
@@ -9,25 +10,59 @@ logger = structlog.get_logger(__name__)
|
||||
|
||||
DEFAULT_TRANSLATION = "en"
|
||||
|
||||
LANGUAGES = {
|
||||
# "Cantonese (Traditional)": "yue_Hant", # Empty
|
||||
"Chinese (Traditional)": "zh_Hant",
|
||||
# "Czech": "cs", # Minimal
|
||||
# "Danish": "da", # Minimal
|
||||
"Dutch": "nl",
|
||||
"English": "en",
|
||||
"Filipino": "fil",
|
||||
"French": "fr",
|
||||
"German": "de",
|
||||
"Hungarian": "hu",
|
||||
# "Italian": "it", # Minimal
|
||||
"Japanese": "ja",
|
||||
"Norwegian Bokmål": "nb_NO", # Minimal
|
||||
"Polish": "pl",
|
||||
"Portuguese (Brazil)": "pt_BR",
|
||||
# "Portuguese (Portugal)": "pt", # Empty
|
||||
"Russian": "ru",
|
||||
"Spanish": "es",
|
||||
"Swedish": "sv",
|
||||
"Tamil": "ta",
|
||||
"Toki Pona": "tok",
|
||||
"Turkish": "tr",
|
||||
# "Viossa": "qpv", # Minimal
|
||||
}
|
||||
|
||||
|
||||
class Translator:
|
||||
_default_strings: dict[str, str]
|
||||
_strings: dict[str, str] = {}
|
||||
_lang: str = DEFAULT_TRANSLATION
|
||||
__lang: str = DEFAULT_TRANSLATION
|
||||
|
||||
def __init__(self):
|
||||
self._default_strings = self.__get_translation_dict(DEFAULT_TRANSLATION)
|
||||
|
||||
def __get_translation_dict(self, lang: str) -> dict[str, str]:
|
||||
with open(
|
||||
Path(__file__).parents[1] / "resources" / "translations" / f"{lang}.json",
|
||||
encoding="utf-8",
|
||||
) as f:
|
||||
return ujson.loads(f.read())
|
||||
try:
|
||||
with open(
|
||||
Path(__file__).parents[1] / "resources" / "translations" / f"{lang}.json",
|
||||
encoding="utf-8",
|
||||
) as f:
|
||||
return ujson.loads(f.read())
|
||||
except FileNotFoundError:
|
||||
return self._default_strings
|
||||
|
||||
def change_language(self, lang: str):
|
||||
self._lang = lang
|
||||
self.__lang = lang
|
||||
self._strings = self.__get_translation_dict(lang)
|
||||
if system() == "Darwin":
|
||||
for k, v in self._strings.items():
|
||||
self._strings[k] = (
|
||||
v.replace("&&", "<ESC_AMP>").replace("&", "", 1).replace("<ESC_AMP>", "&&")
|
||||
)
|
||||
|
||||
def __format(self, text: str, **kwargs) -> str:
|
||||
try:
|
||||
@@ -37,7 +72,7 @@ class Translator:
|
||||
"[Translations] Error while formatting translation.",
|
||||
text=text,
|
||||
kwargs=kwargs,
|
||||
language=self._lang,
|
||||
language=self.__lang,
|
||||
)
|
||||
params: defaultdict[str, Any] = defaultdict(lambda: "{unknown_key}")
|
||||
params.update(kwargs)
|
||||
@@ -49,5 +84,9 @@ class Translator:
|
||||
def __getitem__(self, key: str) -> str:
|
||||
return self._strings.get(key) or self._default_strings.get(key) or f"[{key}]"
|
||||
|
||||
@property
|
||||
def current_language(self) -> str:
|
||||
return self.__lang
|
||||
|
||||
|
||||
Translations = Translator()
|
||||
|
||||
@@ -13,12 +13,14 @@ import ctypes
|
||||
import dataclasses
|
||||
import math
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from argparse import Namespace
|
||||
from pathlib import Path
|
||||
from platform import system
|
||||
from queue import Queue
|
||||
from shutil import which
|
||||
from warnings import catch_warnings
|
||||
|
||||
import structlog
|
||||
@@ -55,7 +57,8 @@ from PySide6.QtWidgets import (
|
||||
import tagstudio.qt.resources_rc # noqa: F401
|
||||
from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE, VERSION, VERSION_BRANCH
|
||||
from tagstudio.core.driver import DriverMixin
|
||||
from tagstudio.core.enums import LibraryPrefs, MacroID, SettingItems
|
||||
from tagstudio.core.enums import MacroID, SettingItems, ShowFilepathOption
|
||||
from tagstudio.core.global_settings import DEFAULT_GLOBAL_SETTINGS_PATH, GlobalSettings, Theme
|
||||
from tagstudio.core.library.alchemy.enums import (
|
||||
FieldTypeEnum,
|
||||
FilterState,
|
||||
@@ -76,6 +79,7 @@ from tagstudio.qt.flowlayout import FlowLayout
|
||||
from tagstudio.qt.helpers.custom_runnable import CustomRunnable
|
||||
from tagstudio.qt.helpers.file_deleter import delete_file
|
||||
from tagstudio.qt.helpers.function_iterator import FunctionIterator
|
||||
from tagstudio.qt.helpers.vendored.ffmpeg import FFMPEG_CMD, FFPROBE_CMD
|
||||
from tagstudio.qt.main_window import Ui_MainWindow
|
||||
from tagstudio.qt.modals.about import AboutModal
|
||||
from tagstudio.qt.modals.build_tag import BuildTagPanel
|
||||
@@ -98,7 +102,6 @@ from tagstudio.qt.widgets.migration_modal import JsonMigrationModal
|
||||
from tagstudio.qt.widgets.panel import PanelModal
|
||||
from tagstudio.qt.widgets.preview_panel import PreviewPanel
|
||||
from tagstudio.qt.widgets.progress import ProgressWidget
|
||||
from tagstudio.qt.widgets.thumb_button import ThumbButton
|
||||
from tagstudio.qt.widgets.thumb_renderer import ThumbRenderer
|
||||
|
||||
BADGE_TAGS = {
|
||||
@@ -141,25 +144,30 @@ class QtDriver(DriverMixin, QObject):
|
||||
|
||||
SIGTERM = Signal()
|
||||
|
||||
preview_panel: PreviewPanel | None = None
|
||||
preview_panel: PreviewPanel
|
||||
tag_manager_panel: PanelModal | None = None
|
||||
color_manager_panel: TagColorManager | None = None
|
||||
file_extension_panel: PanelModal | None = None
|
||||
tag_search_panel: TagSearchPanel | None = None
|
||||
add_tag_modal: PanelModal | None = None
|
||||
folders_modal: FoldersToTagsModal
|
||||
about_modal: AboutModal
|
||||
unlinked_modal: FixUnlinkedEntriesModal
|
||||
dupe_modal: FixDupeFilesModal
|
||||
applied_theme: Theme
|
||||
|
||||
lib: Library
|
||||
|
||||
def __init__(self, args):
|
||||
def __init__(self, args: Namespace):
|
||||
super().__init__()
|
||||
# prevent recursive badges update when multiple items selected
|
||||
self.badge_update_lock = False
|
||||
self.lib = Library()
|
||||
self.rm: ResourceManager = ResourceManager()
|
||||
self.args = args
|
||||
self.filter = FilterState.show_all()
|
||||
self.frame_content: list[int] = [] # List of Entry IDs on the current page
|
||||
self.pages_count = 0
|
||||
self.applied_theme = None
|
||||
|
||||
self.scrollbar_pos = 0
|
||||
self.thumb_size = 128
|
||||
@@ -176,35 +184,43 @@ class QtDriver(DriverMixin, QObject):
|
||||
|
||||
self.SIGTERM.connect(self.handle_sigterm)
|
||||
|
||||
self.config_path = ""
|
||||
if self.args.config_file:
|
||||
path = Path(self.args.config_file)
|
||||
if not path.exists():
|
||||
logger.warning("[Config] Config File does not exist creating", path=path)
|
||||
logger.info("[Config] Using Config File", path=path)
|
||||
self.settings = QSettings(str(path), QSettings.Format.IniFormat)
|
||||
self.config_path = str(path)
|
||||
self.global_settings_path = DEFAULT_GLOBAL_SETTINGS_PATH
|
||||
if self.args.settings_file:
|
||||
self.global_settings_path = Path(self.args.settings_file)
|
||||
else:
|
||||
self.settings = QSettings(
|
||||
logger.info("[Settings] Global Settings File Path not specified, using default")
|
||||
self.settings = GlobalSettings.read_settings(self.global_settings_path)
|
||||
if not self.global_settings_path.exists():
|
||||
logger.warning(
|
||||
"[Settings] Global Settings File does not exist creating",
|
||||
path=self.global_settings_path,
|
||||
)
|
||||
self.filter = FilterState.show_all(page_size=self.settings.page_size)
|
||||
|
||||
if self.args.cache_file:
|
||||
path = Path(self.args.cache_file)
|
||||
if not path.exists():
|
||||
logger.warning("[Cache] Cache File does not exist creating", path=path)
|
||||
logger.info("[Cache] Using Cache File", path=path)
|
||||
self.cached_values = QSettings(str(path), QSettings.Format.IniFormat)
|
||||
else:
|
||||
self.cached_values = QSettings(
|
||||
QSettings.Format.IniFormat,
|
||||
QSettings.Scope.UserScope,
|
||||
"TagStudio",
|
||||
"TagStudio",
|
||||
)
|
||||
logger.info(
|
||||
"[Config] Config File not specified, using default one",
|
||||
filename=self.settings.fileName(),
|
||||
"[Cache] Cache File not specified, using default one",
|
||||
filename=self.cached_values.fileName(),
|
||||
)
|
||||
self.config_path = self.settings.fileName()
|
||||
|
||||
Translations.change_language(
|
||||
str(self.settings.value(SettingItems.LANGUAGE, defaultValue="en", type=str))
|
||||
)
|
||||
Translations.change_language(self.settings.language)
|
||||
|
||||
# NOTE: This should be a per-library setting rather than an application setting.
|
||||
thumb_cache_size_limit: int = int(
|
||||
str(
|
||||
self.settings.value(
|
||||
self.cached_values.value(
|
||||
SettingItems.THUMB_CACHE_SIZE_LIMIT,
|
||||
defaultValue=CacheManager.size_limit,
|
||||
type=int,
|
||||
@@ -213,8 +229,8 @@ class QtDriver(DriverMixin, QObject):
|
||||
)
|
||||
|
||||
CacheManager.size_limit = thumb_cache_size_limit
|
||||
self.settings.setValue(SettingItems.THUMB_CACHE_SIZE_LIMIT, CacheManager.size_limit)
|
||||
self.settings.sync()
|
||||
self.cached_values.setValue(SettingItems.THUMB_CACHE_SIZE_LIMIT, CacheManager.size_limit)
|
||||
self.cached_values.sync()
|
||||
logger.info(
|
||||
f"[Config] Thumbnail cache size limit: {format_size(CacheManager.size_limit)}",
|
||||
)
|
||||
@@ -253,14 +269,24 @@ class QtDriver(DriverMixin, QObject):
|
||||
def start(self) -> None:
|
||||
"""Launch the main Qt window."""
|
||||
_ = QUiLoader()
|
||||
if os.name == "nt":
|
||||
|
||||
if self.settings.theme == Theme.SYSTEM and platform.system() == "Windows":
|
||||
sys.argv += ["-platform", "windows:darkmode=2"]
|
||||
self.app = QApplication(sys.argv)
|
||||
self.app.setStyle("Fusion")
|
||||
if self.settings.theme == Theme.SYSTEM:
|
||||
# TODO: detect theme instead of always setting dark
|
||||
self.app.styleHints().setColorScheme(Qt.ColorScheme.Dark)
|
||||
else:
|
||||
self.app.styleHints().setColorScheme(
|
||||
Qt.ColorScheme.Dark if self.settings.theme == Theme.DARK else Qt.ColorScheme.Light
|
||||
)
|
||||
self.applied_theme = self.settings.theme
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
app.setStyle("Fusion")
|
||||
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark:
|
||||
pal: QPalette = app.palette()
|
||||
if (
|
||||
platform.system() == "Darwin" or platform.system() == "Windows"
|
||||
) and QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark:
|
||||
pal: QPalette = self.app.palette()
|
||||
pal.setColor(QPalette.ColorGroup.Normal, QPalette.ColorRole.Window, QColor("#1e1e1e"))
|
||||
pal.setColor(QPalette.ColorGroup.Normal, QPalette.ColorRole.Button, QColor("#1e1e1e"))
|
||||
pal.setColor(QPalette.ColorGroup.Inactive, QPalette.ColorRole.Window, QColor("#232323"))
|
||||
@@ -269,7 +295,7 @@ class QtDriver(DriverMixin, QObject):
|
||||
QPalette.ColorGroup.Inactive, QPalette.ColorRole.ButtonText, QColor("#666666")
|
||||
)
|
||||
|
||||
app.setPalette(pal)
|
||||
self.app.setPalette(pal)
|
||||
|
||||
# Handle OS signals
|
||||
self.setup_signals()
|
||||
@@ -298,10 +324,15 @@ class QtDriver(DriverMixin, QObject):
|
||||
appid = "cyanvoxel.tagstudio.9"
|
||||
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(appid) # type: ignore[attr-defined,unused-ignore]
|
||||
|
||||
if sys.platform != "darwin":
|
||||
icon = QIcon()
|
||||
icon.addFile(str(self.rm.get_path("icon")))
|
||||
app.setWindowIcon(icon)
|
||||
self.app.setApplicationName("tagstudio")
|
||||
self.app.setApplicationDisplayName("TagStudio")
|
||||
if platform.system() != "Darwin":
|
||||
fallback_icon = QIcon()
|
||||
fallback_icon.addFile(str(self.rm.get_path("icon")))
|
||||
self.app.setWindowIcon(QIcon.fromTheme("tagstudio", fallback_icon))
|
||||
|
||||
if platform.system() != "Windows":
|
||||
self.app.setDesktopFileName("tagstudio")
|
||||
|
||||
# Initialize the Tag Manager panel
|
||||
self.tag_manager_panel = PanelModal(
|
||||
@@ -383,12 +414,13 @@ class QtDriver(DriverMixin, QObject):
|
||||
|
||||
open_on_start_action = QAction(Translations["settings.open_library_on_start"], self)
|
||||
open_on_start_action.setCheckable(True)
|
||||
open_on_start_action.setChecked(
|
||||
bool(self.settings.value(SettingItems.START_LOAD_LAST, defaultValue=True, type=bool))
|
||||
)
|
||||
open_on_start_action.triggered.connect(
|
||||
lambda checked: self.settings.setValue(SettingItems.START_LOAD_LAST, checked)
|
||||
)
|
||||
open_on_start_action.setChecked(self.settings.open_last_loaded_on_startup)
|
||||
|
||||
def set_open_last_loaded_on_startup(checked: bool):
|
||||
self.settings.open_last_loaded_on_startup = checked
|
||||
self.settings.save()
|
||||
|
||||
open_on_start_action.triggered.connect(set_open_last_loaded_on_startup)
|
||||
file_menu.addAction(open_on_start_action)
|
||||
|
||||
file_menu.addSeparator()
|
||||
@@ -408,21 +440,6 @@ class QtDriver(DriverMixin, QObject):
|
||||
file_menu.addAction(self.refresh_dir_action)
|
||||
file_menu.addSeparator()
|
||||
|
||||
self.open_selected_action = QAction(Translations["file.open_files.title.plural"], self)
|
||||
self.open_selected_action.triggered.connect(self.open_selected_files)
|
||||
if system() == "Darwin":
|
||||
shortcut: QtCore.QKeyCombination | Qt.Key = QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
|
||||
QtCore.Qt.Key.Key_Down,
|
||||
)
|
||||
else:
|
||||
shortcut = Qt.Key.Key_Return
|
||||
|
||||
self.open_selected_action.setShortcut(shortcut)
|
||||
self.open_selected_action.setEnabled(False)
|
||||
file_menu.addAction(self.open_selected_action)
|
||||
file_menu.addSeparator()
|
||||
|
||||
self.close_library_action = QAction(Translations["menu.file.close_library"], menu_bar)
|
||||
self.close_library_action.triggered.connect(self.close_library)
|
||||
self.close_library_action.setEnabled(False)
|
||||
@@ -543,23 +560,19 @@ class QtDriver(DriverMixin, QObject):
|
||||
edit_menu.addAction(self.color_manager_action)
|
||||
|
||||
# View Menu ============================================================
|
||||
show_libs_list_action = QAction(Translations["settings.show_recent_libraries"], menu_bar)
|
||||
show_libs_list_action.setCheckable(True)
|
||||
show_libs_list_action.setChecked(
|
||||
bool(self.settings.value(SettingItems.WINDOW_SHOW_LIBS, defaultValue=True, type=bool))
|
||||
)
|
||||
# show_libs_list_action = QAction(Translations["settings.show_recent_libraries"], menu_bar)
|
||||
# show_libs_list_action.setCheckable(True)
|
||||
# show_libs_list_action.setChecked(self.settings.show_library_list)
|
||||
|
||||
def on_show_filenames_action(checked: bool):
|
||||
self.settings.show_filenames_in_grid = checked
|
||||
self.settings.save()
|
||||
self.show_grid_filenames(checked)
|
||||
|
||||
show_filenames_action = QAction(Translations["settings.show_filenames_in_grid"], menu_bar)
|
||||
show_filenames_action.setCheckable(True)
|
||||
show_filenames_action.setChecked(
|
||||
bool(self.settings.value(SettingItems.SHOW_FILENAMES, defaultValue=True, type=bool))
|
||||
)
|
||||
show_filenames_action.triggered.connect(
|
||||
lambda checked: (
|
||||
self.settings.setValue(SettingItems.SHOW_FILENAMES, checked),
|
||||
self.show_grid_filenames(checked),
|
||||
)
|
||||
)
|
||||
show_filenames_action.setChecked(self.settings.show_filenames_in_grid)
|
||||
show_filenames_action.triggered.connect(on_show_filenames_action)
|
||||
view_menu.addAction(show_filenames_action)
|
||||
|
||||
# Tools Menu ===========================================================
|
||||
@@ -626,7 +639,7 @@ class QtDriver(DriverMixin, QObject):
|
||||
# Help Menu ============================================================
|
||||
def create_about_modal():
|
||||
if not hasattr(self, "about_modal"):
|
||||
self.about_modal = AboutModal(self.config_path)
|
||||
self.about_modal = AboutModal(self.global_settings_path)
|
||||
self.about_modal.show()
|
||||
|
||||
self.about_action = QAction(Translations["menu.help.about"], menu_bar)
|
||||
@@ -672,26 +685,24 @@ class QtDriver(DriverMixin, QObject):
|
||||
]
|
||||
self.item_thumbs: list[ItemThumb] = []
|
||||
self.thumb_renderers: list[ThumbRenderer] = []
|
||||
self.filter = FilterState.show_all()
|
||||
self.filter = FilterState.show_all(page_size=self.settings.page_size)
|
||||
self.init_library_window()
|
||||
self.migration_modal: JsonMigrationModal = None
|
||||
|
||||
path_result = self.evaluate_path(str(self.args.open).lstrip().rstrip())
|
||||
if path_result.success and path_result.library_path:
|
||||
self.open_library(path_result.library_path)
|
||||
elif self.settings.value(SettingItems.START_LOAD_LAST):
|
||||
elif self.settings.open_last_loaded_on_startup:
|
||||
# evaluate_path() with argument 'None' returns a LibraryStatus for the last library
|
||||
path_result = self.evaluate_path(None)
|
||||
if path_result.success and path_result.library_path:
|
||||
self.open_library(path_result.library_path)
|
||||
|
||||
# check ffmpeg and show warning if not
|
||||
# NOTE: Does this need to use self?
|
||||
self.ffmpeg_checker = FfmpegChecker()
|
||||
if not self.ffmpeg_checker.installed():
|
||||
self.ffmpeg_checker.show_warning()
|
||||
# Check if FFmpeg or FFprobe are missing and show warning if so
|
||||
if not which(FFMPEG_CMD) or not which(FFPROBE_CMD):
|
||||
FfmpegChecker().show()
|
||||
|
||||
app.exec()
|
||||
self.app.exec()
|
||||
self.shutdown()
|
||||
|
||||
def show_error_message(self, error_name: str, error_desc: str | None = None):
|
||||
@@ -721,7 +732,9 @@ class QtDriver(DriverMixin, QObject):
|
||||
def _filter_items():
|
||||
try:
|
||||
self.filter_items(
|
||||
FilterState.from_search_query(self.main_window.searchField.text())
|
||||
FilterState.from_search_query(
|
||||
self.main_window.searchField.text(), page_size=self.settings.page_size
|
||||
)
|
||||
.with_sorting_mode(self.sorting_mode)
|
||||
.with_sorting_direction(self.sorting_direction)
|
||||
)
|
||||
@@ -835,15 +848,15 @@ class QtDriver(DriverMixin, QObject):
|
||||
self.main_window.statusbar.showMessage(Translations["status.library_closing"])
|
||||
start_time = time.time()
|
||||
|
||||
self.settings.setValue(SettingItems.LAST_LIBRARY, str(self.lib.library_dir))
|
||||
self.settings.sync()
|
||||
self.cached_values.setValue(SettingItems.LAST_LIBRARY, str(self.lib.library_dir))
|
||||
self.cached_values.sync()
|
||||
|
||||
# Reset library state
|
||||
self.preview_panel.update_widgets()
|
||||
self.main_window.searchField.setText("")
|
||||
scrollbar: QScrollArea = self.main_window.scrollArea
|
||||
scrollbar.verticalScrollBar().setValue(0)
|
||||
self.filter = FilterState.show_all()
|
||||
self.filter = FilterState.show_all(page_size=self.settings.page_size)
|
||||
|
||||
self.lib.close()
|
||||
|
||||
@@ -934,7 +947,7 @@ class QtDriver(DriverMixin, QObject):
|
||||
"""Set the selection to all visible items."""
|
||||
self.selected.clear()
|
||||
for item in self.item_thumbs:
|
||||
if item.mode and item.item_id not in self.selected:
|
||||
if item.mode and item.item_id not in self.selected and not item.isHidden():
|
||||
self.selected.append(item.item_id)
|
||||
item.thumb_button.set_selected(True)
|
||||
|
||||
@@ -1309,30 +1322,33 @@ class QtDriver(DriverMixin, QObject):
|
||||
self.frame_content[grid_idx] = None
|
||||
self.item_thumbs[grid_idx].hide()
|
||||
|
||||
def _update_thumb_count(self):
|
||||
missing_count = max(0, self.filter.page_size - len(self.item_thumbs))
|
||||
layout = self.flow_container.layout()
|
||||
for _ in range(missing_count):
|
||||
item_thumb = ItemThumb(
|
||||
None,
|
||||
self.lib,
|
||||
self,
|
||||
(self.thumb_size, self.thumb_size),
|
||||
self.settings.show_filenames_in_grid,
|
||||
)
|
||||
|
||||
layout.addWidget(item_thumb)
|
||||
self.item_thumbs.append(item_thumb)
|
||||
|
||||
def _init_thumb_grid(self):
|
||||
layout = FlowLayout()
|
||||
layout.enable_grid_optimizations(value=True)
|
||||
layout.setSpacing(min(self.thumb_size // 10, 12))
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
# TODO - init after library is loaded, it can have different page_size
|
||||
for _ in range(self.filter.page_size):
|
||||
item_thumb = ItemThumb(
|
||||
None,
|
||||
self.lib,
|
||||
self,
|
||||
(self.thumb_size, self.thumb_size),
|
||||
bool(
|
||||
self.settings.value(SettingItems.SHOW_FILENAMES, defaultValue=True, type=bool)
|
||||
),
|
||||
)
|
||||
|
||||
layout.addWidget(item_thumb)
|
||||
self.item_thumbs.append(item_thumb)
|
||||
|
||||
self.flow_container: QWidget = QWidget()
|
||||
self.flow_container.setObjectName("flowContainer")
|
||||
self.flow_container.setLayout(layout)
|
||||
|
||||
self._update_thumb_count()
|
||||
|
||||
sa: QScrollArea = self.main_window.scrollArea
|
||||
sa.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
sa.setWidgetResizable(True)
|
||||
@@ -1443,40 +1459,6 @@ class QtDriver(DriverMixin, QObject):
|
||||
|
||||
self.preview_panel.update_widgets()
|
||||
|
||||
def open_selected_files(self):
|
||||
if not (
|
||||
QApplication.focusWidget() == self.main_window.scrollArea
|
||||
or isinstance(QApplication.focusWidget(), ThumbButton)
|
||||
):
|
||||
return
|
||||
count = len(self.selected)
|
||||
result = QMessageBox.ButtonRole.ActionRole
|
||||
|
||||
if count >= 5: # Only confirm if we have lots of files
|
||||
confirm_open = QMessageBox()
|
||||
confirm_open.setText(Translations.format("file.open_files.warning", count=count))
|
||||
confirm_open.setWindowTitle(Translations["file.open_files.title"])
|
||||
confirm_open.setIcon(QMessageBox.Icon.Question)
|
||||
|
||||
cancel_button = confirm_open.addButton(
|
||||
Translations["generic.cancel_alt"], QMessageBox.ButtonRole.RejectRole
|
||||
)
|
||||
confirm_open.setEscapeButton(cancel_button)
|
||||
|
||||
open_button = confirm_open.addButton(
|
||||
Translations["generic.open"], QMessageBox.ButtonRole.ActionRole
|
||||
)
|
||||
confirm_open.setDefaultButton(open_button)
|
||||
|
||||
result = QMessageBox.ButtonRole(confirm_open.exec())
|
||||
|
||||
if result == QMessageBox.ButtonRole.ActionRole:
|
||||
opened = []
|
||||
for it in self.item_thumbs:
|
||||
if it.item_id in self.selected and it.item_id not in opened:
|
||||
it.opener.open_file()
|
||||
opened.append(it.item_id)
|
||||
|
||||
def set_macro_menu_viability(self):
|
||||
# self.autofill_action.setDisabled(not self.selected)
|
||||
pass
|
||||
@@ -1504,20 +1486,11 @@ class QtDriver(DriverMixin, QObject):
|
||||
self.add_tag_to_selected_action.setEnabled(True)
|
||||
self.clear_select_action.setEnabled(True)
|
||||
self.delete_file_action.setEnabled(True)
|
||||
|
||||
self.open_selected_action.setEnabled(True)
|
||||
if len(self.selected) == 1:
|
||||
self.open_selected_action.setText(Translations["file.open_files.title.singular"])
|
||||
else:
|
||||
self.open_selected_action.setText(Translations["file.open_files.title.plural"])
|
||||
else:
|
||||
self.add_tag_to_selected_action.setEnabled(False)
|
||||
self.clear_select_action.setEnabled(False)
|
||||
self.delete_file_action.setEnabled(False)
|
||||
|
||||
self.open_selected_action.setEnabled(False)
|
||||
self.open_selected_action.setText(Translations["file.open_files.title.plural"])
|
||||
|
||||
def update_completions_list(self, text: str) -> None:
|
||||
matches = re.search(
|
||||
r"((?:.* )?)(mediatype|filetype|path|tag|tag_id):(\"?[A-Za-z0-9\ \t]+\"?)?", text
|
||||
@@ -1590,6 +1563,7 @@ class QtDriver(DriverMixin, QObject):
|
||||
|
||||
def update_thumbs(self):
|
||||
"""Update search thumbnails."""
|
||||
self._update_thumb_count()
|
||||
# start_time = time.time()
|
||||
# logger.info(f'Current Page: {self.cur_page_idx}, Stack Length:{len(self.nav_stack)}')
|
||||
with self.thumb_job_queue.mutex:
|
||||
@@ -1773,22 +1747,22 @@ class QtDriver(DriverMixin, QObject):
|
||||
)
|
||||
|
||||
def remove_recent_library(self, item_key: str):
|
||||
self.settings.beginGroup(SettingItems.LIBS_LIST)
|
||||
self.settings.remove(item_key)
|
||||
self.settings.endGroup()
|
||||
self.settings.sync()
|
||||
self.cached_values.beginGroup(SettingItems.LIBS_LIST)
|
||||
self.cached_values.remove(item_key)
|
||||
self.cached_values.endGroup()
|
||||
self.cached_values.sync()
|
||||
|
||||
def update_libs_list(self, path: Path | str):
|
||||
"""Add library to list in SettingItems.LIBS_LIST."""
|
||||
item_limit: int = 5
|
||||
item_limit: int = 10
|
||||
path = Path(path)
|
||||
|
||||
self.settings.beginGroup(SettingItems.LIBS_LIST)
|
||||
self.cached_values.beginGroup(SettingItems.LIBS_LIST)
|
||||
|
||||
all_libs = {str(time.time()): str(path)}
|
||||
|
||||
for item_key in self.settings.allKeys():
|
||||
item_path = str(self.settings.value(item_key, type=str))
|
||||
for item_key in self.cached_values.allKeys():
|
||||
item_path = str(self.cached_values.value(item_key, type=str))
|
||||
if Path(item_path) != path:
|
||||
all_libs[item_key] = item_path
|
||||
|
||||
@@ -1796,13 +1770,13 @@ class QtDriver(DriverMixin, QObject):
|
||||
all_libs_list = sorted(all_libs.items(), key=lambda item: item[0], reverse=True)
|
||||
|
||||
# remove previously saved items
|
||||
self.settings.remove("")
|
||||
self.cached_values.remove("")
|
||||
|
||||
for item_key, item_value in all_libs_list[:item_limit]:
|
||||
self.settings.setValue(item_key, item_value)
|
||||
self.cached_values.setValue(item_key, item_value)
|
||||
|
||||
self.settings.endGroup()
|
||||
self.settings.sync()
|
||||
self.cached_values.endGroup()
|
||||
self.cached_values.sync()
|
||||
self.update_recent_lib_menu()
|
||||
|
||||
def update_recent_lib_menu(self):
|
||||
@@ -1810,7 +1784,7 @@ class QtDriver(DriverMixin, QObject):
|
||||
actions: list[QAction] = []
|
||||
lib_items: dict[str, tuple[str, str]] = {}
|
||||
|
||||
settings = self.settings
|
||||
settings = self.cached_values
|
||||
settings.beginGroup(SettingItems.LIBS_LIST)
|
||||
for item_tstamp in settings.allKeys():
|
||||
val = str(settings.value(item_tstamp, type=str))
|
||||
@@ -1827,7 +1801,10 @@ class QtDriver(DriverMixin, QObject):
|
||||
for library_key in libs_sorted:
|
||||
path = Path(library_key[1][0])
|
||||
action = QAction(self.open_recent_library_menu)
|
||||
action.setText(str(path))
|
||||
if self.settings.show_filepath == ShowFilepathOption.SHOW_FULL_PATHS:
|
||||
action.setText(str(path))
|
||||
else:
|
||||
action.setText(str(path.name))
|
||||
action.triggered.connect(lambda checked=False, p=path: self.open_library(p))
|
||||
actions.append(action)
|
||||
|
||||
@@ -1855,34 +1832,22 @@ class QtDriver(DriverMixin, QObject):
|
||||
|
||||
def clear_recent_libs(self):
|
||||
"""Clear the list of recent libraries from the settings file."""
|
||||
settings = self.settings
|
||||
settings = self.cached_values
|
||||
settings.beginGroup(SettingItems.LIBS_LIST)
|
||||
self.settings.remove("")
|
||||
self.settings.endGroup()
|
||||
self.settings.sync()
|
||||
self.cached_values.remove("")
|
||||
self.cached_values.endGroup()
|
||||
self.cached_values.sync()
|
||||
self.update_recent_lib_menu()
|
||||
|
||||
def open_settings_modal(self):
|
||||
# TODO: Implement a proper settings panel, and don't re-create it each time it's opened.
|
||||
settings_panel = SettingsPanel(self)
|
||||
modal = PanelModal(
|
||||
widget=settings_panel,
|
||||
done_callback=lambda: self.update_language_settings(settings_panel.get_language()),
|
||||
has_save=False,
|
||||
)
|
||||
modal.setTitle(Translations["settings.title"])
|
||||
modal.setWindowTitle(Translations["settings.title"])
|
||||
modal.show()
|
||||
|
||||
def update_language_settings(self, language: str):
|
||||
Translations.change_language(language)
|
||||
|
||||
self.settings.setValue(SettingItems.LANGUAGE, language)
|
||||
self.settings.sync()
|
||||
SettingsPanel.build_modal(self).show()
|
||||
|
||||
def open_library(self, path: Path) -> None:
|
||||
"""Open a TagStudio library."""
|
||||
message = Translations.format("splash.opening_library", library_path=str(path))
|
||||
library_dir_display = (
|
||||
path if self.settings.show_filepath == ShowFilepathOption.SHOW_FULL_PATHS else path.name
|
||||
)
|
||||
message = Translations.format("splash.opening_library", library_path=library_dir_display)
|
||||
self.main_window.landing_widget.set_status_label(message)
|
||||
self.main_window.statusbar.showMessage(message, 3)
|
||||
self.main_window.repaint()
|
||||
@@ -1893,8 +1858,18 @@ class QtDriver(DriverMixin, QObject):
|
||||
open_status: LibraryStatus | None = None
|
||||
try:
|
||||
open_status = self.lib.open_library(path)
|
||||
except ValueError as e:
|
||||
logger.warning(e)
|
||||
open_status = LibraryStatus(
|
||||
success=False,
|
||||
library_path=path,
|
||||
message=Translations["menu.file.missing_library.title"],
|
||||
msg_description=Translations.format(
|
||||
"menu.file.missing_library.message", library=library_dir_display
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
logger.error(e)
|
||||
open_status = LibraryStatus(
|
||||
success=False, library_path=path, message=type(e).__name__, msg_description=str(e)
|
||||
)
|
||||
@@ -1921,18 +1896,23 @@ class QtDriver(DriverMixin, QObject):
|
||||
|
||||
self.init_workers()
|
||||
|
||||
self.filter.page_size = self.lib.prefs(LibraryPrefs.PAGE_SIZE)
|
||||
self.filter.page_size = self.settings.page_size
|
||||
|
||||
# TODO - make this call optional
|
||||
if self.lib.entries_count < 10000:
|
||||
self.add_new_files_callback()
|
||||
|
||||
if self.settings.show_filepath == ShowFilepathOption.SHOW_FULL_PATHS:
|
||||
library_dir_display = self.lib.library_dir
|
||||
else:
|
||||
library_dir_display = self.lib.library_dir.name
|
||||
|
||||
self.update_libs_list(path)
|
||||
self.main_window.setWindowTitle(
|
||||
Translations.format(
|
||||
"app.title",
|
||||
base_title=self.base_title,
|
||||
library_dir=self.lib.library_dir,
|
||||
library_dir=library_dir_display,
|
||||
)
|
||||
)
|
||||
self.main_window.setAcceptDrops(True)
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>MainWindow</class>
|
||||
<widget class="QMainWindow" name="MainWindow">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>1280</width>
|
||||
<height>720</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>MainWindow</string>
|
||||
</property>
|
||||
<widget class="QWidget" name="centralwidget">
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="5" column="0">
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_3">
|
||||
<item>
|
||||
<spacer name="horizontalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QComboBox" name="comboBox_2">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>165</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>And (includes all tags)</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>Or (includes any tag)</string>
|
||||
</property>
|
||||
</item>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QComboBox" name="comboBox">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>128</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>256</width>
|
||||
<height>32</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="currentText">
|
||||
<string/>
|
||||
</property>
|
||||
<property name="placeholderText">
|
||||
<string>Thumbnail Size</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item row="9" column="0">
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<widget class="QScrollArea" name="scrollArea">
|
||||
<property name="focusPolicy">
|
||||
<enum>Qt::WheelFocus</enum>
|
||||
</property>
|
||||
<property name="frameShape">
|
||||
<enum>QFrame::NoFrame</enum>
|
||||
</property>
|
||||
<property name="frameShadow">
|
||||
<enum>QFrame::Plain</enum>
|
||||
</property>
|
||||
<property name="widgetResizable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<widget class="QWidget" name="scrollAreaWidgetContents">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>1260</width>
|
||||
<height>585</height>
|
||||
</rect>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_2">
|
||||
<property name="leftMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>8</number>
|
||||
</property>
|
||||
<property name="spacing">
|
||||
<number>8</number>
|
||||
</property>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item row="3" column="0">
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_2">
|
||||
<property name="sizeConstraint">
|
||||
<enum>QLayout::SetMinimumSize</enum>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QPushButton" name="backButton">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>32</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>32</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="font">
|
||||
<font>
|
||||
<pointsize>14</pointsize>
|
||||
<bold>true</bold>
|
||||
</font>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string><</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="forwardButton">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>32</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>32</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="font">
|
||||
<font>
|
||||
<pointsize>14</pointsize>
|
||||
<bold>true</bold>
|
||||
<kerning>true</kerning>
|
||||
</font>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>></string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLineEdit" name="searchField">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>32</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="font">
|
||||
<font>
|
||||
<pointsize>11</pointsize>
|
||||
<bold>false</bold>
|
||||
</font>
|
||||
</property>
|
||||
<property name="placeholderText">
|
||||
<string>Search Entries</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="searchButton">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>32</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="font">
|
||||
<font>
|
||||
<pointsize>11</pointsize>
|
||||
<bold>false</bold>
|
||||
</font>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Search</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QMenuBar" name="menubar">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>1280</width>
|
||||
<height>21</height>
|
||||
</rect>
|
||||
</property>
|
||||
</widget>
|
||||
<widget class="QStatusBar" name="statusbar">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Maximum">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
</widget>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
@@ -1,160 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
################################################################################
|
||||
## Form generated from reading UI file 'home.ui'
|
||||
##
|
||||
## Created by: Qt User Interface Compiler version 6.6.3
|
||||
##
|
||||
## WARNING! All changes made in this file will be lost when recompiling UI file!
|
||||
################################################################################
|
||||
|
||||
from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
|
||||
QMetaObject, QObject, QPoint, QRect,
|
||||
QSize, QTime, QUrl, Qt)
|
||||
from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor,
|
||||
QFont, QFontDatabase, QGradient, QIcon,
|
||||
QImage, QKeySequence, QLinearGradient, QPainter,
|
||||
QPalette, QPixmap, QRadialGradient, QTransform)
|
||||
from PySide6.QtWidgets import (QApplication, QComboBox, QFrame, QGridLayout,
|
||||
QHBoxLayout, QLayout, QLineEdit, QMainWindow,
|
||||
QMenuBar, QPushButton, QScrollArea, QSizePolicy,
|
||||
QSpacerItem, QStatusBar, QWidget)
|
||||
|
||||
class Ui_MainWindow(object):
|
||||
def setupUi(self, MainWindow):
|
||||
if not MainWindow.objectName():
|
||||
MainWindow.setObjectName(u"MainWindow")
|
||||
MainWindow.resize(1280, 720)
|
||||
self.centralwidget = QWidget(MainWindow)
|
||||
self.centralwidget.setObjectName(u"centralwidget")
|
||||
self.gridLayout = QGridLayout(self.centralwidget)
|
||||
self.gridLayout.setObjectName(u"gridLayout")
|
||||
self.horizontalLayout_3 = QHBoxLayout()
|
||||
self.horizontalLayout_3.setObjectName(u"horizontalLayout_3")
|
||||
self.horizontalSpacer = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
|
||||
|
||||
self.horizontalLayout_3.addItem(self.horizontalSpacer)
|
||||
|
||||
self.comboBox_2 = QComboBox(self.centralwidget)
|
||||
self.comboBox_2.addItem("")
|
||||
self.comboBox_2.addItem("")
|
||||
self.comboBox_2.setObjectName(u"comboBox_2")
|
||||
self.comboBox_2.setMinimumSize(QSize(165, 0))
|
||||
|
||||
self.horizontalLayout_3.addWidget(self.comboBox_2)
|
||||
|
||||
self.comboBox = QComboBox(self.centralwidget)
|
||||
self.comboBox.setObjectName(u"comboBox")
|
||||
sizePolicy = QSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(self.comboBox.sizePolicy().hasHeightForWidth())
|
||||
self.comboBox.setSizePolicy(sizePolicy)
|
||||
self.comboBox.setMinimumSize(QSize(128, 0))
|
||||
self.comboBox.setMaximumSize(QSize(256, 32))
|
||||
|
||||
self.horizontalLayout_3.addWidget(self.comboBox)
|
||||
|
||||
|
||||
self.gridLayout.addLayout(self.horizontalLayout_3, 5, 0, 1, 1)
|
||||
|
||||
self.horizontalLayout = QHBoxLayout()
|
||||
self.horizontalLayout.setObjectName(u"horizontalLayout")
|
||||
self.scrollArea = QScrollArea(self.centralwidget)
|
||||
self.scrollArea.setObjectName(u"scrollArea")
|
||||
self.scrollArea.setFocusPolicy(Qt.WheelFocus)
|
||||
self.scrollArea.setFrameShape(QFrame.NoFrame)
|
||||
self.scrollArea.setFrameShadow(QFrame.Plain)
|
||||
self.scrollArea.setWidgetResizable(True)
|
||||
self.scrollAreaWidgetContents = QWidget()
|
||||
self.scrollAreaWidgetContents.setObjectName(u"scrollAreaWidgetContents")
|
||||
self.scrollAreaWidgetContents.setGeometry(QRect(0, 0, 1260, 585))
|
||||
self.gridLayout_2 = QGridLayout(self.scrollAreaWidgetContents)
|
||||
self.gridLayout_2.setSpacing(8)
|
||||
self.gridLayout_2.setObjectName(u"gridLayout_2")
|
||||
self.gridLayout_2.setContentsMargins(0, 0, 0, 8)
|
||||
self.scrollArea.setWidget(self.scrollAreaWidgetContents)
|
||||
|
||||
self.horizontalLayout.addWidget(self.scrollArea)
|
||||
|
||||
|
||||
self.gridLayout.addLayout(self.horizontalLayout, 9, 0, 1, 1)
|
||||
|
||||
self.horizontalLayout_2 = QHBoxLayout()
|
||||
self.horizontalLayout_2.setObjectName(u"horizontalLayout_2")
|
||||
self.horizontalLayout_2.setSizeConstraint(QLayout.SetMinimumSize)
|
||||
self.backButton = QPushButton(self.centralwidget)
|
||||
self.backButton.setObjectName(u"backButton")
|
||||
self.backButton.setMinimumSize(QSize(0, 32))
|
||||
self.backButton.setMaximumSize(QSize(32, 16777215))
|
||||
font = QFont()
|
||||
font.setPointSize(14)
|
||||
font.setBold(True)
|
||||
self.backButton.setFont(font)
|
||||
|
||||
self.horizontalLayout_2.addWidget(self.backButton)
|
||||
|
||||
self.forwardButton = QPushButton(self.centralwidget)
|
||||
self.forwardButton.setObjectName(u"forwardButton")
|
||||
self.forwardButton.setMinimumSize(QSize(0, 32))
|
||||
self.forwardButton.setMaximumSize(QSize(32, 16777215))
|
||||
font1 = QFont()
|
||||
font1.setPointSize(14)
|
||||
font1.setBold(True)
|
||||
font1.setKerning(True)
|
||||
self.forwardButton.setFont(font1)
|
||||
|
||||
self.horizontalLayout_2.addWidget(self.forwardButton)
|
||||
|
||||
self.searchField = QLineEdit(self.centralwidget)
|
||||
self.searchField.setObjectName(u"searchField")
|
||||
self.searchField.setMinimumSize(QSize(0, 32))
|
||||
font2 = QFont()
|
||||
font2.setPointSize(11)
|
||||
font2.setBold(False)
|
||||
self.searchField.setFont(font2)
|
||||
|
||||
self.horizontalLayout_2.addWidget(self.searchField)
|
||||
|
||||
self.searchButton = QPushButton(self.centralwidget)
|
||||
self.searchButton.setObjectName(u"searchButton")
|
||||
self.searchButton.setMinimumSize(QSize(0, 32))
|
||||
self.searchButton.setFont(font2)
|
||||
|
||||
self.horizontalLayout_2.addWidget(self.searchButton)
|
||||
|
||||
|
||||
self.gridLayout.addLayout(self.horizontalLayout_2, 3, 0, 1, 1)
|
||||
|
||||
MainWindow.setCentralWidget(self.centralwidget)
|
||||
self.menubar = QMenuBar(MainWindow)
|
||||
self.menubar.setObjectName(u"menubar")
|
||||
self.menubar.setGeometry(QRect(0, 0, 1280, 21))
|
||||
MainWindow.setMenuBar(self.menubar)
|
||||
self.statusbar = QStatusBar(MainWindow)
|
||||
self.statusbar.setObjectName(u"statusbar")
|
||||
sizePolicy1 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
|
||||
sizePolicy1.setHorizontalStretch(0)
|
||||
sizePolicy1.setVerticalStretch(0)
|
||||
sizePolicy1.setHeightForWidth(self.statusbar.sizePolicy().hasHeightForWidth())
|
||||
self.statusbar.setSizePolicy(sizePolicy1)
|
||||
MainWindow.setStatusBar(self.statusbar)
|
||||
|
||||
self.retranslateUi(MainWindow)
|
||||
|
||||
QMetaObject.connectSlotsByName(MainWindow)
|
||||
# setupUi
|
||||
|
||||
def retranslateUi(self, MainWindow):
|
||||
MainWindow.setWindowTitle(QCoreApplication.translate("MainWindow", u"MainWindow", None))
|
||||
self.comboBox_2.setItemText(0, QCoreApplication.translate("MainWindow", u"And (includes all tags)", None))
|
||||
self.comboBox_2.setItemText(1, QCoreApplication.translate("MainWindow", u"Or (includes any tag)", None))
|
||||
|
||||
self.comboBox.setCurrentText("")
|
||||
self.comboBox.setPlaceholderText(QCoreApplication.translate("MainWindow", u"Thumbnail Size", None))
|
||||
self.backButton.setText(QCoreApplication.translate("MainWindow", u"<", None))
|
||||
self.forwardButton.setText(QCoreApplication.translate("MainWindow", u">", None))
|
||||
self.searchField.setPlaceholderText(QCoreApplication.translate("MainWindow", u"Search Entries", None))
|
||||
self.searchButton.setText(QCoreApplication.translate("MainWindow", u"Search", None))
|
||||
# retranslateUi
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
from typing import Callable, override
|
||||
from warnings import catch_warnings
|
||||
|
||||
import structlog
|
||||
@@ -54,9 +54,9 @@ class FieldContainer(QWidget):
|
||||
self.setObjectName("fieldContainer")
|
||||
self.title: str = title
|
||||
self.inline: bool = inline
|
||||
self.copy_callback: Callable = None
|
||||
self.edit_callback: Callable = None
|
||||
self.remove_callback: Callable = None
|
||||
self.copy_callback: Callable[[], None] | None = None
|
||||
self.edit_callback: Callable[[], None] | None = None
|
||||
self.remove_callback: Callable[[], None] | None = None
|
||||
button_size = 24
|
||||
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
@@ -129,7 +129,7 @@ class FieldContainer(QWidget):
|
||||
self.set_title(title)
|
||||
self.setStyleSheet(FieldContainer.container_style)
|
||||
|
||||
def set_copy_callback(self, callback: Callable | None = None):
|
||||
def set_copy_callback(self, callback: Callable[[], None] | None = None) -> None:
|
||||
with catch_warnings(record=True):
|
||||
self.copy_button.clicked.disconnect()
|
||||
|
||||
@@ -137,7 +137,7 @@ class FieldContainer(QWidget):
|
||||
if callback:
|
||||
self.copy_button.clicked.connect(callback)
|
||||
|
||||
def set_edit_callback(self, callback: Callable | None = None):
|
||||
def set_edit_callback(self, callback: Callable[[], None] | None = None) -> None:
|
||||
with catch_warnings(record=True):
|
||||
self.edit_button.clicked.disconnect()
|
||||
|
||||
@@ -145,7 +145,7 @@ class FieldContainer(QWidget):
|
||||
if callback:
|
||||
self.edit_button.clicked.connect(callback)
|
||||
|
||||
def set_remove_callback(self, callback: Callable | None = None):
|
||||
def set_remove_callback(self, callback: Callable[[], None] | None = None) -> None:
|
||||
with catch_warnings(record=True):
|
||||
self.remove_button.clicked.disconnect()
|
||||
|
||||
@@ -153,7 +153,7 @@ class FieldContainer(QWidget):
|
||||
if callback:
|
||||
self.remove_button.clicked.connect(callback)
|
||||
|
||||
def set_inner_widget(self, widget: "FieldWidget"):
|
||||
def set_inner_widget(self, widget: "FieldWidget") -> None:
|
||||
if self.field_layout.itemAt(0):
|
||||
old: QWidget = self.field_layout.itemAt(0).widget()
|
||||
self.field_layout.removeWidget(old)
|
||||
@@ -161,19 +161,20 @@ class FieldContainer(QWidget):
|
||||
|
||||
self.field_layout.addWidget(widget)
|
||||
|
||||
def get_inner_widget(self):
|
||||
def get_inner_widget(self) -> QWidget | None:
|
||||
if self.field_layout.itemAt(0):
|
||||
return self.field_layout.itemAt(0).widget()
|
||||
return None
|
||||
|
||||
def set_title(self, title: str):
|
||||
def set_title(self, title: str) -> None:
|
||||
self.title = self.title = f"<h4>{title}</h4>"
|
||||
self.title_widget.setText(self.title)
|
||||
|
||||
def set_inline(self, inline: bool):
|
||||
def set_inline(self, inline: bool) -> None:
|
||||
self.inline = inline
|
||||
|
||||
def enterEvent(self, event: QEnterEvent) -> None: # noqa: N802
|
||||
@override
|
||||
def enterEvent(self, event: QEnterEvent) -> None:
|
||||
# NOTE: You could pass the hover event to the FieldWidget if needed.
|
||||
if self.copy_callback:
|
||||
self.copy_button.setHidden(False)
|
||||
@@ -183,7 +184,8 @@ class FieldContainer(QWidget):
|
||||
self.remove_button.setHidden(False)
|
||||
return super().enterEvent(event)
|
||||
|
||||
def leaveEvent(self, event: QEvent) -> None: # noqa: N802
|
||||
@override
|
||||
def leaveEvent(self, event: QEvent) -> None:
|
||||
if self.copy_callback:
|
||||
self.copy_button.setHidden(True)
|
||||
if self.edit_callback:
|
||||
@@ -192,12 +194,13 @@ class FieldContainer(QWidget):
|
||||
self.remove_button.setHidden(True)
|
||||
return super().leaveEvent(event)
|
||||
|
||||
def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802
|
||||
@override
|
||||
def resizeEvent(self, event: QResizeEvent) -> None:
|
||||
self.title_widget.setFixedWidth(int(event.size().width() // 1.5))
|
||||
return super().resizeEvent(event)
|
||||
|
||||
|
||||
class FieldWidget(QWidget):
|
||||
def __init__(self, title) -> None:
|
||||
def __init__(self, title: str) -> None:
|
||||
super().__init__()
|
||||
self.title = title
|
||||
self.title: str = title
|
||||
|
||||
@@ -116,7 +116,7 @@ class ItemThumb(FlowWidget):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mode: ItemType,
|
||||
mode: ItemType | None,
|
||||
library: Library,
|
||||
driver: "QtDriver",
|
||||
thumb_size: tuple[int, int],
|
||||
@@ -124,7 +124,7 @@ class ItemThumb(FlowWidget):
|
||||
):
|
||||
super().__init__()
|
||||
self.lib = library
|
||||
self.mode: ItemType = mode
|
||||
self.mode: ItemType | None = mode
|
||||
self.driver = driver
|
||||
self.item_id: int | None = None
|
||||
self.thumb_size: tuple[int, int] = thumb_size
|
||||
@@ -230,7 +230,6 @@ class ItemThumb(FlowWidget):
|
||||
self.thumb_button.addAction(open_file_action)
|
||||
self.thumb_button.addAction(open_explorer_action)
|
||||
self.thumb_button.addAction(self.delete_action)
|
||||
self.thumb_button.double_clicked.connect(self.opener.open_file)
|
||||
|
||||
# Static Badges ========================================================
|
||||
|
||||
|
||||
@@ -2,41 +2,148 @@
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
import logging
|
||||
import typing
|
||||
from pathlib import Path
|
||||
from time import gmtime
|
||||
from typing import Any
|
||||
from typing import override
|
||||
|
||||
from PySide6.QtCore import Qt, QUrl
|
||||
from PySide6.QtGui import QIcon, QPixmap
|
||||
from PIL import Image, ImageDraw
|
||||
from PySide6.QtCore import QEvent, QObject, QRectF, QSize, Qt, QUrl, QVariantAnimation
|
||||
from PySide6.QtGui import QAction, QBitmap, QBrush, QColor, QPen, QRegion, QResizeEvent
|
||||
from PySide6.QtMultimedia import QAudioOutput, QMediaDevices, QMediaPlayer
|
||||
from PySide6.QtMultimediaWidgets import QGraphicsVideoItem
|
||||
from PySide6.QtSvgWidgets import QSvgWidget
|
||||
from PySide6.QtWidgets import (
|
||||
QGraphicsScene,
|
||||
QGraphicsView,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSlider,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from tagstudio.qt.helpers.qslider_wrapper import QClickSlider
|
||||
from tagstudio.qt.translations import Translations
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
|
||||
|
||||
class MediaPlayer(QWidget):
|
||||
class MediaPlayer(QGraphicsView):
|
||||
"""A basic media player widget.
|
||||
|
||||
Gives a basic control set to manage media playback.
|
||||
"""
|
||||
|
||||
video_preview: "VideoPreview | None" = None
|
||||
|
||||
def __init__(self, driver: "QtDriver") -> None:
|
||||
super().__init__()
|
||||
self.driver = driver
|
||||
|
||||
self.setFixedHeight(50)
|
||||
slider_style = """
|
||||
QSlider {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
QSlider::groove:horizontal {
|
||||
border: 1px solid #999999;
|
||||
height: 2px;
|
||||
margin: 2px 0;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
QSlider::handle:horizontal {
|
||||
background: #6ea0ff;
|
||||
border: 1px solid #5c5c5c;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin: -6px 0;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
QSlider::add-page:horizontal {
|
||||
background: #3f4144;
|
||||
height: 2px;
|
||||
margin: 2px 0;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
QSlider::sub-page:horizontal {
|
||||
background: #6ea0ff;
|
||||
height: 2px;
|
||||
margin: 2px 0;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
QSlider::groove:vertical {
|
||||
border: 1px solid #999999;
|
||||
width: 2px;
|
||||
margin: 0 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
QSlider::handle:vertical {
|
||||
background: #6ea0ff;
|
||||
border: 1px solid #5c5c5c;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin: 0 -6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
QSlider::add-page:vertical {
|
||||
background: #6ea0ff;
|
||||
width: 2px;
|
||||
margin: 0 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
QSlider::sup-page:vertical {
|
||||
background: #3f4144;
|
||||
width: 2px;
|
||||
margin: 0 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
"""
|
||||
|
||||
# setup the scene
|
||||
self.installEventFilter(self)
|
||||
self.setScene(QGraphicsScene(self))
|
||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.setStyleSheet("""
|
||||
QGraphicsView {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
""")
|
||||
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
self.video_preview = VideoPreview()
|
||||
self.video_preview.setAcceptHoverEvents(True)
|
||||
self.video_preview.setAcceptedMouseButtons(Qt.MouseButton.RightButton)
|
||||
self.video_preview.setAcceptedMouseButtons(Qt.MouseButton.LeftButton)
|
||||
self.video_preview.installEventFilter(self)
|
||||
|
||||
# animation
|
||||
self.animation = QVariantAnimation(self)
|
||||
self.animation.valueChanged.connect(lambda value: self.set_tint_opacity(value))
|
||||
|
||||
# Set up the tint.
|
||||
self.tint = self.scene().addRect(
|
||||
0,
|
||||
0,
|
||||
self.size().width(),
|
||||
self.size().height(),
|
||||
QPen(QColor(0, 0, 0, 0)),
|
||||
QBrush(QColor(0, 0, 0, 0)),
|
||||
)
|
||||
|
||||
# setup the player
|
||||
self.filepath: Path | None = None
|
||||
self.player = QMediaPlayer()
|
||||
self.player.setAudioOutput(QAudioOutput(QMediaDevices().defaultAudioOutput(), self.player))
|
||||
@@ -52,58 +159,206 @@ class MediaPlayer(QWidget):
|
||||
self.player.positionChanged.connect(self.player_position_changed)
|
||||
self.player.mediaStatusChanged.connect(self.media_status_changed)
|
||||
self.player.playingChanged.connect(self.playing_changed)
|
||||
self.player.hasVideoChanged.connect(self.has_video_changed)
|
||||
self.player.audioOutput().mutedChanged.connect(self.muted_changed)
|
||||
|
||||
# Media controls
|
||||
self.base_layout = QGridLayout(self)
|
||||
self.base_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.base_layout.setSpacing(0)
|
||||
self.master_controls = QWidget()
|
||||
master_layout = QGridLayout(self.master_controls)
|
||||
master_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.master_controls.setStyleSheet("background: transparent;")
|
||||
self.master_controls.setMinimumHeight(75)
|
||||
|
||||
self.pslider = QSlider(self)
|
||||
self.pslider = QClickSlider()
|
||||
self.pslider.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.pslider.setTickPosition(QSlider.TickPosition.NoTicks)
|
||||
self.pslider.setSingleStep(1)
|
||||
self.pslider.setOrientation(Qt.Orientation.Horizontal)
|
||||
|
||||
self.pslider.setStyleSheet(slider_style)
|
||||
self.pslider.sliderReleased.connect(self.slider_released)
|
||||
self.pslider.valueChanged.connect(self.slider_value_changed)
|
||||
self.pslider.hide()
|
||||
|
||||
self.media_btns_layout = QHBoxLayout()
|
||||
master_layout.addWidget(self.pslider, 0, 0, 0, 2)
|
||||
master_layout.setAlignment(self.pslider, Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
policy = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||
fixed_policy = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||
|
||||
self.play_pause = QPushButton("", self)
|
||||
self.play_pause.setFlat(True)
|
||||
self.play_pause.setSizePolicy(policy)
|
||||
self.play_pause.clicked.connect(self.toggle_pause)
|
||||
self.sub_controls = QWidget()
|
||||
self.sub_controls.setMouseTracking(True)
|
||||
self.sub_controls.installEventFilter(self)
|
||||
sub_layout = QHBoxLayout(self.sub_controls)
|
||||
sub_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.sub_controls.setStyleSheet("background: transparent;")
|
||||
|
||||
self.load_play_pause_icon(playing=False)
|
||||
self.play_pause = QSvgWidget()
|
||||
self.play_pause.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.play_pause.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, on=True)
|
||||
self.play_pause.setMouseTracking(True)
|
||||
self.play_pause.installEventFilter(self)
|
||||
self.load_toggle_play_icon(playing=False)
|
||||
self.play_pause.resize(16, 16)
|
||||
self.play_pause.setSizePolicy(fixed_policy)
|
||||
self.play_pause.setStyleSheet("background: transparent;")
|
||||
self.play_pause.hide()
|
||||
|
||||
self.media_btns_layout.addWidget(self.play_pause)
|
||||
|
||||
self.mute = QPushButton("", self)
|
||||
self.mute.setFlat(True)
|
||||
self.mute.setSizePolicy(policy)
|
||||
self.mute.clicked.connect(self.toggle_mute)
|
||||
sub_layout.addWidget(self.play_pause)
|
||||
sub_layout.setAlignment(self.play_pause, Qt.AlignmentFlag.AlignLeft)
|
||||
|
||||
self.mute_unmute = QSvgWidget()
|
||||
self.mute_unmute.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.mute_unmute.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, on=True)
|
||||
self.mute_unmute.setMouseTracking(True)
|
||||
self.mute_unmute.installEventFilter(self)
|
||||
self.load_mute_unmute_icon(muted=False)
|
||||
self.mute_unmute.resize(16, 16)
|
||||
self.mute_unmute.setSizePolicy(fixed_policy)
|
||||
self.mute_unmute.hide()
|
||||
|
||||
self.media_btns_layout.addWidget(self.mute)
|
||||
sub_layout.addWidget(self.mute_unmute)
|
||||
sub_layout.setAlignment(self.mute_unmute, Qt.AlignmentFlag.AlignLeft)
|
||||
|
||||
self.volume_slider = QSlider()
|
||||
retain_policy = QSizePolicy()
|
||||
retain_policy.setRetainSizeWhenHidden(True)
|
||||
|
||||
self.volume_slider = QClickSlider()
|
||||
self.volume_slider.setOrientation(Qt.Orientation.Horizontal)
|
||||
# set slider value to current volume
|
||||
self.volume_slider.setValue(int(self.player.audioOutput().volume() * 100))
|
||||
self.volume_slider.valueChanged.connect(self.volume_slider_changed)
|
||||
self.volume_slider.setStyleSheet(slider_style)
|
||||
self.volume_slider.setSizePolicy(retain_policy)
|
||||
self.volume_slider.hide()
|
||||
|
||||
self.media_btns_layout.addWidget(self.volume_slider)
|
||||
sub_layout.addWidget(self.volume_slider)
|
||||
sub_layout.setAlignment(self.volume_slider, Qt.AlignmentFlag.AlignLeft)
|
||||
|
||||
# Adding a stretch here ensures the rest of the widgets
|
||||
# in the sub_layout will not stretch to fill the remaining
|
||||
# space.
|
||||
sub_layout.addStretch()
|
||||
|
||||
master_layout.addWidget(self.sub_controls, 1, 0)
|
||||
|
||||
self.position_label = QLabel("0:00")
|
||||
self.position_label.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
self.position_label.setStyleSheet("color: #ffffff;")
|
||||
master_layout.addWidget(self.position_label, 1, 1)
|
||||
master_layout.setAlignment(self.position_label, Qt.AlignmentFlag.AlignRight)
|
||||
self.position_label.hide()
|
||||
|
||||
self.base_layout.addWidget(self.pslider, 0, 0, 1, 2)
|
||||
self.base_layout.addLayout(self.media_btns_layout, 1, 0)
|
||||
self.base_layout.addWidget(self.position_label, 1, 1)
|
||||
self.scene().addWidget(self.master_controls)
|
||||
|
||||
self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
|
||||
autoplay_action = QAction(Translations["media_player.autoplay"], self)
|
||||
autoplay_action.setCheckable(True)
|
||||
self.addAction(autoplay_action)
|
||||
autoplay_action.setChecked(self.driver.settings.autoplay)
|
||||
autoplay_action.triggered.connect(lambda: self.toggle_autoplay())
|
||||
self.autoplay = autoplay_action
|
||||
|
||||
loop_action = QAction(Translations["media_player.loop"], self)
|
||||
loop_action.setCheckable(True)
|
||||
self.addAction(loop_action)
|
||||
loop_action.setChecked(self.driver.settings.loop)
|
||||
loop_action.triggered.connect(lambda: self.toggle_loop())
|
||||
self.loop = loop_action
|
||||
self.toggle_loop()
|
||||
|
||||
# start the player muted
|
||||
self.player.audioOutput().setMuted(True)
|
||||
|
||||
def set_video_output(self, video: QGraphicsVideoItem):
|
||||
self.player.setVideoOutput(video)
|
||||
|
||||
def toggle_autoplay(self) -> None:
|
||||
"""Toggle the autoplay state of the video."""
|
||||
self.driver.settings.autoplay = self.autoplay.isChecked()
|
||||
self.driver.settings.save()
|
||||
|
||||
def toggle_loop(self) -> None:
|
||||
self.driver.settings.loop = self.loop.isChecked()
|
||||
self.driver.settings.save()
|
||||
|
||||
self.player.setLoops(-1 if self.driver.settings.loop else 1)
|
||||
|
||||
def apply_rounded_corners(self) -> None:
|
||||
"""Apply a rounded corner effect to the video player."""
|
||||
width: int = int(max(self.contentsRect().size().width(), 0))
|
||||
height: int = int(max(self.contentsRect().size().height(), 0))
|
||||
mask = Image.new(
|
||||
"RGBA",
|
||||
(
|
||||
width,
|
||||
height,
|
||||
),
|
||||
(0, 0, 0, 255),
|
||||
)
|
||||
draw = ImageDraw.Draw(mask)
|
||||
draw.rounded_rectangle(
|
||||
(0, 0) + (width, height),
|
||||
radius=8,
|
||||
fill=(0, 0, 0, 0),
|
||||
)
|
||||
final_mask = mask.getchannel("A").toqpixmap()
|
||||
self.setMask(QRegion(QBitmap(final_mask)))
|
||||
|
||||
def set_tint_opacity(self, opacity: int) -> None:
|
||||
"""Set the opacity of the video player's tint.
|
||||
|
||||
Args:
|
||||
opacity(int): The opacity value, from 0-255.
|
||||
"""
|
||||
self.tint.setBrush(QBrush(QColor(0, 0, 0, opacity)))
|
||||
|
||||
def underMouse(self) -> bool: # noqa: N802
|
||||
self.animation.setStartValue(self.tint.brush().color().alpha())
|
||||
self.animation.setEndValue(100)
|
||||
self.animation.setDuration(250)
|
||||
self.animation.start()
|
||||
self.pslider.show()
|
||||
self.play_pause.show()
|
||||
self.mute_unmute.show()
|
||||
self.position_label.show()
|
||||
|
||||
return super().underMouse()
|
||||
|
||||
def releaseMouse(self) -> None: # noqa: N802
|
||||
self.animation.setStartValue(self.tint.brush().color().alpha())
|
||||
self.animation.setEndValue(0)
|
||||
self.animation.setDuration(500)
|
||||
self.animation.start()
|
||||
self.pslider.hide()
|
||||
self.play_pause.hide()
|
||||
self.mute_unmute.hide()
|
||||
self.volume_slider.hide()
|
||||
self.position_label.hide()
|
||||
|
||||
return super().releaseMouse()
|
||||
|
||||
@override
|
||||
def eventFilter(self, arg__1: QObject, arg__2: QEvent) -> bool:
|
||||
"""Manage events for the media player."""
|
||||
if (
|
||||
arg__2.type() == QEvent.Type.MouseButtonPress
|
||||
and arg__2.button() == Qt.MouseButton.LeftButton # type: ignore
|
||||
):
|
||||
if arg__1 == self.play_pause:
|
||||
self.toggle_play()
|
||||
elif arg__1 == self.mute_unmute:
|
||||
self.toggle_mute()
|
||||
else:
|
||||
self.toggle_play()
|
||||
elif arg__2.type() is QEvent.Type.Enter:
|
||||
if arg__1 == self or arg__1 == self.video_preview:
|
||||
self.underMouse()
|
||||
elif arg__1 == self.mute_unmute:
|
||||
self.volume_slider.show()
|
||||
elif arg__2.type() == QEvent.Type.Leave:
|
||||
if arg__1 == self or arg__1 == self.video_preview:
|
||||
self.releaseMouse()
|
||||
elif arg__1 == self.sub_controls:
|
||||
self.volume_slider.hide()
|
||||
|
||||
return super().eventFilter(arg__1, arg__2)
|
||||
|
||||
def format_time(self, ms: int) -> str:
|
||||
"""Format the given time.
|
||||
@@ -128,8 +383,8 @@ class MediaPlayer(QWidget):
|
||||
else f"{time.tm_min}:{time.tm_sec:02}"
|
||||
)
|
||||
|
||||
def toggle_pause(self) -> None:
|
||||
"""Toggle the pause state of the media."""
|
||||
def toggle_play(self) -> None:
|
||||
"""Toggle the playing state of the media."""
|
||||
if self.player.isPlaying():
|
||||
self.player.pause()
|
||||
self.is_paused = True
|
||||
@@ -145,11 +400,21 @@ class MediaPlayer(QWidget):
|
||||
self.player.audioOutput().setMuted(True)
|
||||
|
||||
def playing_changed(self, playing: bool) -> None:
|
||||
self.load_play_pause_icon(playing)
|
||||
self.load_toggle_play_icon(playing)
|
||||
|
||||
def muted_changed(self, muted: bool) -> None:
|
||||
self.load_mute_unmute_icon(muted)
|
||||
|
||||
def has_video_changed(self, video_available: bool) -> None:
|
||||
if not self.video_preview:
|
||||
return
|
||||
if video_available:
|
||||
self.scene().addItem(self.video_preview)
|
||||
self.video_preview.setZValue(-1)
|
||||
self.player.setVideoOutput(self.video_preview)
|
||||
else:
|
||||
self.scene().removeItem(self.video_preview)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Clear the filepath and stop the player."""
|
||||
self.filepath = None
|
||||
@@ -161,24 +426,19 @@ class MediaPlayer(QWidget):
|
||||
if not self.is_paused:
|
||||
self.player.stop()
|
||||
self.player.setSource(QUrl.fromLocalFile(self.filepath))
|
||||
self.player.play()
|
||||
|
||||
if self.autoplay.isChecked():
|
||||
self.player.play()
|
||||
else:
|
||||
self.player.setSource(QUrl.fromLocalFile(self.filepath))
|
||||
|
||||
def load_play_pause_icon(self, playing: bool) -> None:
|
||||
def load_toggle_play_icon(self, playing: bool) -> None:
|
||||
icon = self.driver.rm.pause_icon if playing else self.driver.rm.play_icon
|
||||
self.set_icon(self.play_pause, icon)
|
||||
self.play_pause.load(icon)
|
||||
|
||||
def load_mute_unmute_icon(self, muted: bool) -> None:
|
||||
icon = self.driver.rm.volume_mute_icon if muted else self.driver.rm.volume_icon
|
||||
self.set_icon(self.mute, icon)
|
||||
|
||||
def set_icon(self, btn: QPushButton, icon: Any) -> None:
|
||||
pix_map = QPixmap()
|
||||
if pix_map.loadFromData(icon):
|
||||
btn.setIcon(QIcon(pix_map))
|
||||
else:
|
||||
logging.error("failed to load svg file")
|
||||
self.mute_unmute.load(icon)
|
||||
|
||||
def slider_value_changed(self, value: int) -> None:
|
||||
current = self.format_time(value)
|
||||
@@ -202,10 +462,6 @@ class MediaPlayer(QWidget):
|
||||
duration = self.format_time(self.player.duration())
|
||||
self.position_label.setText(f"{current} / {duration}")
|
||||
|
||||
if self.player.duration() == position:
|
||||
self.player.pause()
|
||||
self.player.setPosition(0)
|
||||
|
||||
def media_status_changed(self, status: QMediaPlayer.MediaStatus) -> None:
|
||||
# We can only set the slider duration once we know the size of the media
|
||||
if status == QMediaPlayer.MediaStatus.LoadedMedia and self.filepath is not None:
|
||||
@@ -216,5 +472,54 @@ class MediaPlayer(QWidget):
|
||||
duration = self.format_time(self.player.duration())
|
||||
self.position_label.setText(f"{current} / {duration}")
|
||||
|
||||
def _update_controls(self, size: QSize) -> None:
|
||||
self.scene().setSceneRect(0, 0, size.width(), size.height())
|
||||
|
||||
# occupy entire scene width
|
||||
self.master_controls.setMinimumWidth(size.width())
|
||||
self.master_controls.setMaximumWidth(size.width())
|
||||
|
||||
self.master_controls.move(0, int(self.scene().height() - self.master_controls.height()))
|
||||
|
||||
ps_w = self.master_controls.width() - 5
|
||||
self.pslider.setMinimumWidth(ps_w)
|
||||
self.pslider.setMaximumWidth(ps_w)
|
||||
|
||||
# Changing the orientation of the volume slider to
|
||||
# make it easier to use in smaller sizes.
|
||||
orientation = self.volume_slider.orientation()
|
||||
if size.width() <= 175 and orientation is Qt.Orientation.Horizontal:
|
||||
self.volume_slider.setOrientation(Qt.Orientation.Vertical)
|
||||
self.volume_slider.setMaximumHeight(30)
|
||||
elif size.width() > 175 and orientation is Qt.Orientation.Vertical:
|
||||
self.volume_slider.setOrientation(Qt.Orientation.Horizontal)
|
||||
|
||||
if self.video_preview:
|
||||
self.video_preview.setSize(self.size())
|
||||
if self.player.hasVideo():
|
||||
self.centerOn(self.video_preview)
|
||||
|
||||
self.tint.setRect(0, 0, self.size().width(), self.size().height())
|
||||
self.apply_rounded_corners()
|
||||
|
||||
@override
|
||||
def resizeEvent(self, event: QResizeEvent) -> None:
|
||||
self._update_controls(event.size())
|
||||
|
||||
def volume_slider_changed(self, position: int) -> None:
|
||||
self.player.audioOutput().setVolume(position / 100)
|
||||
|
||||
|
||||
class VideoPreview(QGraphicsVideoItem):
|
||||
@override
|
||||
def boundingRect(self):
|
||||
return QRectF(0, 0, self.size().width(), self.size().height())
|
||||
|
||||
@override
|
||||
def paint(self, painter, option, widget=None) -> None:
|
||||
# painter.brush().setColor(QColor(0, 0, 0, 255))
|
||||
# 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,7 +17,7 @@ from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QGuiApplication
|
||||
from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget
|
||||
|
||||
from tagstudio.core.enums import Theme
|
||||
from tagstudio.core.enums import ShowFilepathOption, Theme
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.media_types import MediaCategories
|
||||
from tagstudio.qt.helpers.file_opener import FileOpenerHelper, FileOpenerLabel
|
||||
@@ -96,6 +96,8 @@ class FileAttributes(QWidget):
|
||||
root_layout.addWidget(self.file_label)
|
||||
root_layout.addWidget(self.date_container)
|
||||
root_layout.addWidget(self.dimensions_label)
|
||||
self.library = library
|
||||
self.driver = driver
|
||||
|
||||
def update_date_label(self, filepath: Path | None = None) -> None:
|
||||
"""Update the "Date Created" and "Date Modified" file property labels."""
|
||||
@@ -142,6 +144,15 @@ class FileAttributes(QWidget):
|
||||
self.dimensions_label.setText("")
|
||||
self.dimensions_label.setHidden(True)
|
||||
else:
|
||||
self.library_path = self.library.library_dir
|
||||
display_path = filepath
|
||||
if self.driver.settings.show_filepath == ShowFilepathOption.SHOW_FULL_PATHS:
|
||||
display_path = filepath
|
||||
elif self.driver.settings.show_filepath == ShowFilepathOption.SHOW_RELATIVE_PATHS:
|
||||
display_path = Path(filepath).relative_to(self.library_path)
|
||||
elif self.driver.settings.show_filepath == ShowFilepathOption.SHOW_FILENAMES_ONLY:
|
||||
display_path = Path(filepath.name)
|
||||
|
||||
self.layout().setSpacing(6)
|
||||
self.file_label.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
self.file_label.set_file_path(filepath)
|
||||
@@ -149,12 +160,14 @@ class FileAttributes(QWidget):
|
||||
|
||||
file_str: str = ""
|
||||
separator: str = f"<a style='color: #777777'><b>{os.path.sep}</a>" # Gray
|
||||
for i, part in enumerate(filepath.parts):
|
||||
for i, part in enumerate(display_path.parts):
|
||||
part_ = part.strip(os.path.sep)
|
||||
if i != len(filepath.parts) - 1:
|
||||
file_str += f"{'\u200b'.join(part_)}{separator}</b>"
|
||||
if i != len(display_path.parts) - 1:
|
||||
file_str += f"{"\u200b".join(part_)}{separator}</b>"
|
||||
else:
|
||||
file_str += f"<br><b>{'\u200b'.join(part_)}</b>"
|
||||
if file_str != "":
|
||||
file_str += "<br>"
|
||||
file_str += f"<b>{"\u200b".join(part_)}</b>"
|
||||
self.file_label.setText(file_str)
|
||||
self.file_label.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.opener = FileOpenerHelper(filepath)
|
||||
|
||||
@@ -12,12 +12,13 @@ import cv2
|
||||
import rawpy
|
||||
import structlog
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from PIL.Image import DecompressionBombError
|
||||
from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt
|
||||
from PySide6.QtGui import QAction, QMovie, QResizeEvent
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QWidget
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QStackedLayout, QWidget
|
||||
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.media_types import MediaCategories
|
||||
from tagstudio.core.media_types import MediaCategories, MediaType
|
||||
from tagstudio.qt.helpers.file_opener import FileOpenerHelper, open_file
|
||||
from tagstudio.qt.helpers.file_tester import is_readable_video
|
||||
from tagstudio.qt.helpers.qbutton_wrapper import QPushButtonWrapper
|
||||
@@ -27,12 +28,12 @@ from tagstudio.qt.resource_manager import ResourceManager
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.widgets.media_player import MediaPlayer
|
||||
from tagstudio.qt.widgets.thumb_renderer import ThumbRenderer
|
||||
from tagstudio.qt.widgets.video_player import VideoPlayer
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
Image.MAX_IMAGE_PIXELS = None
|
||||
|
||||
|
||||
class PreviewThumb(QWidget):
|
||||
@@ -48,8 +49,10 @@ class PreviewThumb(QWidget):
|
||||
self.img_button_size: tuple[int, int] = (266, 266)
|
||||
self.image_ratio: float = 1.0
|
||||
|
||||
image_layout = QHBoxLayout(self)
|
||||
image_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.image_layout = QStackedLayout(self)
|
||||
self.image_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.image_layout.setStackingMode(QStackedLayout.StackingMode.StackAll)
|
||||
self.image_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.open_file_action = QAction(Translations["file.open_file"], self)
|
||||
self.open_explorer_action = QAction(open_file_str(), self)
|
||||
@@ -66,6 +69,11 @@ class PreviewThumb(QWidget):
|
||||
self.preview_img.addAction(self.open_explorer_action)
|
||||
self.preview_img.addAction(self.delete_action)
|
||||
|
||||
# In testing, it didn't seem possible to center the widgets directly
|
||||
# on the QStackedLayout. Adding sublayouts allows us to center the widgets.
|
||||
self.preview_img_page = QWidget()
|
||||
self._stacked_page_setup(self.preview_img_page, self.preview_img)
|
||||
|
||||
self.preview_gif = QLabel()
|
||||
self.preview_gif.setMinimumSize(*self.img_button_size)
|
||||
self.preview_gif.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
|
||||
@@ -73,14 +81,31 @@ class PreviewThumb(QWidget):
|
||||
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.addAction(self.delete_action)
|
||||
self.preview_vid.hide()
|
||||
self.preview_gif_page = QWidget()
|
||||
self._stacked_page_setup(self.preview_gif_page, self.preview_gif)
|
||||
|
||||
self.media_player = MediaPlayer(driver)
|
||||
self.media_player.addAction(self.open_file_action)
|
||||
self.media_player.addAction(self.open_explorer_action)
|
||||
self.media_player.addAction(self.delete_action)
|
||||
|
||||
# Need to watch for this to resize the player appropriately.
|
||||
self.media_player.player.hasVideoChanged.connect(self._has_video_changed)
|
||||
|
||||
self.mp_max_size = QSize(*self.img_button_size)
|
||||
|
||||
self.media_player_page = QWidget()
|
||||
self._stacked_page_setup(self.media_player_page, self.media_player)
|
||||
|
||||
self.thumb_renderer = ThumbRenderer(self.lib)
|
||||
self.thumb_renderer.updated.connect(lambda ts, i, s: (self.preview_img.setIcon(i)))
|
||||
self.thumb_renderer.updated.connect(
|
||||
lambda ts, i, s: (
|
||||
self.preview_img.setIcon(i),
|
||||
self._set_mp_max_size(i.size()),
|
||||
)
|
||||
)
|
||||
self.thumb_renderer.updated_ratio.connect(
|
||||
lambda ratio: (
|
||||
self.set_image_ratio(ratio),
|
||||
@@ -94,21 +119,31 @@ class PreviewThumb(QWidget):
|
||||
)
|
||||
)
|
||||
|
||||
self.media_player = MediaPlayer(driver)
|
||||
self.media_player.hide()
|
||||
self.image_layout.addWidget(self.preview_img_page)
|
||||
self.image_layout.addWidget(self.preview_gif_page)
|
||||
self.image_layout.addWidget(self.media_player_page)
|
||||
|
||||
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.setMinimumSize(*self.img_button_size)
|
||||
|
||||
self.hide_preview()
|
||||
|
||||
def _set_mp_max_size(self, size: QSize) -> None:
|
||||
self.mp_max_size = size
|
||||
|
||||
def _has_video_changed(self, video: bool) -> None:
|
||||
self.update_image_size((self.size().width(), self.size().height()))
|
||||
|
||||
def _stacked_page_setup(self, page: QWidget, widget: QWidget):
|
||||
layout = QHBoxLayout(page)
|
||||
layout.addWidget(widget)
|
||||
layout.setAlignment(widget, Qt.AlignmentFlag.AlignCenter)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
page.setLayout(layout)
|
||||
|
||||
def set_image_ratio(self, ratio: float):
|
||||
self.image_ratio = ratio
|
||||
|
||||
def update_image_size(self, size: tuple[int, int], ratio: float = None):
|
||||
def update_image_size(self, size: tuple[int, int], ratio: float | None = None):
|
||||
if ratio:
|
||||
self.set_image_ratio(ratio)
|
||||
|
||||
@@ -129,17 +164,36 @@ class PreviewThumb(QWidget):
|
||||
adj_height = size[1]
|
||||
|
||||
adj_size = QSize(int(adj_width), int(adj_height))
|
||||
|
||||
self.img_button_size = (int(adj_width), int(adj_height))
|
||||
self.preview_img.setMaximumSize(adj_size)
|
||||
self.preview_img.setIconSize(adj_size)
|
||||
self.preview_vid.resize_video(adj_size)
|
||||
self.preview_vid.setMaximumSize(adj_size)
|
||||
self.preview_vid.setMinimumSize(adj_size)
|
||||
self.preview_gif.setMaximumSize(adj_size)
|
||||
self.preview_gif.setMinimumSize(adj_size)
|
||||
|
||||
if not self.media_player.player.hasVideo():
|
||||
# ensure we do not exceed the thumbnail size
|
||||
mp_width = (
|
||||
adj_size.width()
|
||||
if adj_size.width() < self.mp_max_size.width()
|
||||
else self.mp_max_size.width()
|
||||
)
|
||||
mp_height = (
|
||||
adj_size.height()
|
||||
if adj_size.height() < self.mp_max_size.height()
|
||||
else self.mp_max_size.height()
|
||||
)
|
||||
mp_size = QSize(mp_width, mp_height)
|
||||
self.media_player.setMinimumSize(mp_size)
|
||||
self.media_player.setMaximumSize(mp_size)
|
||||
else:
|
||||
# have video, so just resize as normal
|
||||
self.media_player.setMaximumSize(adj_size)
|
||||
self.media_player.setMinimumSize(adj_size)
|
||||
|
||||
proxy_style = RoundedPixmapStyle(radius=8)
|
||||
self.preview_gif.setStyle(proxy_style)
|
||||
self.preview_vid.setStyle(proxy_style)
|
||||
self.media_player.setStyle(proxy_style)
|
||||
m = self.preview_gif.movie()
|
||||
if m:
|
||||
m.setScaledSize(adj_size)
|
||||
@@ -151,24 +205,31 @@ class PreviewThumb(QWidget):
|
||||
)
|
||||
|
||||
def switch_preview(self, preview: str):
|
||||
if preview != "image" and preview != "media":
|
||||
self.preview_img.hide()
|
||||
|
||||
if preview != "video_legacy":
|
||||
self.preview_vid.stop()
|
||||
self.preview_vid.hide()
|
||||
|
||||
if preview != "media":
|
||||
if preview in ["audio", "video"]:
|
||||
self.media_player.show()
|
||||
self.image_layout.setCurrentWidget(self.media_player_page)
|
||||
else:
|
||||
self.media_player.stop()
|
||||
self.media_player.hide()
|
||||
|
||||
if preview != "animated":
|
||||
if preview in ["image", "audio"]:
|
||||
self.preview_img.show()
|
||||
self.image_layout.setCurrentWidget(
|
||||
self.preview_img_page if preview == "image" else self.media_player_page
|
||||
)
|
||||
else:
|
||||
self.preview_img.hide()
|
||||
|
||||
if preview == "animated":
|
||||
self.preview_gif.show()
|
||||
self.image_layout.setCurrentWidget(self.preview_gif_page)
|
||||
else:
|
||||
if self.preview_gif.movie():
|
||||
self.preview_gif.movie().stop()
|
||||
self.gif_buffer.close()
|
||||
self.preview_gif.hide()
|
||||
|
||||
def _display_fallback_image(self, filepath: Path, ext=str) -> dict:
|
||||
def _display_fallback_image(self, filepath: Path, ext: str) -> dict:
|
||||
"""Renders the given file as an image, no matter its media type.
|
||||
|
||||
Useful for fallback scenarios.
|
||||
@@ -181,7 +242,6 @@ class PreviewThumb(QWidget):
|
||||
self.devicePixelRatio(),
|
||||
update_on_ratio_change=True,
|
||||
)
|
||||
self.preview_img.show()
|
||||
return self._update_image(filepath, ext)
|
||||
|
||||
def _update_image(self, filepath: Path, ext: str) -> dict:
|
||||
@@ -189,7 +249,7 @@ class PreviewThumb(QWidget):
|
||||
stats: dict = {}
|
||||
self.switch_preview("image")
|
||||
|
||||
image: Image.Image = None
|
||||
image: Image.Image | None = None
|
||||
|
||||
if MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.IMAGE_RAW_TYPES, mime_fallback=True
|
||||
@@ -201,8 +261,8 @@ class PreviewThumb(QWidget):
|
||||
stats["width"] = image.width
|
||||
stats["height"] = image.height
|
||||
except (
|
||||
rawpy._rawpy.LibRawIOError,
|
||||
rawpy._rawpy.LibRawFileUnsupportedError,
|
||||
rawpy._rawpy._rawpy.LibRawIOError, # pyright: ignore[reportAttributeAccessIssue]
|
||||
rawpy._rawpy.LibRawFileUnsupportedError, # pyright: ignore[reportAttributeAccessIssue]
|
||||
FileNotFoundError,
|
||||
):
|
||||
pass
|
||||
@@ -213,15 +273,18 @@ class PreviewThumb(QWidget):
|
||||
image = Image.open(str(filepath))
|
||||
stats["width"] = image.width
|
||||
stats["height"] = image.height
|
||||
except (UnidentifiedImageError, FileNotFoundError, NotImplementedError) as e:
|
||||
except (
|
||||
DecompressionBombError,
|
||||
FileNotFoundError,
|
||||
NotImplementedError,
|
||||
UnidentifiedImageError,
|
||||
) as e:
|
||||
logger.error("[PreviewThumb] Could not get image stats", filepath=filepath, error=e)
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.IMAGE_VECTOR_TYPES, mime_fallback=True
|
||||
):
|
||||
pass
|
||||
|
||||
self.preview_img.show()
|
||||
|
||||
return stats
|
||||
|
||||
def _update_animation(self, filepath: Path, ext: str) -> dict:
|
||||
@@ -237,25 +300,30 @@ class PreviewThumb(QWidget):
|
||||
image: Image.Image = Image.open(filepath)
|
||||
stats["width"] = image.width
|
||||
stats["height"] = image.height
|
||||
|
||||
self.update_image_size((image.width, image.height), image.width / image.height)
|
||||
anim_image: Image.Image = image
|
||||
image_bytes_io: io.BytesIO = io.BytesIO()
|
||||
anim_image.save(
|
||||
image_bytes_io,
|
||||
"GIF",
|
||||
lossless=True,
|
||||
save_all=True,
|
||||
loop=0,
|
||||
disposal=2,
|
||||
)
|
||||
image_bytes_io.seek(0)
|
||||
ba: bytes = image_bytes_io.read()
|
||||
self.gif_buffer.setData(ba)
|
||||
if ext == ".apng":
|
||||
image_bytes_io = io.BytesIO()
|
||||
image.save(
|
||||
image_bytes_io,
|
||||
"GIF",
|
||||
lossless=True,
|
||||
save_all=True,
|
||||
loop=0,
|
||||
disposal=2,
|
||||
)
|
||||
image.close()
|
||||
image_bytes_io.seek(0)
|
||||
self.gif_buffer.setData(image_bytes_io.read())
|
||||
else:
|
||||
image.close()
|
||||
with open(filepath, "rb") as f:
|
||||
self.gif_buffer.setData(f.read())
|
||||
movie = QMovie(self.gif_buffer, QByteArray())
|
||||
self.preview_gif.setMovie(movie)
|
||||
|
||||
# If the animation only has 1 frame, display it like a normal image.
|
||||
if movie.frameCount() == 1:
|
||||
if movie.frameCount() <= 1:
|
||||
self._display_fallback_image(filepath, ext)
|
||||
return stats
|
||||
|
||||
@@ -263,57 +331,52 @@ class PreviewThumb(QWidget):
|
||||
self.switch_preview("animated")
|
||||
self.resizeEvent(
|
||||
QResizeEvent(
|
||||
QSize(image.width, image.height),
|
||||
QSize(image.width, image.height),
|
||||
QSize(stats["width"], stats["height"]),
|
||||
QSize(stats["width"], stats["height"]),
|
||||
)
|
||||
)
|
||||
movie.start()
|
||||
self.preview_gif.show()
|
||||
|
||||
stats["duration"] = movie.frameCount() // 60
|
||||
except UnidentifiedImageError as e:
|
||||
except (UnidentifiedImageError, FileNotFoundError) as e:
|
||||
logger.error("[PreviewThumb] Could not load animated image", filepath=filepath, error=e)
|
||||
return self._display_fallback_image(filepath, ext)
|
||||
|
||||
return stats
|
||||
|
||||
def _update_video_legacy(self, filepath: Path) -> dict:
|
||||
def _get_video_res(self, filepath: str) -> tuple[bool, QSize]:
|
||||
video = cv2.VideoCapture(filepath, cv2.CAP_FFMPEG)
|
||||
success, frame = video.read()
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
image = Image.fromarray(frame)
|
||||
return (success, QSize(image.width, image.height))
|
||||
|
||||
def _update_media(self, filepath: Path, type: MediaType) -> dict:
|
||||
stats: dict = {}
|
||||
filepath_ = str(filepath)
|
||||
self.switch_preview("video_legacy")
|
||||
|
||||
try:
|
||||
video = cv2.VideoCapture(filepath_, cv2.CAP_FFMPEG)
|
||||
success, frame = video.read()
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
image = Image.fromarray(frame)
|
||||
stats["width"] = image.width
|
||||
stats["height"] = image.height
|
||||
if success:
|
||||
self.preview_vid.play(filepath_, QSize(image.width, image.height))
|
||||
self.update_image_size((image.width, image.height), image.width / image.height)
|
||||
self.resizeEvent(
|
||||
QResizeEvent(
|
||||
QSize(image.width, image.height),
|
||||
QSize(image.width, image.height),
|
||||
)
|
||||
)
|
||||
self.preview_vid.show()
|
||||
|
||||
stats["duration"] = video.get(cv2.CAP_PROP_FRAME_COUNT) / video.get(cv2.CAP_PROP_FPS)
|
||||
except cv2.error as e:
|
||||
logger.error("[PreviewThumb] Could not play video", filepath=filepath_, error=e)
|
||||
|
||||
return stats
|
||||
|
||||
def _update_media(self, filepath: Path) -> dict:
|
||||
stats: dict = {}
|
||||
self.switch_preview("media")
|
||||
|
||||
self.preview_img.show()
|
||||
self.media_player.show()
|
||||
self.media_player.play(filepath)
|
||||
|
||||
if type == MediaType.VIDEO:
|
||||
try:
|
||||
success, size = self._get_video_res(str(filepath))
|
||||
if success:
|
||||
self.update_image_size(
|
||||
(size.width(), size.height()), size.width() / size.height()
|
||||
)
|
||||
self.resizeEvent(
|
||||
QResizeEvent(
|
||||
QSize(size.width(), size.height()),
|
||||
QSize(size.width(), size.height()),
|
||||
)
|
||||
)
|
||||
|
||||
stats["width"] = size.width()
|
||||
stats["height"] = size.height()
|
||||
|
||||
except cv2.error as e:
|
||||
logger.error("[PreviewThumb] Could not play video", filepath=filepath, error=e)
|
||||
|
||||
self.switch_preview("video" if type == MediaType.VIDEO else "audio")
|
||||
stats["duration"] = self.media_player.player.duration() * 1000
|
||||
return stats
|
||||
|
||||
@@ -321,18 +384,18 @@ class PreviewThumb(QWidget):
|
||||
"""Render a single file preview."""
|
||||
stats: dict = {}
|
||||
|
||||
# Video (Legacy)
|
||||
# Video
|
||||
if MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.VIDEO_TYPES, mime_fallback=True
|
||||
) and is_readable_video(filepath):
|
||||
stats = self._update_video_legacy(filepath)
|
||||
stats = self._update_media(filepath, MediaType.VIDEO)
|
||||
|
||||
# Audio
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.AUDIO_TYPES, mime_fallback=True
|
||||
):
|
||||
self._update_image(filepath, ext)
|
||||
stats = self._update_media(filepath)
|
||||
stats = self._update_media(filepath, MediaType.AUDIO)
|
||||
self.thumb_renderer.render(
|
||||
time.time(),
|
||||
filepath,
|
||||
@@ -394,8 +457,7 @@ class PreviewThumb(QWidget):
|
||||
logger.info("[PreviewThumb] 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(str(ResourceManager.get_path("placeholder_mp4")), QSize(8, 8))
|
||||
self.preview_vid.hide()
|
||||
self.media_player.play(ResourceManager.get_path("placeholder_mp4"))
|
||||
|
||||
def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802
|
||||
self.update_image_size((self.size().width(), self.size().height()))
|
||||
|
||||
@@ -110,7 +110,6 @@ class PreviewPanel(QWidget):
|
||||
add_buttons_layout.addWidget(self.add_field_button)
|
||||
|
||||
preview_layout.addWidget(self.thumb)
|
||||
preview_layout.addWidget(self.thumb.media_player)
|
||||
info_layout.addWidget(self.file_attrs)
|
||||
info_layout.addWidget(self.fields)
|
||||
|
||||
|
||||
@@ -67,7 +67,9 @@ class TagBoxWidget(FieldWidget):
|
||||
tag_widget.search_for_tag_action.triggered.connect(
|
||||
lambda checked=False, tag_id=tag.id: (
|
||||
self.driver.main_window.searchField.setText(f"tag_id:{tag_id}"),
|
||||
self.driver.filter_items(FilterState.from_tag_id(tag_id)),
|
||||
self.driver.filter_items(
|
||||
FilterState.from_tag_id(tag_id, page_size=self.driver.settings.page_size)
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -1,35 +1,32 @@
|
||||
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
import sys
|
||||
from typing import override
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtCore import QEvent, Signal
|
||||
from PySide6.QtCore import QEvent
|
||||
from PySide6.QtGui import (
|
||||
QColor,
|
||||
QEnterEvent,
|
||||
QMouseEvent,
|
||||
QPainter,
|
||||
QPainterPath,
|
||||
QPaintEvent,
|
||||
QPalette,
|
||||
QPen,
|
||||
)
|
||||
from PySide6.QtWidgets import QPushButton, QWidget
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
from tagstudio.qt.helpers.qbutton_wrapper import QPushButtonWrapper
|
||||
|
||||
|
||||
class ThumbButton(QPushButton):
|
||||
double_clicked = Signal()
|
||||
|
||||
class ThumbButton(QPushButtonWrapper):
|
||||
def __init__(self, parent: QWidget, thumb_size: tuple[int, int]) -> None: # noqa: N802
|
||||
super().__init__(parent)
|
||||
self.thumb_size: tuple[int, int] = thumb_size
|
||||
self.hovered = False
|
||||
self.selected = False
|
||||
self.double_click = False
|
||||
|
||||
# NOTE: As of PySide 6.8.0.1, the QPalette.ColorRole.Accent role no longer works on Windows.
|
||||
# The QPalette.ColorRole.AlternateBase does for some reason, but not on macOS.
|
||||
@@ -88,9 +85,8 @@ class ThumbButton(QPushButton):
|
||||
self.hover_color.alpha(),
|
||||
)
|
||||
|
||||
@override
|
||||
def paintEvent(self, arg__1: QPaintEvent) -> None: # noqa: N802
|
||||
super().paintEvent(arg__1)
|
||||
def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802
|
||||
super().paintEvent(event)
|
||||
if self.hovered or self.selected:
|
||||
painter = QPainter()
|
||||
painter.begin(self)
|
||||
@@ -129,13 +125,11 @@ class ThumbButton(QPushButton):
|
||||
|
||||
painter.end()
|
||||
|
||||
@override
|
||||
def enterEvent(self, event: QEnterEvent) -> None: # noqa: N802
|
||||
self.hovered = True
|
||||
self.repaint()
|
||||
return super().enterEvent(event)
|
||||
|
||||
@override
|
||||
def leaveEvent(self, event: QEvent) -> None: # noqa: N802
|
||||
self.hovered = False
|
||||
self.repaint()
|
||||
@@ -144,18 +138,3 @@ class ThumbButton(QPushButton):
|
||||
def set_selected(self, value: bool) -> None: # noqa: N802
|
||||
self.selected = value
|
||||
self.repaint()
|
||||
|
||||
@override
|
||||
def mousePressEvent(self, e: QMouseEvent) -> None: # noqa: N802
|
||||
self.double_click = False
|
||||
|
||||
@override
|
||||
def mouseDoubleClickEvent(self, event: QMouseEvent) -> None: # noqa: N802
|
||||
self.double_click = True
|
||||
|
||||
@override
|
||||
def mouseReleaseEvent(self, e: QMouseEvent) -> None: # noqa: N802
|
||||
if self.double_click:
|
||||
self.double_clicked.emit()
|
||||
else:
|
||||
self.clicked.emit()
|
||||
|
||||
@@ -11,11 +11,13 @@ import zipfile
|
||||
from copy import deepcopy
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from warnings import catch_warnings
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import rawpy
|
||||
import structlog
|
||||
from cv2.typing import MatLike
|
||||
from mutagen import MutagenError, flac, id3, mp4
|
||||
from PIL import (
|
||||
Image,
|
||||
@@ -71,11 +73,12 @@ from tagstudio.qt.resource_manager import ResourceManager
|
||||
ImageFile.LOAD_TRUNCATED_IMAGES = True
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
Image.MAX_IMAGE_PIXELS = None
|
||||
register_heif_opener()
|
||||
register_avif_opener()
|
||||
|
||||
try:
|
||||
import pillow_jxl # noqa: F401
|
||||
import pillow_jxl # noqa: F401 # pyright: ignore[reportUnusedImport]
|
||||
except ImportError:
|
||||
logger.exception('[ThumbRenderer] Could not import the "pillow_jxl" module')
|
||||
|
||||
@@ -102,11 +105,11 @@ class ThumbRenderer(QObject):
|
||||
# Cached thumbnail elements.
|
||||
# Key: Size + Pixel Ratio Tuple + Radius Scale
|
||||
# (Ex. (512, 512, 1.25, 4))
|
||||
self.thumb_masks: dict = {}
|
||||
self.raised_edges: dict = {}
|
||||
self.thumb_masks: dict[tuple[int, int, float, float], Image.Image] = {}
|
||||
self.raised_edges: dict[tuple[int, int, float], tuple[Image.Image, Image.Image]] = {}
|
||||
|
||||
# Key: ("name", UiColor, 512, 512, 1.25)
|
||||
self.icons: dict = {}
|
||||
self.icons: dict[tuple[str, UiColor, int, int, float], Image.Image] = {}
|
||||
|
||||
def _get_resource_id(self, url: Path) -> str:
|
||||
"""Return the name of the icon resource to use for a file type.
|
||||
@@ -119,6 +122,14 @@ class ThumbRenderer(QObject):
|
||||
ext = url.suffix.lower()
|
||||
types: set[MediaType] = MediaCategories.get_types(ext, mime_fallback=True)
|
||||
|
||||
# Manual icon overrides.
|
||||
if ext in {".gif", ".vtf"}:
|
||||
return MediaType.IMAGE
|
||||
elif ext in {".dll", ".pyc", ".o", ".dylib"}:
|
||||
return MediaType.PROGRAM
|
||||
elif ext in {".mscz"}: # noqa: SIM114
|
||||
return MediaType.TEXT
|
||||
|
||||
# Loop though the specific (non-IANA) categories and return the string
|
||||
# name of the first matching category found.
|
||||
for cat in MediaCategories.ALL_CATEGORIES:
|
||||
@@ -149,7 +160,7 @@ class ThumbRenderer(QObject):
|
||||
if scale_radius:
|
||||
radius_scale = max(size[0], size[1]) / thumb_scale
|
||||
|
||||
item: Image.Image = self.thumb_masks.get((*size, pixel_ratio, radius_scale))
|
||||
item: Image.Image | None = self.thumb_masks.get((*size, pixel_ratio, radius_scale))
|
||||
if not item:
|
||||
item = self._render_mask(size, pixel_ratio, radius_scale)
|
||||
self.thumb_masks[(*size, pixel_ratio, radius_scale)] = item
|
||||
@@ -166,7 +177,7 @@ class ThumbRenderer(QObject):
|
||||
size (tuple[int, int]): The size of the graphic.
|
||||
pixel_ratio (float): The screen pixel ratio.
|
||||
"""
|
||||
item: tuple[Image.Image, Image.Image] = self.raised_edges.get((*size, pixel_ratio))
|
||||
item: tuple[Image.Image, Image.Image] | None = self.raised_edges.get((*size, pixel_ratio))
|
||||
if not item:
|
||||
item = self._render_edge(size, pixel_ratio)
|
||||
self.raised_edges[(*size, pixel_ratio)] = item
|
||||
@@ -187,7 +198,7 @@ class ThumbRenderer(QObject):
|
||||
if name == "thumb_loading":
|
||||
draw_border = False
|
||||
|
||||
item: Image.Image = self.icons.get((name, color, *size, pixel_ratio))
|
||||
item: Image.Image | None = self.icons.get((name, color, *size, pixel_ratio))
|
||||
if not item:
|
||||
item_flat: Image.Image = self._render_icon(name, color, size, pixel_ratio, draw_border)
|
||||
edge: tuple[Image.Image, Image.Image] = self._get_edge(size, pixel_ratio)
|
||||
@@ -455,14 +466,15 @@ class ThumbRenderer(QObject):
|
||||
|
||||
return im
|
||||
|
||||
def _audio_album_thumb(self, filepath: Path, ext: str) -> Image.Image | None:
|
||||
@staticmethod
|
||||
def _audio_album_thumb(filepath: Path, ext: str) -> Image.Image | None:
|
||||
"""Return an album cover thumb from an audio file if a cover is present.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path of the file.
|
||||
ext (str): The file extension (with leading ".").
|
||||
"""
|
||||
image: Image.Image = None
|
||||
image: Image.Image | None = None
|
||||
try:
|
||||
if not filepath.is_file():
|
||||
raise FileNotFoundError
|
||||
@@ -494,8 +506,9 @@ class ThumbRenderer(QObject):
|
||||
logger.error("Couldn't read album artwork", path=filepath, error=type(e).__name__)
|
||||
return image
|
||||
|
||||
@staticmethod
|
||||
def _audio_waveform_thumb(
|
||||
self, filepath: Path, ext: str, size: int, pixel_ratio: float
|
||||
filepath: Path, ext: str, size: int, pixel_ratio: float
|
||||
) -> Image.Image | None:
|
||||
"""Render a waveform image from an audio file.
|
||||
|
||||
@@ -531,8 +544,9 @@ class ThumbRenderer(QObject):
|
||||
d = data[math.ceil(data_indices[i]) - 1]
|
||||
if count < samples_per_bar:
|
||||
count = count + 1
|
||||
if abs(d) > maximum_item:
|
||||
maximum_item = abs(d)
|
||||
with catch_warnings(record=True):
|
||||
if abs(d) > maximum_item:
|
||||
maximum_item = abs(d)
|
||||
else:
|
||||
max_array.append(maximum_item)
|
||||
|
||||
@@ -580,7 +594,8 @@ class ThumbRenderer(QObject):
|
||||
|
||||
return im
|
||||
|
||||
def _blender(self, filepath: Path) -> Image.Image:
|
||||
@staticmethod
|
||||
def _blender(filepath: Path) -> Image.Image:
|
||||
"""Get an emended thumbnail from a Blender file, if a thumbnail is present.
|
||||
|
||||
Args:
|
||||
@@ -614,7 +629,8 @@ class ThumbRenderer(QObject):
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
|
||||
return im
|
||||
|
||||
def _source_engine(self, filepath: Path) -> Image.Image:
|
||||
@staticmethod
|
||||
def _source_engine(filepath: Path) -> Image.Image:
|
||||
"""This is a function to convert the VTF (Valve Texture Format) files to thumbnails.
|
||||
|
||||
It works using the VTF2IMG library for PILLOW.
|
||||
@@ -637,8 +653,8 @@ class ThumbRenderer(QObject):
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
|
||||
return im
|
||||
|
||||
@classmethod
|
||||
def _open_doc_thumb(cls, filepath: Path) -> Image.Image:
|
||||
@staticmethod
|
||||
def _open_doc_thumb(filepath: Path) -> Image.Image:
|
||||
"""Extract and render a thumbnail for an OpenDocument file.
|
||||
|
||||
Args:
|
||||
@@ -660,8 +676,34 @@ class ThumbRenderer(QObject):
|
||||
|
||||
return im
|
||||
|
||||
@classmethod
|
||||
def _epub_cover(cls, filepath: Path) -> Image.Image:
|
||||
@staticmethod
|
||||
def _powerpoint_thumb(filepath: Path) -> Image.Image | None:
|
||||
"""Extract and render a thumbnail for a Microsoft PowerPoint file.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path of the file.
|
||||
"""
|
||||
file_path_within_zip = "docProps/thumbnail.jpeg"
|
||||
im: Image.Image | None = None
|
||||
try:
|
||||
with zipfile.ZipFile(filepath, "r") as zip_file:
|
||||
# Check if the file exists in the zip
|
||||
if file_path_within_zip in zip_file.namelist():
|
||||
# Read the specific file into memory
|
||||
file_data = zip_file.read(file_path_within_zip)
|
||||
thumb_im = Image.open(BytesIO(file_data))
|
||||
if thumb_im:
|
||||
im = Image.new("RGB", thumb_im.size, color="#1e1e1e")
|
||||
im.paste(thumb_im)
|
||||
else:
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath)
|
||||
except zipfile.BadZipFile as e:
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath, error=e)
|
||||
|
||||
return im
|
||||
|
||||
@staticmethod
|
||||
def _epub_cover(filepath: Path) -> Image.Image:
|
||||
"""Extracts and returns the first image found in the ePub file at the given filepath.
|
||||
|
||||
Args:
|
||||
@@ -739,12 +781,13 @@ class ThumbRenderer(QObject):
|
||||
cropped_im,
|
||||
box=(margin, margin + ((size - new_y) // 2)),
|
||||
)
|
||||
im = self._apply_overlay_color(bg, UiColor.PURPLE)
|
||||
im = self._apply_overlay_color(bg, UiColor.BLUE)
|
||||
except OSError as e:
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
|
||||
return im
|
||||
|
||||
def _font_long_thumb(self, filepath: Path, size: int) -> Image.Image:
|
||||
@staticmethod
|
||||
def _font_long_thumb(filepath: Path, size: int) -> Image.Image:
|
||||
"""Render a large font preview ("Alphabet") thumbnail from a font file.
|
||||
|
||||
Args:
|
||||
@@ -775,7 +818,8 @@ class ThumbRenderer(QObject):
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
|
||||
return im
|
||||
|
||||
def _image_raw_thumb(self, filepath: Path) -> Image.Image:
|
||||
@staticmethod
|
||||
def _image_raw_thumb(filepath: Path) -> Image.Image:
|
||||
"""Render a thumbnail for a RAW image type.
|
||||
|
||||
Args:
|
||||
@@ -799,7 +843,8 @@ class ThumbRenderer(QObject):
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
|
||||
return im
|
||||
|
||||
def _image_thumb(self, filepath: Path) -> Image.Image:
|
||||
@staticmethod
|
||||
def _image_thumb(filepath: Path) -> Image.Image:
|
||||
"""Render a thumbnail for a standard image type.
|
||||
|
||||
Args:
|
||||
@@ -823,8 +868,8 @@ class ThumbRenderer(QObject):
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
|
||||
return im
|
||||
|
||||
@classmethod
|
||||
def _image_vector_thumb(cls, filepath: Path, size: int) -> Image.Image:
|
||||
@staticmethod
|
||||
def _image_vector_thumb(filepath: Path, size: int) -> Image.Image:
|
||||
"""Render a thumbnail for a vector image, such as SVG.
|
||||
|
||||
Args:
|
||||
@@ -860,7 +905,46 @@ class ThumbRenderer(QObject):
|
||||
buffer.close()
|
||||
return im
|
||||
|
||||
def _model_stl_thumb(self, filepath: Path, size: int) -> Image.Image:
|
||||
@staticmethod
|
||||
def _iwork_thumb(filepath: Path) -> Image.Image:
|
||||
"""Extract and render a thumbnail for an Apple iWork (Pages, Numbers, Keynote) file.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path of the file.
|
||||
"""
|
||||
preview_thumb_dir = "preview.jpg"
|
||||
quicklook_thumb_dir = "QuickLook/Thumbnail.jpg"
|
||||
im: Image.Image | None = None
|
||||
|
||||
def get_image(path: str) -> Image.Image | None:
|
||||
thumb_im: Image.Image | None = None
|
||||
# Read the specific file into memory
|
||||
file_data = zip_file.read(path)
|
||||
thumb_im = Image.open(BytesIO(file_data))
|
||||
return thumb_im
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(filepath, "r") as zip_file:
|
||||
thumb: Image.Image | None = None
|
||||
|
||||
# Check if the file exists in the zip
|
||||
if preview_thumb_dir in zip_file.namelist():
|
||||
thumb = get_image(preview_thumb_dir)
|
||||
elif quicklook_thumb_dir in zip_file.namelist():
|
||||
thumb = get_image(quicklook_thumb_dir)
|
||||
else:
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath)
|
||||
|
||||
if thumb:
|
||||
im = Image.new("RGB", thumb.size, color="#1e1e1e")
|
||||
im.paste(thumb)
|
||||
except zipfile.BadZipFile as e:
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath, error=e)
|
||||
|
||||
return im
|
||||
|
||||
@staticmethod
|
||||
def _model_stl_thumb(filepath: Path, size: int) -> Image.Image:
|
||||
"""Render a thumbnail for an STL file.
|
||||
|
||||
Args:
|
||||
@@ -892,8 +976,8 @@ class ThumbRenderer(QObject):
|
||||
|
||||
return im
|
||||
|
||||
@classmethod
|
||||
def _pdf_thumb(cls, filepath: Path, size: int) -> Image.Image:
|
||||
@staticmethod
|
||||
def _pdf_thumb(filepath: Path, size: int) -> Image.Image:
|
||||
"""Render a thumbnail for a PDF file.
|
||||
|
||||
filepath (Path): The path of the file.
|
||||
@@ -910,6 +994,7 @@ class ThumbRenderer(QObject):
|
||||
return im
|
||||
document: QPdfDocument = QPdfDocument()
|
||||
document.load(file)
|
||||
file.close()
|
||||
# Transform page_size in points to pixels with proper aspect ratio
|
||||
page_size: QSizeF = document.pagePointSize(0)
|
||||
ratio_hw: float = page_size.height() / page_size.width()
|
||||
@@ -932,20 +1017,21 @@ class ThumbRenderer(QObject):
|
||||
buffer: QBuffer = QBuffer()
|
||||
buffer.open(QBuffer.OpenModeFlag.ReadWrite)
|
||||
try:
|
||||
q_image.save(buffer, "PNG") # type: ignore[call-overload]
|
||||
q_image.save(buffer, "PNG") # type: ignore # pyright: ignore
|
||||
im = Image.open(BytesIO(buffer.buffer().data()))
|
||||
finally:
|
||||
buffer.close()
|
||||
# Replace transparent pixels with white (otherwise Background defaults to transparent)
|
||||
return replace_transparent_pixels(im)
|
||||
|
||||
def _text_thumb(self, filepath: Path) -> Image.Image:
|
||||
@staticmethod
|
||||
def _text_thumb(filepath: Path) -> Image.Image | None:
|
||||
"""Render a thumbnail for a plaintext file.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path of the file.
|
||||
"""
|
||||
im: Image.Image = None
|
||||
im: Image.Image | None = None
|
||||
|
||||
bg_color: str = (
|
||||
"#1e1e1e"
|
||||
@@ -976,13 +1062,15 @@ class ThumbRenderer(QObject):
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
|
||||
return im
|
||||
|
||||
def _video_thumb(self, filepath: Path) -> Image.Image:
|
||||
@staticmethod
|
||||
def _video_thumb(filepath: Path) -> Image.Image | None:
|
||||
"""Render a thumbnail for a video file.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path of the file.
|
||||
"""
|
||||
im: Image.Image = None
|
||||
im: Image.Image | None = None
|
||||
frame: MatLike | None = None
|
||||
try:
|
||||
if is_readable_video(filepath):
|
||||
video = cv2.VideoCapture(str(filepath), cv2.CAP_FFMPEG)
|
||||
@@ -1006,8 +1094,9 @@ class ThumbRenderer(QObject):
|
||||
video.set(cv2.CAP_PROP_POS_FRAMES, i)
|
||||
else:
|
||||
break
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
im = Image.fromarray(frame)
|
||||
if frame is not None:
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
im = Image.fromarray(frame)
|
||||
except (
|
||||
UnidentifiedImageError,
|
||||
cv2.error,
|
||||
@@ -1072,7 +1161,7 @@ class ThumbRenderer(QObject):
|
||||
cached_path: Path | None = None
|
||||
|
||||
if hash_value and self.lib.library_dir:
|
||||
cached_path = (
|
||||
cached_path = Path(
|
||||
self.lib.library_dir
|
||||
/ TS_FOLDER_NAME
|
||||
/ THUMB_CACHE_NAME
|
||||
@@ -1083,7 +1172,7 @@ class ThumbRenderer(QObject):
|
||||
try:
|
||||
image = Image.open(cached_path)
|
||||
if not image:
|
||||
raise UnidentifiedImageError
|
||||
raise UnidentifiedImageError # pyright: ignore[reportUnreachable]
|
||||
ThumbRenderer.last_cache_folder = folder
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
@@ -1105,7 +1194,7 @@ class ThumbRenderer(QObject):
|
||||
|
||||
image: Image.Image | None = None
|
||||
# Try to get a non-loading thumbnail for the grid.
|
||||
if not is_loading and is_grid_thumb and filepath and filepath != ".":
|
||||
if not is_loading and is_grid_thumb and filepath and filepath != Path("."):
|
||||
# Attempt to retrieve cached image from disk
|
||||
mod_time: str = ""
|
||||
with contextlib.suppress(Exception):
|
||||
@@ -1243,7 +1332,7 @@ class ThumbRenderer(QObject):
|
||||
|
||||
"""
|
||||
adj_size = math.ceil(max(base_size[0], base_size[1]) * pixel_ratio)
|
||||
image: Image.Image = None
|
||||
image: Image.Image | None = None
|
||||
_filepath: Path = Path(filepath)
|
||||
savable_media_type: bool = True
|
||||
|
||||
@@ -1252,7 +1341,7 @@ class ThumbRenderer(QObject):
|
||||
# Missing Files ================================================
|
||||
if not _filepath.exists():
|
||||
raise FileNotFoundError
|
||||
ext: str = _filepath.suffix.lower()
|
||||
ext: str = _filepath.suffix.lower() if _filepath.suffix else _filepath.stem.lower()
|
||||
# Images =======================================================
|
||||
if MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.IMAGE_TYPES, mime_fallback=True
|
||||
@@ -1275,11 +1364,17 @@ class ThumbRenderer(QObject):
|
||||
ext, MediaCategories.VIDEO_TYPES, mime_fallback=True
|
||||
):
|
||||
image = self._video_thumb(_filepath)
|
||||
# PowerPoint Slideshow
|
||||
elif ext in {".pptx"}:
|
||||
image = self._powerpoint_thumb(_filepath)
|
||||
# OpenDocument/OpenOffice ======================================
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.OPEN_DOCUMENT_TYPES, mime_fallback=True
|
||||
):
|
||||
image = self._open_doc_thumb(_filepath)
|
||||
# Apple iWork Suite ============================================
|
||||
elif MediaCategories.is_ext_in_category(ext, MediaCategories.IWORK_TYPES):
|
||||
image = self._iwork_thumb(_filepath)
|
||||
# Plain Text ===================================================
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.PLAINTEXT_TYPES, mime_fallback=True
|
||||
@@ -1350,7 +1445,7 @@ class ThumbRenderer(QObject):
|
||||
|
||||
return image
|
||||
|
||||
def _resize_image(self, image, size: tuple[int, int]) -> Image.Image:
|
||||
def _resize_image(self, image: Image.Image, size: tuple[int, int]) -> Image.Image:
|
||||
orig_x, orig_y = image.size
|
||||
new_x, new_y = size
|
||||
|
||||
|
||||
@@ -1,347 +0,0 @@
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
from PySide6.QtCore import (
|
||||
QEvent,
|
||||
QObject,
|
||||
QRectF,
|
||||
QSize,
|
||||
Qt,
|
||||
QTimer,
|
||||
QUrl,
|
||||
QVariantAnimation,
|
||||
)
|
||||
from PySide6.QtGui import QAction, QBitmap, QBrush, QColor, QPen, QRegion, QResizeEvent
|
||||
from PySide6.QtMultimedia import QAudioOutput, QMediaDevices, QMediaPlayer
|
||||
from PySide6.QtMultimediaWidgets import QGraphicsVideoItem
|
||||
from PySide6.QtSvgWidgets import QSvgWidget
|
||||
from PySide6.QtWidgets import QGraphicsScene, QGraphicsView
|
||||
|
||||
from tagstudio.core.enums import SettingItems
|
||||
from tagstudio.qt.helpers.file_opener import FileOpenerHelper
|
||||
from tagstudio.qt.platform_strings import open_file_str
|
||||
from tagstudio.qt.translations import Translations
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
|
||||
|
||||
class VideoPlayer(QGraphicsView):
|
||||
"""A basic video player."""
|
||||
|
||||
video_preview = None
|
||||
play_pause = None
|
||||
mute_button = None
|
||||
filepath: str | None
|
||||
|
||||
def __init__(self, driver: "QtDriver") -> None:
|
||||
super().__init__()
|
||||
self.driver = driver
|
||||
self.resolution = QSize(1280, 720)
|
||||
self.animation = QVariantAnimation(self)
|
||||
self.animation.valueChanged.connect(lambda value: self.set_tint_opacity(value))
|
||||
self.hover_fix_timer = QTimer()
|
||||
self.hover_fix_timer.timeout.connect(lambda: self.check_if_hovered())
|
||||
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))
|
||||
self.player = QMediaPlayer(self)
|
||||
self.player.mediaStatusChanged.connect(
|
||||
lambda: self.check_media_status(self.player.mediaStatus())
|
||||
)
|
||||
self.video_preview = VideoPreview()
|
||||
self.player.setVideoOutput(self.video_preview)
|
||||
self.video_preview.setAcceptHoverEvents(True)
|
||||
self.video_preview.setAcceptedMouseButtons(Qt.MouseButton.RightButton)
|
||||
self.video_preview.installEventFilter(self)
|
||||
self.player.setAudioOutput(QAudioOutput(QMediaDevices().defaultAudioOutput(), self.player))
|
||||
self.player.audioOutput().setMuted(True)
|
||||
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
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,
|
||||
0,
|
||||
self.video_preview.size().width(),
|
||||
self.video_preview.size().height(),
|
||||
QPen(QColor(0, 0, 0, 0)),
|
||||
QBrush(QColor(0, 0, 0, 0)),
|
||||
)
|
||||
|
||||
# Set up the buttons.
|
||||
self.play_pause = QSvgWidget()
|
||||
self.play_pause.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, on=True)
|
||||
self.play_pause.setMouseTracking(True)
|
||||
self.play_pause.installEventFilter(self)
|
||||
self.scene().addWidget(self.play_pause)
|
||||
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()
|
||||
self.mute_button.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, on=True)
|
||||
self.mute_button.setMouseTracking(True)
|
||||
self.mute_button.installEventFilter(self)
|
||||
self.scene().addWidget(self.mute_button)
|
||||
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.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
|
||||
self.opener = FileOpenerHelper(filepath=self.filepath)
|
||||
autoplay_action = QAction(Translations["media_player.autoplay"], self)
|
||||
autoplay_action.setCheckable(True)
|
||||
self.addAction(autoplay_action)
|
||||
autoplay_action.setChecked(
|
||||
self.driver.settings.value(SettingItems.AUTOPLAY, defaultValue=True, type=bool)
|
||||
)
|
||||
autoplay_action.triggered.connect(lambda: self.toggle_autoplay())
|
||||
self.autoplay = autoplay_action
|
||||
|
||||
open_file_action = QAction(Translations["file.open_file"], self)
|
||||
open_file_action.triggered.connect(self.opener.open_file)
|
||||
|
||||
open_explorer_action = QAction(open_file_str(), self)
|
||||
|
||||
open_explorer_action.triggered.connect(self.opener.open_explorer)
|
||||
self.addAction(open_file_action)
|
||||
self.addAction(open_explorer_action)
|
||||
|
||||
def close(self, *args, **kwargs) -> None:
|
||||
self.player.stop()
|
||||
super().close(*args, **kwargs)
|
||||
|
||||
def toggle_autoplay(self) -> None:
|
||||
"""Toggle the autoplay state of the video."""
|
||||
self.driver.settings.setValue(SettingItems.AUTOPLAY, self.autoplay.isChecked())
|
||||
self.driver.settings.sync()
|
||||
|
||||
def check_media_status(self, media_status: QMediaPlayer.MediaStatus) -> None:
|
||||
if media_status == QMediaPlayer.MediaStatus.EndOfMedia:
|
||||
# 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))
|
||||
self.player.setPosition(0)
|
||||
if self.autoplay.isChecked():
|
||||
self.player.play()
|
||||
else:
|
||||
self.player.pause()
|
||||
self.opener.set_filepath(self.filepath)
|
||||
self.reposition_controls()
|
||||
self.update_controls()
|
||||
|
||||
def update_controls(self) -> None:
|
||||
"""Update the icons of the video player controls."""
|
||||
if self.player.audioOutput().isMuted():
|
||||
self.mute_button.load(self.driver.rm.volume_mute_icon)
|
||||
else:
|
||||
self.mute_button.load(self.driver.rm.volume_icon)
|
||||
|
||||
if self.player.isPlaying():
|
||||
self.play_pause.load(self.driver.rm.pause_icon)
|
||||
else:
|
||||
self.play_pause.load(self.driver.rm.play_icon)
|
||||
|
||||
def eventFilter(self, obj: QObject, event: QEvent) -> bool: # noqa: N802
|
||||
"""Manage events for the video player."""
|
||||
if (
|
||||
event.type() == QEvent.Type.MouseButtonPress
|
||||
and event.button() == Qt.MouseButton.LeftButton # type: ignore
|
||||
):
|
||||
if obj == self.play_pause and self.player.hasVideo():
|
||||
self.toggle_pause()
|
||||
elif obj == self.mute_button and self.player.hasAudio():
|
||||
self.toggle_mute()
|
||||
|
||||
elif obj == self.video_preview:
|
||||
if event.type() in (
|
||||
QEvent.Type.GraphicsSceneHoverEnter,
|
||||
QEvent.Type.HoverEnter,
|
||||
):
|
||||
if self.video_preview.isUnderMouse():
|
||||
self.underMouse()
|
||||
self.hover_fix_timer.start(10)
|
||||
elif (
|
||||
event.type() in (QEvent.Type.GraphicsSceneHoverLeave, QEvent.Type.HoverLeave)
|
||||
and not self.video_preview.isUnderMouse()
|
||||
):
|
||||
self.hover_fix_timer.stop()
|
||||
self.releaseMouse()
|
||||
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
def check_if_hovered(self) -> None:
|
||||
"""Check if the mouse is still hovering over the video player."""
|
||||
# Sometimes the HoverLeave event does not trigger and is unable to hide the video controls.
|
||||
# As 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:
|
||||
self.hover_fix_timer.start(10)
|
||||
|
||||
def set_tint_opacity(self, opacity: int) -> None:
|
||||
"""Set the opacity of the video player's tint.
|
||||
|
||||
Args:
|
||||
opacity(int): The opacity value, from 0-255.
|
||||
"""
|
||||
self.video_tint.setBrush(QBrush(QColor(0, 0, 0, opacity)))
|
||||
|
||||
def underMouse(self) -> bool: # noqa: N802
|
||||
self.animation.setStartValue(self.video_tint.brush().color().alpha())
|
||||
self.animation.setEndValue(100)
|
||||
self.animation.setDuration(250)
|
||||
self.animation.start()
|
||||
self.play_pause.show()
|
||||
self.mute_button.show()
|
||||
self.reposition_controls()
|
||||
self.update_controls()
|
||||
|
||||
return super().underMouse()
|
||||
|
||||
def releaseMouse(self) -> None: # noqa: N802
|
||||
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()
|
||||
|
||||
return super().releaseMouse()
|
||||
|
||||
def reset_controls(self) -> None:
|
||||
"""Reset the video controls to their default state."""
|
||||
self.play_pause.load(self.driver.rm.pause_icon)
|
||||
self.mute_button.load(self.driver.rm.volume_mute_icon)
|
||||
|
||||
def toggle_pause(self) -> None:
|
||||
"""Toggle the pause state of the video."""
|
||||
if self.player.isPlaying():
|
||||
self.player.pause()
|
||||
self.play_pause.load(self.driver.rm.play_icon)
|
||||
else:
|
||||
self.player.play()
|
||||
self.play_pause.load(self.driver.rm.pause_icon)
|
||||
|
||||
def toggle_mute(self) -> None:
|
||||
"""Toggle the mute state of the video."""
|
||||
if self.player.audioOutput().isMuted():
|
||||
self.player.audioOutput().setMuted(False)
|
||||
self.mute_button.load(self.driver.rm.volume_icon)
|
||||
else:
|
||||
self.player.audioOutput().setMuted(True)
|
||||
self.mute_button.load(self.driver.rm.volume_mute_icon)
|
||||
|
||||
def play(self, filepath: str, resolution: QSize) -> None:
|
||||
"""Set the filepath and send the current player position to the very end.
|
||||
|
||||
This is used so that the new video can be played.
|
||||
"""
|
||||
logging.info(f"Playing {filepath}")
|
||||
self.resolution = resolution
|
||||
self.filepath = filepath
|
||||
if self.player.isPlaying():
|
||||
self.player.setPosition(self.player.duration())
|
||||
self.player.play()
|
||||
else:
|
||||
self.check_media_status(QMediaPlayer.MediaStatus.EndOfMedia)
|
||||
|
||||
def stop(self) -> None:
|
||||
self.filepath = None
|
||||
self.player.stop()
|
||||
|
||||
def resize_video(self, new_size: QSize) -> None:
|
||||
"""Resize the video player.
|
||||
|
||||
Args:
|
||||
new_size(QSize): The new size of the video player to set.
|
||||
"""
|
||||
self.video_preview.setSize(new_size)
|
||||
self.video_tint.setRect(
|
||||
0, 0, self.video_preview.size().width(), self.video_preview.size().height()
|
||||
)
|
||||
|
||||
contents = self.contentsRect()
|
||||
self.centerOn(self.video_preview)
|
||||
self.apply_rounded_corners()
|
||||
self.setSceneRect(0, 0, contents.width(), contents.height())
|
||||
self.reposition_controls()
|
||||
|
||||
def apply_rounded_corners(self) -> None:
|
||||
"""Apply a rounded corner effect to the video player."""
|
||||
width: int = int(max(self.contentsRect().size().width(), 0))
|
||||
height: int = int(max(self.contentsRect().size().height(), 0))
|
||||
mask = Image.new(
|
||||
"RGBA",
|
||||
(
|
||||
width,
|
||||
height,
|
||||
),
|
||||
(0, 0, 0, 255),
|
||||
)
|
||||
draw = ImageDraw.Draw(mask)
|
||||
draw.rounded_rectangle(
|
||||
(0, 0) + (width, height),
|
||||
radius=12,
|
||||
fill=(0, 0, 0, 0),
|
||||
)
|
||||
final_mask = mask.getchannel("A").toqpixmap()
|
||||
self.setMask(QRegion(QBitmap(final_mask)))
|
||||
|
||||
def reposition_controls(self) -> None:
|
||||
"""Reposition video controls to their intended locations."""
|
||||
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.mute_button.move(
|
||||
int(self.width() - self.mute_button.size().width() - 10),
|
||||
int(self.height() - self.mute_button.size().height() - 10),
|
||||
)
|
||||
|
||||
def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802
|
||||
"""Keep the video preview in the center of the screen."""
|
||||
self.centerOn(self.video_preview)
|
||||
self.resize_video(
|
||||
QSize(
|
||||
int(self.video_preview.size().width()),
|
||||
int(self.video_preview.size().height()),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class VideoPreview(QGraphicsVideoItem):
|
||||
def boundingRect(self): # noqa: N802
|
||||
return QRectF(0, 0, self.size().width(), self.size().height())
|
||||
|
||||
def paint(self, painter, option, widget=None) -> None:
|
||||
# painter.brush().setColor(QColor(0, 0, 0, 255))
|
||||
# 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)
|
||||
BIN
src/tagstudio/resources/qt/images/file_icons/archive.png
Normal file
BIN
src/tagstudio/resources/qt/images/file_icons/archive.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.4 KiB |
BIN
src/tagstudio/resources/qt/images/file_icons/database.png
Normal file
BIN
src/tagstudio/resources/qt/images/file_icons/database.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
src/tagstudio/resources/qt/images/file_icons/ebook.png
Normal file
BIN
src/tagstudio/resources/qt/images/file_icons/ebook.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
BIN
src/tagstudio/resources/qt/images/file_icons/shader.png
Normal file
BIN
src/tagstudio/resources/qt/images/file_icons/shader.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
src/tagstudio/resources/qt/images/file_icons/shortcut.png
Normal file
BIN
src/tagstudio/resources/qt/images/file_icons/shortcut.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.5 KiB |
10
src/tagstudio/resources/tagstudio.desktop
Normal file
10
src/tagstudio/resources/tagstudio.desktop
Normal file
@@ -0,0 +1,10 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=TagStudio
|
||||
GenericName=Tag Management System
|
||||
Comment=Tag, find, and organize files
|
||||
Icon=tagstudio
|
||||
Exec=tagstudio
|
||||
Terminal=false
|
||||
Categories=AudioVideo;Qt;
|
||||
Keywords=files;folders;tags;
|
||||
@@ -1,8 +1,45 @@
|
||||
{
|
||||
"about.config_path": "Konfigurační cesta",
|
||||
"about.description": "TagStudio je aplikace pro organizaci fotografií a souborů, založená na systému využívajícím tagy, který se zaměřuje na poskytnutí svobody a flexibility. Žádné proprietární programy nebo formáty, žádná záplava vedlejších souborů a žádné převrácení struktury vašeho souborového systému.",
|
||||
"about.documentation": "Dokumentace",
|
||||
"about.license": "Licence",
|
||||
"about.module.found": "Nalezeno",
|
||||
"about.title": "O TagStudiu",
|
||||
"about.website": "Webová stránka",
|
||||
"app.git": "Git Commit",
|
||||
"app.pre_release": "Beta verze",
|
||||
"app.title": "{base_title} - Knihovna '{library_dir}'",
|
||||
"color.color_border": "Použít Sekundární Barvu pro Okraj",
|
||||
"color.confirm_delete": "Opravdu chcete odstranit barvu „{color_name}“?",
|
||||
"color.delete": "Odstranit Tag",
|
||||
"color.import_pack": "Importovat Balíček Barev",
|
||||
"color.name": "Název",
|
||||
"color.namespace.delete.prompt": "Opravdu chcete odstranit tento soubor barev? Tím se odstraní VŠECHNY barvy v tomto souboru!",
|
||||
"color.namespace.delete.title": "Odstranění Soubor Barev",
|
||||
"color.new": "Nová Barva",
|
||||
"color.placeholder": "Barva",
|
||||
"color.primary": "Primární Barva",
|
||||
"color.primary_required": "Primární Barva (Povinná)",
|
||||
"color.secondary": "Sekundární Barva",
|
||||
"color.title.no_color": "Žádná Barva",
|
||||
"color_manager.title": "Správa barev tagů",
|
||||
"drop_import.description": "Tyto soubory v knihovně již existují",
|
||||
"drop_import.duplicates_choice.plural": "Těchto {count} souborů odpovídá cestám k souborům, které již v knihovně existují.",
|
||||
"drop_import.duplicates_choice.singular": "Tento soubor odpovídá cestě k souboru, která již v knihovně existuje.",
|
||||
"drop_import.progress.label.initial": "Importuji nové soubory...",
|
||||
"edit.tag_manager": "Spravovat tagy",
|
||||
"drop_import.progress.label.plural": "Import nových souborů...\n{count} Importované soubory.{suffix}",
|
||||
"drop_import.progress.label.singular": "Importuji nové soubory...\n1 soubor importován.{suffix}",
|
||||
"drop_import.progress.window_title": "Importovat Soubory",
|
||||
"drop_import.title": "Konfliktní Soubory",
|
||||
"edit.color_manager": "Správa Barev Tagů",
|
||||
"edit.copy_fields": "Kopírovat Pole",
|
||||
"edit.paste_fields": "Vložit Pole",
|
||||
"edit.tag_manager": "Spravovat Tagy",
|
||||
"entries.duplicate.merge": "Sloučit Duplicitní Položky",
|
||||
"entries.duplicate.merge.label": "Slučování Duplicitních Položek...",
|
||||
"entries.duplicate.refresh": "Obnovit Duplicitní Položky",
|
||||
"entries.duplicates.description": "Duplicitní položky jsou definovány jako více položek, které ukazují na stejný soubor na disku. Jejich sloučením se spojí značky a metadata ze všech duplikátů do jediné konsolidované položky. Nesmí se zaměňovat s „duplicitními soubory“, což jsou duplikáty samotných vašich souborů mimo TagStudio.",
|
||||
"entries.mirror.confirmation": "Opravdu chcete zrcadlit následujících {count} položek?",
|
||||
"entries.unlinked.relink.manual": "Znovu propojit ručně",
|
||||
"entries.unlinked.relink.title": "Propojuji záznamy",
|
||||
"entries.unlinked.scanning": "Skenuji knihovnu pro nepropojené záznamy...",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"about.config_path": "Konfigurations-Pfad",
|
||||
"about.description": "TagStudio ist eine Anwendung zum organisieren von Fotos & Dateien mit einem zugrunde liegendem Tag-basierten System, welches sich darauf konzentriert, dem Nutzer Freiraum und Flexibilität zu bieten. Keine proprietären Programme oder Formate, kein Meer an Hilfsdateien und keine komplette Umwälzung deiner Dateisystemstruktur.",
|
||||
"about.description": "TagStudio ist eine Anwendung zum organisieren von Fotos und Dateien mit einem zugrunde liegendem Tag-basierten System, welches sich darauf konzentriert, dem Nutzer Freiraum und Flexibilität zu bieten. Keine proprietären Programme oder Formate, kein Meer an Hilfsdateien und keine komplette Umwälzung deiner Dateisystemstruktur.",
|
||||
"about.documentation": "Dokumentation",
|
||||
"about.license": "Lizenz",
|
||||
"about.title": "Über TagStudio",
|
||||
@@ -66,6 +66,7 @@
|
||||
"file.date_added": "Hinzufügungsdatum",
|
||||
"file.date_created": "Erstellungsdatum",
|
||||
"file.date_modified": "Datum geändert",
|
||||
"file.path": "Dateipfad",
|
||||
"file.dimensions": "Abmessungen",
|
||||
"file.duplicates.description": "TagStudio unterstützt das Importieren von DupeGuru-Ergebnissen um Dateiduplikate zu verwalten.",
|
||||
"file.duplicates.dupeguru.advice": "Nach dem Kopiervorgang kann DupeGuru benutzt werden und ungewollte Dateien zu löschen. Anschließend kann TagStudios \"Unverknüpfte Einträge reparieren\" Funktion im \"Werkzeuge\" Menü benutzt werden um die nicht verknüpften Einträge zu löschen.",
|
||||
@@ -220,11 +221,18 @@
|
||||
"select.all": "Alle auswählen",
|
||||
"select.clear": "Auswahl leeren",
|
||||
"settings.clear_thumb_cache.title": "Vorschaubild-Zwischenspeicher leeren",
|
||||
"settings.global": "Globale Einstellungen",
|
||||
"settings.language": "Sprache",
|
||||
"settings.library": "Bibliothekseinstellungen",
|
||||
"settings.open_library_on_start": "Bibliothek zum Start öffnen",
|
||||
"settings.page_size": "Elemente pro Seite",
|
||||
"settings.restart_required": "Bitte TagStudio neustarten, um Änderungen anzuwenden.",
|
||||
"settings.show_filenames_in_grid": "Dateinamen in Raster darstellen",
|
||||
"settings.show_recent_libraries": "Zuletzt verwendete Bibliotheken anzeigen",
|
||||
"settings.theme.dark": "Dunkel",
|
||||
"settings.theme.label": "Design:",
|
||||
"settings.theme.light": "Hell",
|
||||
"settings.theme.system": "System",
|
||||
"settings.title": "Einstellungen",
|
||||
"sorting.direction.ascending": "Aufsteigend",
|
||||
"sorting.direction.descending": "Absteigend",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"about.config_path": "Config Path",
|
||||
"about.description": "TagStudio is a photo & file organization application with an underlying tag-based system that focuses on giving freedom and flexibility to the user. No proprietary programs or formats, no sea of sidecar files, and no complete upheaval of your filesystem structure.",
|
||||
"about.description": "TagStudio is a photo and file organization application with an underlying tag-based system that focuses on giving freedom and flexibility to the user. No proprietary programs or formats, no sea of sidecar files, and no complete upheaval of your filesystem structure.",
|
||||
"about.documentation": "Documentation",
|
||||
"about.license": "License",
|
||||
"about.module.found": "Found",
|
||||
@@ -23,6 +23,7 @@
|
||||
"color.primary": "Primary Color",
|
||||
"color.secondary": "Secondary Color",
|
||||
"color.title.no_color": "No Color",
|
||||
"dependency.missing.title": "{dependency} Not Found",
|
||||
"drop_import.description": "The following files match file paths that already exist in the library",
|
||||
"drop_import.duplicates_choice.plural": "The following {count} files match file paths that already exist in the library.",
|
||||
"drop_import.duplicates_choice.singular": "The following file matches a file path that already exists in the library.",
|
||||
@@ -62,12 +63,15 @@
|
||||
"entries.unlinked.scanning": "Scanning Library for Unlinked Entries...",
|
||||
"entries.unlinked.search_and_relink": "&Search && Relink",
|
||||
"entries.unlinked.title": "Fix Unlinked Entries",
|
||||
"ffmpeg.missing.description": "FFmpeg and/or FFprobe were not found. FFmpeg is required for multimedia playback and thumbnails.",
|
||||
"ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}<br>{ffprobe}: {ffprobe_status}",
|
||||
"field.copy": "Copy Field",
|
||||
"field.edit": "Edit Field",
|
||||
"field.paste": "Paste Field",
|
||||
"file.date_added": "Date Added",
|
||||
"file.date_created": "Date Created",
|
||||
"file.date_modified": "Date Modified",
|
||||
"file.path": "File Path",
|
||||
"file.dimensions": "Dimensions",
|
||||
"file.duplicates.description": "TagStudio supports importing DupeGuru results to manage duplicate files.",
|
||||
"file.duplicates.dupeguru.advice": "After mirroring, you're free to use DupeGuru to delete the unwanted files. Afterwards, use TagStudio's \"Fix Unlinked Entries\" feature in the Tools menu in order to delete the unlinked Entries.",
|
||||
@@ -84,9 +88,6 @@
|
||||
"file.not_found": "File Not Found",
|
||||
"file.open_file_with": "Open file with",
|
||||
"file.open_file": "Open file",
|
||||
"file.open_files.title.plural": "Open Selected Files",
|
||||
"file.open_files.title.singular": "Open Selected File",
|
||||
"file.open_files.warning": "Are you sure you want to open {count} files?",
|
||||
"file.open_location.generic": "Show file in file explorer",
|
||||
"file.open_location.mac": "Reveal in Finder",
|
||||
"file.open_location.windows": "Show in File Explorer",
|
||||
@@ -115,7 +116,6 @@
|
||||
"generic.navigation.back": "Back",
|
||||
"generic.navigation.next": "Next",
|
||||
"generic.none": "None",
|
||||
"generic.open": "Open",
|
||||
"generic.overwrite_alt": "&Overwrite",
|
||||
"generic.overwrite": "Overwrite",
|
||||
"generic.paste": "Paste",
|
||||
@@ -187,6 +187,7 @@
|
||||
"macros.running.dialog.new_entries": "Running Configured Macros on {count}/{total} New File Entries...",
|
||||
"macros.running.dialog.title": "Running Macros on New Entries",
|
||||
"media_player.autoplay": "Autoplay",
|
||||
"media_player.loop": "Loop",
|
||||
"menu.delete_selected_files_ambiguous": "Move File(s) to {trash_term}",
|
||||
"menu.delete_selected_files_plural": "Move Files to {trash_term}",
|
||||
"menu.delete_selected_files_singular": "Move File to {trash_term}",
|
||||
@@ -197,6 +198,8 @@
|
||||
"menu.edit": "Edit",
|
||||
"menu.file.clear_recent_libraries": "Clear Recent",
|
||||
"menu.file.close_library": "&Close Library",
|
||||
"menu.file.missing_library.message": "The location of the library \"{library}\" cannot be found.",
|
||||
"menu.file.missing_library.title": "Missing Library",
|
||||
"menu.file.new_library": "New Library",
|
||||
"menu.file.open_create_library": "&Open/Create Library",
|
||||
"menu.file.open_library": "Open Library",
|
||||
@@ -227,11 +230,22 @@
|
||||
"select.all": "Select All",
|
||||
"select.clear": "Clear Selection",
|
||||
"settings.clear_thumb_cache.title": "Clear Thumbnail Cache",
|
||||
"settings.filepath.label": "Filepath Visibility",
|
||||
"settings.filepath.option.full": "Show Full Paths",
|
||||
"settings.filepath.option.name": "Show Filenames Only",
|
||||
"settings.filepath.option.relative": "Show Relative Paths",
|
||||
"settings.global": "Global Settings",
|
||||
"settings.language": "Language",
|
||||
"settings.library": "Library Settings",
|
||||
"settings.open_library_on_start": "Open Library on Start",
|
||||
"settings.page_size": "Page Size",
|
||||
"settings.restart_required": "Please restart TagStudio for changes to take effect.",
|
||||
"settings.show_filenames_in_grid": "Show Filenames in Grid",
|
||||
"settings.show_recent_libraries": "Show Recent Libraries",
|
||||
"settings.theme.dark": "Dark",
|
||||
"settings.theme.label": "Theme:",
|
||||
"settings.theme.light": "Light",
|
||||
"settings.theme.system": "System",
|
||||
"settings.title": "Settings",
|
||||
"sorting.direction.ascending": "Ascending",
|
||||
"sorting.direction.descending": "Descending",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
{
|
||||
"about.config_path": "Archivo de configuración",
|
||||
"about.description": "TagStudio es una aplicación de fotografías y archivos con un sistema de etiquetas subyacentes que se centra en dar libertad y flexibilidad al usuario. Sin programas ni formatos propios, ni un mar de archivos y sin trastornar completamente tu sistema de estructurar los archivos.",
|
||||
"about.description": "TagStudio es una aplicación para organizar fotografías y archivos que utiliza un sistema de etiquetas subyacentes centrado en dar libertad y flexibilidad al usuario. Sin programas ni formatos propios, ni un mar de archivos y sin trastornar completamente tu sistema de estructurar los archivos.",
|
||||
"about.documentation": "Documentación",
|
||||
"about.license": "Licencia",
|
||||
"about.title": "Acerca de",
|
||||
"about.module.found": "Encontrado",
|
||||
"about.title": "Acerca de TagStudio",
|
||||
"about.website": "Página web",
|
||||
"app.git": "Git Commit",
|
||||
"app.pre_release": "Previas al lanzamiento",
|
||||
"app.title": "{base_title} - Biblioteca '{library_dir}'",
|
||||
@@ -21,6 +23,7 @@
|
||||
"color.secondary": "Color secundario",
|
||||
"color.title.no_color": "Sin color",
|
||||
"color_manager.title": "Administrar los colores de las etiquetas",
|
||||
"dependency.missing.title": "{dependency} no encontrada",
|
||||
"drop_import.description": "Los siguientes archivos igualan con las rutas de archivos que ya existen en la biblioteca",
|
||||
"drop_import.duplicates_choice.plural": "Los siguientes {count} archivos igualan con las rutas de archivos que ya existen en la biblioteca.",
|
||||
"drop_import.duplicates_choice.singular": "El siguiente archivo iguala con la ruta de archivo que ya existe en la biblioteca.",
|
||||
@@ -60,6 +63,8 @@
|
||||
"entries.unlinked.scanning": "Buscando entradas no enlazadas en la biblioteca...",
|
||||
"entries.unlinked.search_and_relink": "&Buscar && volver a vincular",
|
||||
"entries.unlinked.title": "Corregir entradas no vinculadas",
|
||||
"ffmpeg.missing.description": "No se ha encontrado FFmpeg y/o FFprobe. Se requiere de FFmpeg para la reproducción de contenido multimedia y las miniaturas.",
|
||||
"ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}<br>{ffprobe}: {ffprobe_status}",
|
||||
"field.copy": "Copiar campo",
|
||||
"field.edit": "Editar campo",
|
||||
"field.paste": "Pegar campo",
|
||||
@@ -85,6 +90,7 @@
|
||||
"file.open_location.generic": "Abrir archivo en el Explorador",
|
||||
"file.open_location.mac": "Abrir en el Finder",
|
||||
"file.open_location.windows": "Abrir en el Explorador",
|
||||
"file.path": "Ruta de archivo",
|
||||
"folders_to_tags.close_all": "Cerrar todo",
|
||||
"folders_to_tags.converting": "Convertir carpetas en etiquetas",
|
||||
"folders_to_tags.description": "Crea etiquetas basadas en su estructura de carpetas y las aplica a sus entradas.\nLa siguiente estructura muestra todas las etiquetas que se crearán y a qué entradas se aplicarán.",
|
||||
@@ -102,10 +108,11 @@
|
||||
"generic.delete": "Eliminar",
|
||||
"generic.delete_alt": "&Eliminar",
|
||||
"generic.done": "Terminado",
|
||||
"generic.done_alt": "&Terminado",
|
||||
"generic.done_alt": "&Hecho",
|
||||
"generic.edit": "Editar",
|
||||
"generic.edit_alt": "&Editar",
|
||||
"generic.filename": "Nombre de archivo",
|
||||
"generic.missing": "Ausente",
|
||||
"generic.navigation.back": "Volver",
|
||||
"generic.navigation.next": "Continuar",
|
||||
"generic.none": "Ninguno",
|
||||
@@ -220,6 +227,10 @@
|
||||
"select.all": "Seleccionar todo",
|
||||
"select.clear": "Borrar selección",
|
||||
"settings.clear_thumb_cache.title": "Borrar cache de las miniaturas",
|
||||
"settings.filepath.label": "Visualización ruta de archivos",
|
||||
"settings.filepath.option.full": "Mostrar rutas completas",
|
||||
"settings.filepath.option.name": "Mostrar sólo el nombre",
|
||||
"settings.filepath.option.relative": "Mostrar rutas relativas",
|
||||
"settings.language": "Idioma",
|
||||
"settings.open_library_on_start": "Abrir biblioteca al iniciar",
|
||||
"settings.restart_required": "Por favor, reinicia TagStudio para que se los cambios surtan efecto.",
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"about.config_path": "Chemin de Configuration",
|
||||
"about.description": "TagStudio est une application d'organisation de photos et de fichiers avec un système de tags qui mets en avant la liberté et flexibilité à l'utilisateur. Pas de programmes ou de formats propriétaires, pas la moindre trace de fichiers secondaires, et pas de bouleversement complet de la structure de votre système de fichiers.",
|
||||
"about.documentation": "Documentation",
|
||||
"about.license": "Licence",
|
||||
"about.module.found": "Trouver",
|
||||
"about.title": "À propos de TagStudio",
|
||||
"about.website": "Site Internet",
|
||||
"app.git": "Git Commit",
|
||||
"app.pre_release": "Version Préliminaire",
|
||||
"app.title": "{base_title} - Bibliothèque '{library_dir}'",
|
||||
@@ -19,6 +23,7 @@
|
||||
"color.secondary": "Couleur Secondaire",
|
||||
"color.title.no_color": "Aucune couleur",
|
||||
"color_manager.title": "Gérer la Couleur des Tags",
|
||||
"dependency.missing.title": "{dependency} Manquante",
|
||||
"drop_import.description": "Les fichiers suivants correspondent à des chemins de fichiers déjà existant dans la bibliothèque",
|
||||
"drop_import.duplicates_choice.plural": "Les chemins d'accès des {count} fichiers suivants existent déjà dans la Bibliothèque.",
|
||||
"drop_import.duplicates_choice.singular": "Le fichier suivant correspond a un chemin d'accès déjà existant dans la bibliothèque.",
|
||||
@@ -58,6 +63,8 @@
|
||||
"entries.unlinked.scanning": "Balayage de la Bibliothèque pour trouver des Entrées non Liées...",
|
||||
"entries.unlinked.search_and_relink": "&Rechercher && Relier",
|
||||
"entries.unlinked.title": "Réparation des Entrées non Liées",
|
||||
"ffmpeg.missing.description": "FFmpeg et/ou FFprobe n’ont pas été trouvée. FFmpeg est nécessaire pour la lecture de média et les vignettes.",
|
||||
"ffmpeg.missing.status": "{ffmpeg} : {ffmpeg_status}<br>{ffprobe} : {ffprobe_status}",
|
||||
"field.copy": "Copier le Champ",
|
||||
"field.edit": "Modifier le Champ",
|
||||
"field.paste": "Coller le Champ",
|
||||
@@ -83,6 +90,7 @@
|
||||
"file.open_location.generic": "Ouvrir le Fichier dans l'Explorateur de Fichier",
|
||||
"file.open_location.mac": "Montrer dans le Finder",
|
||||
"file.open_location.windows": "Montrer dans l'explorateur de Fichiers",
|
||||
"file.path": "Chemin du Fichier",
|
||||
"folders_to_tags.close_all": "Tout Fermer",
|
||||
"folders_to_tags.converting": "Conversion des dossiers en Tags",
|
||||
"folders_to_tags.description": "Créé des Tags basés sur votre arborescence de dossier et les applique à vos entrées.\nLa structure ci-dessous affiche tous les labels qui seront créés et à quelles entrées ils seront appliqués.",
|
||||
@@ -104,6 +112,7 @@
|
||||
"generic.edit": "Éditer",
|
||||
"generic.edit_alt": "&Modifier",
|
||||
"generic.filename": "Nom de fichier",
|
||||
"generic.missing": "Manquant",
|
||||
"generic.navigation.back": "Retour",
|
||||
"generic.navigation.next": "Suivant",
|
||||
"generic.none": "Aucun",
|
||||
@@ -130,7 +139,7 @@
|
||||
"ignore_list.add_extension": "&Ajouter une Extension",
|
||||
"ignore_list.mode.exclude": "Exclure",
|
||||
"ignore_list.mode.include": "Inclure",
|
||||
"ignore_list.mode.label": "Mode de la liste:",
|
||||
"ignore_list.mode.label": "Mode de la liste :",
|
||||
"ignore_list.title": "Extensions de Fichiers",
|
||||
"json_migration.checking_for_parity": "Vérification de la Parité...",
|
||||
"json_migration.creating_database_tables": "Création des Tables de Base de Données SQL...",
|
||||
@@ -138,19 +147,19 @@
|
||||
"json_migration.discrepancies_found": "Divergence Détectées dans la Bibliothèque",
|
||||
"json_migration.discrepancies_found.description": "Des divergences ont été détectées entre le format d'origine et le format converti de la bibliothèque. Veuillez les examiner et choisir de poursuivre la migration ou de l'annuler.",
|
||||
"json_migration.finish_migration": "Terminer la Migration",
|
||||
"json_migration.heading.aliases": "Alias:",
|
||||
"json_migration.heading.colors": "Couleurs:",
|
||||
"json_migration.heading.aliases": "Alias :",
|
||||
"json_migration.heading.colors": "Couleurs :",
|
||||
"json_migration.heading.differ": "Divergence",
|
||||
"json_migration.heading.entires": "Entrées:",
|
||||
"json_migration.heading.extension_list_type": "Type de liste d'extension:",
|
||||
"json_migration.heading.fields": "Champs:",
|
||||
"json_migration.heading.file_extension_list": "Liste des extensions de fichiers:",
|
||||
"json_migration.heading.entires": "Entrées :",
|
||||
"json_migration.heading.extension_list_type": "Type de liste d'extension :",
|
||||
"json_migration.heading.fields": "Champs :",
|
||||
"json_migration.heading.file_extension_list": "Liste des extensions de fichiers :",
|
||||
"json_migration.heading.match": "Correspondant",
|
||||
"json_migration.heading.names": "Noms:",
|
||||
"json_migration.heading.parent_tags": "Tags Parents:",
|
||||
"json_migration.heading.paths": "Chemins:",
|
||||
"json_migration.heading.shorthands": "Abréviations:",
|
||||
"json_migration.heading.tags": "Tags:",
|
||||
"json_migration.heading.names": "Noms :",
|
||||
"json_migration.heading.parent_tags": "Tags Parents :",
|
||||
"json_migration.heading.paths": "Chemins :",
|
||||
"json_migration.heading.shorthands": "Abréviations :",
|
||||
"json_migration.heading.tags": "Tags :",
|
||||
"json_migration.info.description": "Les fichiers de sauvegarde de bibliothèque créés avec les versions <b>9.4 et inférieures</b> de TagStudio devront être migrés vers le nouveau format <b>v9.5+</b>.<br><h2>Ce que vous devez savoir:</h2><ul><li>Votre fichier de sauvegarde de bibliothèque existant ne sera <b><i>PAS</i></b> supprimé</li><li>Vos fichiers personnels ne seront <b><i>PAS</i></b> supprimés, déplacés ou modifié</li><li>Le nouveau format de sauvegarde v9.5+ ne peut pas être ouvert dans les versions antérieures de TagStudio</li></ul><h3>Ce qui a changé:</h3><ul><li>Les \"Tag Fields\" ont été remplacés par \"Tag Categories\". Au lieu d’ajouter d’abord des Tags aux Champs, les Tags sont désormais ajoutées directement aux entrées de fichier. Ils sont ensuite automatiquement organisés en catégories basées sur les tags parent marquées avec la nouvelle propriété \"Is Category\" dans le menu d'édition des tags. N'importe quelle tag peut être marquée comme catégorie, et les tags enfants seront triées sous les tags parents marquées comme catégories. Les tags « Favoris » et « Archivés » héritent désormais d'un nouveaux tag \"Meta Tags\" qui est marquée comme catégorie par défaut.</li><li>La couleur des tags à été modifiées et développées. Certaines couleurs ont été renommées ou consolidées, mais toutes les couleurs des tags seront toujours converties en correspondances exactes ou proches dans la version 9.5.</li></ul><ul>",
|
||||
"json_migration.migrating_files_entries": "Migration de {entries:,d} entrées de fichier.",
|
||||
"json_migration.migration_complete": "Migration Terminée!",
|
||||
@@ -178,6 +187,7 @@
|
||||
"macros.running.dialog.new_entries": "Exécution des Macros Configurées sur {count}/{total} Nouvelles Entrées de Fichiers...",
|
||||
"macros.running.dialog.title": "Exécution des Macros sur les Nouvelles Entrées",
|
||||
"media_player.autoplay": "Lecture automatique",
|
||||
"media_player.loop": "Lire en boucle",
|
||||
"menu.delete_selected_files_ambiguous": "Déplacer les Fichier(s) vers {trash_term}",
|
||||
"menu.delete_selected_files_plural": "Déplacer les Fichiers vers {trash_term}",
|
||||
"menu.delete_selected_files_singular": "Déplacer le Fichier vers {trash_term}",
|
||||
@@ -189,7 +199,9 @@
|
||||
"menu.file": "&Fichier",
|
||||
"menu.file.clear_recent_libraries": "Supprimer l'historique de Bibliothèque récente",
|
||||
"menu.file.close_library": "&Fermer la Bibliothèque",
|
||||
"menu.file.new_library": "Nouvelle Bibliothéque",
|
||||
"menu.file.missing_library.message": "L'emplacement de la bibliothèque \"{library}\" n'a pas été trouvée.",
|
||||
"menu.file.missing_library.title": "Bibliothèque Manquante",
|
||||
"menu.file.new_library": "Nouvelle Bibliothèque",
|
||||
"menu.file.open_create_library": "&Ouvrir/Créer une Bibliothèque",
|
||||
"menu.file.open_library": "Ouvrir la Bibliothèque",
|
||||
"menu.file.open_recent_library": "Ouvrir la Bibliothèque récente",
|
||||
@@ -218,11 +230,22 @@
|
||||
"select.all": "Tout Sélectionner",
|
||||
"select.clear": "Effacer la Sélection",
|
||||
"settings.clear_thumb_cache.title": "Effacer le cache des vignettes",
|
||||
"settings.filepath.label": "Visibilités du Chemin de Fichier",
|
||||
"settings.filepath.option.full": "Afficher le Chemin Complet",
|
||||
"settings.filepath.option.name": "Afficher Seulement le Nom du Fichier",
|
||||
"settings.filepath.option.relative": "Afficher le Chemin Relatif",
|
||||
"settings.global": "Paramètres Globaux",
|
||||
"settings.language": "Langage",
|
||||
"settings.library": "Paramètres de la Bibliothèque",
|
||||
"settings.open_library_on_start": "Ouvrir la Bibliothèque au Démarrage",
|
||||
"settings.page_size": "Entités par page",
|
||||
"settings.restart_required": "Veuillez redémarré TagStudio pour que les changements prenne effet.",
|
||||
"settings.show_filenames_in_grid": "Afficher les Noms de Fichiers en Grille",
|
||||
"settings.show_recent_libraries": "Afficher les Bibliothèques Récentes",
|
||||
"settings.theme.dark": "Sombre",
|
||||
"settings.theme.label": "Thème :",
|
||||
"settings.theme.light": "Clair",
|
||||
"settings.theme.system": "Système",
|
||||
"settings.title": "Paramètres",
|
||||
"sorting.direction.ascending": "Croissant",
|
||||
"sorting.direction.descending": "Décroissant",
|
||||
@@ -266,7 +289,7 @@
|
||||
"tag.search_for_tag": "Recherche de Label",
|
||||
"tag.shorthand": "Abrégé",
|
||||
"tag.tag_name_required": "Nom du Tag (Requis)",
|
||||
"tag.view_limit": "Limite d'affichage:",
|
||||
"tag.view_limit": "Limite d'affichage :",
|
||||
"tag_manager.title": "Tags de la Bibliothèque",
|
||||
"trash.context.ambiguous": "Déplacer les fichier(s) vers {trash_term}",
|
||||
"trash.context.plural": "Déplacer les fichiers vers {trash_term}",
|
||||
@@ -275,7 +298,7 @@
|
||||
"trash.dialog.disambiguation_warning.singular": "Cela le retirera de TagStudio <i>ET</i> de votre système de fichier!",
|
||||
"trash.dialog.move.confirmation.plural": "Voulez vous vraiment déplacer {count} fichiers vers la {trash_term}?",
|
||||
"trash.dialog.move.confirmation.singular": "Voulez vous vraiment déplacer ce fichier vers la {trash_term}?",
|
||||
"trash.dialog.permanent_delete_warning": "<b>ATTENTION!</b> Si le fichier ne peut pas être déplacer vers la {trash_term}, <b>elle sera<b>supprimer de manière permanente!</b>",
|
||||
"trash.dialog.permanent_delete_warning": "<b>ATTENTION!</b> Si le fichier ne peut pas être déplacer vers la {trash_term}, elle sera <b>supprimer de manière permanente!</b>",
|
||||
"trash.dialog.title.plural": "Supprimer les Fichiers",
|
||||
"trash.dialog.title.singular": "Supprimer le Fichier",
|
||||
"trash.name.generic": "Poubelle",
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
"about.description": "A TagStudio egy fénykép- és fájlkezelő program, mely címkék segítségével nyújt felhasználói szabadságot és rugalmasságot. A TagStudio nem használ jogvédett formátumokat, társfájlokat és nem fordítja a feje tetejére a már létező fájlrendszert.",
|
||||
"about.documentation": "Dokumentáció",
|
||||
"about.license": "Licenc",
|
||||
"about.module.found": "Telepítve",
|
||||
"about.title": "A TagStudio névjegye",
|
||||
"about.website": "Honlap",
|
||||
"app.git": "Git-véglegesítés",
|
||||
"app.pre_release": "Kísérleti verzió",
|
||||
"app.title": "{base_title} – Könyvtár: „{library_dir}”",
|
||||
@@ -21,6 +23,7 @@
|
||||
"color.secondary": "Másodlagos szín",
|
||||
"color.title.no_color": "Színtelen",
|
||||
"color_manager.title": "Színek kezelése",
|
||||
"dependency.missing.title": "A(z) {dependency} nem található",
|
||||
"drop_import.description": "Az alábbi fájlok elérési útvonala már foglaltak a könyvtárban",
|
||||
"drop_import.duplicates_choice.plural": "Az alábbi {count} fájl elérési útvonala már szerepel a könyvtárban.",
|
||||
"drop_import.duplicates_choice.singular": "Az alábbi fájl elérési útvonala már szerepel a könyvtárban.",
|
||||
@@ -60,6 +63,8 @@
|
||||
"entries.unlinked.scanning": "Kapcsolat nélküli elemek keresése a könyvtárban…",
|
||||
"entries.unlinked.search_and_relink": "&Keresés és újra összekapcsolás",
|
||||
"entries.unlinked.title": "Kapcsolat nélküli elemek javítása",
|
||||
"ffmpeg.missing.description": "Az FFmpeg és/vagy az FFprobe nem található. Az FFmpeg megléte szükséges a videó- és hangfájlok lejátszásához és a miniatűrök megjelenítéséhez.",
|
||||
"ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}<br>{ffprobe}: {ffprobe_status}",
|
||||
"field.copy": "Mező &másolása",
|
||||
"field.edit": "Mező szerkesztése",
|
||||
"field.paste": "Mező &beillesztése",
|
||||
@@ -85,6 +90,7 @@
|
||||
"file.open_location.generic": "Fájl megnyitása Intézőben",
|
||||
"file.open_location.mac": "Megnyitás Finderben",
|
||||
"file.open_location.windows": "Megnyitás Intézőben",
|
||||
"file.path": "Elérési út",
|
||||
"folders_to_tags.close_all": "Az összes ö&sszecsukása",
|
||||
"folders_to_tags.converting": "Mappák címkékké alakítása",
|
||||
"folders_to_tags.description": "Címkék automatikus létrehozása a létező mappastruktúra alapján.\nAz alábbi mappafán megtekintheti a létrehozandó címkéket, és hogy mely elemekre lesznek alkalmazva.",
|
||||
@@ -106,6 +112,7 @@
|
||||
"generic.edit": "Szerkesztés",
|
||||
"generic.edit_alt": "S&zerkesztés",
|
||||
"generic.filename": "Fájlnév",
|
||||
"generic.missing": "Nem található",
|
||||
"generic.navigation.back": "Vissza",
|
||||
"generic.navigation.next": "Tovább",
|
||||
"generic.none": "Nincs",
|
||||
@@ -180,6 +187,7 @@
|
||||
"macros.running.dialog.new_entries": "Korábban beállított makrók futtatása {total}/{count} új elemen…",
|
||||
"macros.running.dialog.title": "Makrók futtatása az új elemeken",
|
||||
"media_player.autoplay": "Automatikus lejátszás",
|
||||
"media_player.loop": "Ismétlés",
|
||||
"menu.delete_selected_files_ambiguous": "Fájl(ok) {trash_term} &helyezése",
|
||||
"menu.delete_selected_files_plural": "Fájlok {trash_term} &helyezése",
|
||||
"menu.delete_selected_files_singular": "Fájl {trash_term} &helyezése",
|
||||
@@ -191,6 +199,8 @@
|
||||
"menu.file": "&Fájl",
|
||||
"menu.file.clear_recent_libraries": "&Legutóbbi könyvtárak listájának törlése",
|
||||
"menu.file.close_library": "Könyvtár be&zárása",
|
||||
"menu.file.missing_library.message": "A(z) „{library}”-könyvtár nem található.",
|
||||
"menu.file.missing_library.title": "Hiányzó könyvtár",
|
||||
"menu.file.new_library": "Új könyvtár",
|
||||
"menu.file.open_create_library": "Könyvtár meg&nyitása/létrehozása",
|
||||
"menu.file.open_library": "Könyvtár megnyitása",
|
||||
@@ -220,11 +230,22 @@
|
||||
"select.all": "&Az összes kijelölése",
|
||||
"select.clear": "&Kijelölés megszüntetése",
|
||||
"settings.clear_thumb_cache.title": "&Miniatűr-gyorsítótár ürítése",
|
||||
"settings.filepath.label": "Elérési utak láthatósága",
|
||||
"settings.filepath.option.full": "Teljes elérési út mutatása",
|
||||
"settings.filepath.option.name": "Csak a fájlnév mutatása",
|
||||
"settings.filepath.option.relative": "Relatív elérési út mutatása",
|
||||
"settings.global": "Globális beállítások",
|
||||
"settings.language": "Nyelv",
|
||||
"settings.library": "Könyvtárbeállítások",
|
||||
"settings.open_library_on_start": "&Könyvtár megnyitása a program indulásakor",
|
||||
"settings.page_size": "Oldalméret",
|
||||
"settings.restart_required": "A módosítások érvénybeléptetéséhez<br>újra kell indítani a TagStudiót.",
|
||||
"settings.show_filenames_in_grid": "&Fájlnevek megjelenítése rácsnézetben",
|
||||
"settings.show_recent_libraries": "&Legutóbbi könyvtárak megjelenítése",
|
||||
"settings.theme.dark": "Sötét",
|
||||
"settings.theme.label": "Téma:",
|
||||
"settings.theme.light": "Világos",
|
||||
"settings.theme.system": "Automatikus",
|
||||
"settings.title": "Beállítások",
|
||||
"sorting.direction.ascending": "Növekvő sorrend",
|
||||
"sorting.direction.descending": "Csökkenő sorrend",
|
||||
|
||||
160
src/tagstudio/resources/translations/ja.json
Normal file
160
src/tagstudio/resources/translations/ja.json
Normal file
@@ -0,0 +1,160 @@
|
||||
{
|
||||
"about.config_path": "設定ファイルのパス",
|
||||
"about.description": "TagStudioは、自由と柔軟性を提供することを目指したタグベースのシステムを基盤とした画像・ファイル整理アプリケーションです。専売のプログラムやフォーマット、サイドカーファイルの海、ファイルシステム構造の大混乱はもうありません。",
|
||||
"about.documentation": "ドキュメント",
|
||||
"about.license": "ライセンス",
|
||||
"about.module.found": "インストール済み",
|
||||
"about.title": "TagStudio について",
|
||||
"about.website": "ウェブサイト",
|
||||
"app.git": "Git コミット",
|
||||
"app.pre_release": "公開前",
|
||||
"app.title": "{base_title} - ライブラリ '{library_dir}'",
|
||||
"color.color_border": "境界線にアクセントカラーを使う",
|
||||
"color.confirm_delete": "\"{color_name}\" を本当に削除しますか?",
|
||||
"color.delete": "タグを削除",
|
||||
"color.import_pack": "色のパックをインポート",
|
||||
"color.name": "名前",
|
||||
"color.namespace.delete.prompt": "この色の名前空間を本当に削除しますか? 削除した場合、名前空間内のすべての色も一緒に削除されます!",
|
||||
"color.namespace.delete.title": "色の名前空間を削除",
|
||||
"color.new": "新しい色",
|
||||
"color.placeholder": "色",
|
||||
"color.primary": "メインカラー",
|
||||
"color.primary_required": "メインカラー (必須)",
|
||||
"color.secondary": "アクセントカラー",
|
||||
"color.title.no_color": "色なし",
|
||||
"color_manager.title": "タグの色を管理",
|
||||
"drop_import.description": "以下のファイルはすでにライブラリに存在するファイルパスにマッチします",
|
||||
"drop_import.duplicates_choice.plural": "以下の {count} 件のファイルはすでにライブラリに存在するファイルパスにマッチします。",
|
||||
"drop_import.duplicates_choice.singular": "次のファイルはすでにライブラリに存在するファイルパスにマッチします。",
|
||||
"drop_import.progress.label.initial": "新しいファイルのインポート中…",
|
||||
"drop_import.progress.label.plural": "新しいファイルのインポート中…\n{count} 件インポート済み。{suffix}",
|
||||
"drop_import.progress.label.singular": "新しいファイルをインポート中…\n1 件インポート済み。{suffix}",
|
||||
"drop_import.progress.window_title": "ファイルをインポート",
|
||||
"drop_import.title": "競合ファイル",
|
||||
"edit.color_manager": "タグの色を管理",
|
||||
"edit.copy_fields": "フィールドをコピー",
|
||||
"edit.paste_fields": "フィールドを貼り付け",
|
||||
"edit.tag_manager": "タグを管理",
|
||||
"entries.duplicate.merge": "重複エントリをマージ",
|
||||
"entries.duplicate.merge.label": "重複エントリをマージ中…",
|
||||
"entries.duplicate.refresh": "重複エントリを最新の状態にする",
|
||||
"entries.duplicates.description": "重複エントリとは、ディスク上の同じファイルを指す複数のエントリを指します。これらをマージすると、すべての重複エントリのタグとメタデータが1つのまとまったエントリに統合されます。TagStudio の外部にあるファイル自体の複製である「重複ファイル」と混同しないようにご注意ください。",
|
||||
"entries.mirror": "ミラー(&m)",
|
||||
"entries.mirror.confirmation": "以下 {count} 件のエントリを本当にミラーしますか?",
|
||||
"entries.mirror.label": "{total} 件中 {idx} 件のエントリをミラー中…",
|
||||
"entries.mirror.title": "エントリをミラー",
|
||||
"entries.mirror.window_title": "エントリをミラー",
|
||||
"entries.running.dialog.new_entries": "新しいファイルエントリを {total} 件追加中…",
|
||||
"entries.running.dialog.title": "新しいファイルエントリを追加",
|
||||
"entries.tags": "タグ",
|
||||
"entries.unlinked.delete": "リンクされていないエントリを削除",
|
||||
"entries.unlinked.delete.confirm": "以下の {count} 件のエントリを本当に削除しますか?",
|
||||
"entries.unlinked.delete.deleting": "エントリの削除",
|
||||
"entries.unlinked.delete.deleting_count": "{count} 件中 {idx} 件のリンクされていないエントリを削除中",
|
||||
"entries.unlinked.delete_alt": "リンクされていないエントリを削除(&l)",
|
||||
"entries.unlinked.description": "ライブラリの各エントリは、ディレクトリ内のファイルにリンクされています。エントリにリンクされたファイルがTagStudio外で移動または削除された場合、リンクされていないとみなされます。<br><br>リンクされていないエントリは、ディレクトリを検索して自動的に再リンクするか、必要に応じて削除することができます。",
|
||||
"entries.unlinked.missing_count.none": "リンクされていないエントリ数: N/A",
|
||||
"entries.unlinked.missing_count.some": "リンクされていないエントリ数: {count}",
|
||||
"entries.unlinked.refresh_all": "すべて再読み込み(&r)",
|
||||
"entries.unlinked.relink.attempting": "{missing_count} 件中 {idx} 件の再リンクを試行し {fixed_count} 件の再リンクに成功",
|
||||
"entries.unlinked.relink.manual": "手動で再リンク(&m)",
|
||||
"entries.unlinked.relink.title": "エントリの再リンク",
|
||||
"entries.unlinked.scanning": "リンクされていないエントリのためにライブラリをスキャン中…",
|
||||
"entries.unlinked.search_and_relink": "検索して再リンク(&s)",
|
||||
"entries.unlinked.title": "リンクされていないエントリを修正",
|
||||
"field.copy": "フィールドをコピー",
|
||||
"field.edit": "フィールドを編集",
|
||||
"field.paste": "フィールドを貼り付け",
|
||||
"file.date_added": "追加日",
|
||||
"file.date_created": "作成日",
|
||||
"file.date_modified": "更新日",
|
||||
"file.dimensions": "ディメンション",
|
||||
"file.duplicates.description": "TagStudio は重複ファイルの管理のために DupeGuru の結果のインポートをサポートしています。",
|
||||
"file.duplicates.dupeguru.advice": "ミラ ーした後、DupeGuru を使って不要なファイルを削除することができます。その後、TagStudio のツールメニューにある「リンクされていないエントリを修正」機能を使って、リンクされていないエントリを削除します。",
|
||||
"file.duplicates.dupeguru.file_extension": "DupeGuru ファイル (*.dupeguru)",
|
||||
"file.duplicates.dupeguru.load_file": "DupeGuru ファイルの読み込み(&l)",
|
||||
"file.duplicates.dupeguru.no_file": "DupeGuru ファイルが選択されていません",
|
||||
"file.duplicates.dupeguru.open_file": "DupeGuru 結果ファイルを開く",
|
||||
"file.duplicates.fix": "重複ファイルを修正",
|
||||
"file.duplicates.matches": "重複ファイルのマッチ数: {count}",
|
||||
"file.duplicates.matches_uninitialized": "重複ファイルのマッチ数: N/A",
|
||||
"file.duplicates.mirror.description": "重複する各マッチセットでエントリデータをミラーしてすべてのデータを結合しますが、フィールドの削除や複製は行いません。この操作によってファイルやデータが削除されることはありません。",
|
||||
"file.duplicates.mirror_entries": "エントリのミラー(&m)",
|
||||
"file.duration": "長さ",
|
||||
"file.not_found": "ファイルが見つかりません",
|
||||
"file.open_file": "ファイルを開く",
|
||||
"file.open_file_with": "ファイルを開くプログラムを指定",
|
||||
"file.open_location.generic": "ファイルの場所をエクスプローラーで開く",
|
||||
"file.open_location.mac": "Finder で見る",
|
||||
"file.open_location.windows": "エクスプローラーで見る",
|
||||
"file.path": "ファイルパス",
|
||||
"folders_to_tags.close_all": "すべて閉じる",
|
||||
"folders_to_tags.converting": "フォルダーをタグに変換",
|
||||
"folders_to_tags.description": "フォルダ構造に基づいてタグを作成し、エントリーに適用します。\n 以下の構造は、作成されるすべてのタグと、それらが適用されるエントリを示しています。",
|
||||
"folders_to_tags.open_all": "すべて開く",
|
||||
"folders_to_tags.title": "フォルダーからタグを作成",
|
||||
"generic.add": "追加",
|
||||
"generic.apply": "適用",
|
||||
"generic.apply_alt": "適用(&a)",
|
||||
"generic.cancel": "キャンセル",
|
||||
"generic.cancel_alt": "キャンセル(&c)",
|
||||
"generic.close": "閉じる",
|
||||
"generic.continue": "継続する",
|
||||
"generic.copy": "コピー",
|
||||
"generic.cut": "切り取り",
|
||||
"generic.delete": "削除",
|
||||
"generic.delete_alt": "削除(&d)",
|
||||
"generic.done": "完了",
|
||||
"generic.done_alt": "完了(&d)",
|
||||
"generic.edit": "編集",
|
||||
"generic.edit_alt": "編集(&e)",
|
||||
"generic.filename": "ファイル名",
|
||||
"generic.missing": "紛失",
|
||||
"generic.navigation.back": "戻る",
|
||||
"generic.navigation.next": "次へ",
|
||||
"generic.none": "なし",
|
||||
"generic.overwrite": "上書き",
|
||||
"generic.overwrite_alt": "上書き(&o)",
|
||||
"generic.paste": "貼り付け",
|
||||
"generic.recent_libraries": "最近使用したライブラリ",
|
||||
"generic.rename": "名前の変更",
|
||||
"generic.rename_alt": "名前の変更(&r)",
|
||||
"generic.reset": "リセット",
|
||||
"generic.save": "保存",
|
||||
"generic.skip": "スキップ",
|
||||
"generic.skip_alt": "スキップ(&s)",
|
||||
"home.search": "検索",
|
||||
"home.search_entries": "エントリを検索",
|
||||
"home.search_library": "ライブラリを検索",
|
||||
"home.search_tags": "タグを検索",
|
||||
"home.thumbnail_size": "サムネイルのサイズ",
|
||||
"home.thumbnail_size.extra_large": "巨大なサムネイル",
|
||||
"home.thumbnail_size.large": "大きいサムネイル",
|
||||
"home.thumbnail_size.medium": "中くらいのサムネイル",
|
||||
"home.thumbnail_size.mini": "極小のサムネイル",
|
||||
"home.thumbnail_size.small": "小さいサムネイル",
|
||||
"ignore_list.add_extension": "拡張子を追加(&a)",
|
||||
"ignore_list.mode.exclude": "含めない",
|
||||
"ignore_list.mode.include": "含める",
|
||||
"ignore_list.mode.label": "リストのモード:",
|
||||
"ignore_list.title": "ファイルの拡張子",
|
||||
"json_migration.checking_for_parity": "パリティチェック中…",
|
||||
"json_migration.creating_database_tables": "SQLデータベース テーブルを作成中…",
|
||||
"json_migration.description": "<br>ライブラリの移行処理を開始し、結果をプレビューします。 「移行完了」をクリックしない限り、変換されたライブラリは<i>使用されません。</i><br><br>ライブラリデータは、値が一致するか、「マッチ」ラベルが表示される必要があります。 一致しない値は赤色で表示され、その横に「<b>(!)</b>」マークが表示されます。<br><center><i>大規模なライブラリの場合、この処理に数分かかることがあります。</i></center>",
|
||||
"json_migration.discrepancies_found": "ライブラリの矛盾が見つかりました",
|
||||
"json_migration.discrepancies_found.description": "元のライブラリ形式と変換後のライブラリ形式の間に矛盾が見つかりました。確認して、移行を続行するかキャンセルするかを選択してください。",
|
||||
"json_migration.finish_migration": "移行完了",
|
||||
"json_migration.heading.aliases": "エイリアス:",
|
||||
"json_migration.heading.colors": "色:",
|
||||
"json_migration.heading.differ": "矛盾",
|
||||
"json_migration.heading.entires": "エントリ:",
|
||||
"json_migration.heading.extension_list_type": "拡張子リストの種類:",
|
||||
"json_migration.heading.fields": "フィールド:",
|
||||
"json_migration.heading.file_extension_list": "ファイルの拡張子リスト:",
|
||||
"json_migration.heading.match": "マッチ",
|
||||
"json_migration.heading.names": "名前:",
|
||||
"json_migration.heading.parent_tags": "親タグ:",
|
||||
"json_migration.heading.paths": "パス:",
|
||||
"json_migration.heading.shorthands": "短縮名:",
|
||||
"json_migration.heading.tags": "タグ:"
|
||||
}
|
||||
@@ -151,6 +151,8 @@
|
||||
"json_migration.migration_complete": "Migração Concluída!",
|
||||
"json_migration.migration_complete_with_discrepancies": "Migração Concluída, Discrepâncias Encontradas",
|
||||
"json_migration.start_and_preview": "Iniciar e Visualizar",
|
||||
"json_migration.title.new_lib": "<h2>Biblioteca v9.5+</h2>",
|
||||
"json_migration.title.old_lib": "<h2>Biblioteca v9.4</h2>",
|
||||
"landing.open_create_library": "Abrir/Criar Biblioteca {shortcut}",
|
||||
"library.field.add": "Adicionar Campo",
|
||||
"library.field.confirm_remove": "Você tem certeza de que quer remover o campo \"{name}\"?",
|
||||
@@ -245,6 +247,7 @@
|
||||
"tag.parent_tags.add": "Adicionar Tag Pai",
|
||||
"tag.search_for_tag": "Procurar por Tag",
|
||||
"tag.shorthand": "Abreviação",
|
||||
"tag.view_limit": "Limite de visualização:",
|
||||
"tag_manager.title": "Tags da sua biblioteca",
|
||||
"trash.context.ambiguous": "Mover arquivo(s) para {trash_term}",
|
||||
"trash.context.plural": "Mover arquivos para {trash_term}",
|
||||
|
||||
68
src/tagstudio/resources/translations/qpv.json
Normal file
68
src/tagstudio/resources/translations/qpv.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"about.config_path": "Mlafuplas fu sentakuzma",
|
||||
"about.description": "TagStudio es riso & parjat fu mlafu zeting ke bruk festaretol, hadafvui per anta sentakuzma au mange dekizma brukdjin made. Nil kinijena zeting os mlafufal, jamnai mare fu flanka mlafu au perpa tumam fu kompyu.",
|
||||
"about.documentation": "Mahaklarazma",
|
||||
"about.license": "Bruk-ruuru",
|
||||
"about.module.found": "Finnajena",
|
||||
"about.title": "Tsui TagStudio",
|
||||
"about.website": "Zelehti",
|
||||
"app.git": "Git Commit",
|
||||
"app.pre_release": "De-Gvir",
|
||||
"app.title": "{base_title} - Mlafuhuomi '{library_dir}'",
|
||||
"color.color_border": "Bruk minusvikti varge per flanka",
|
||||
"color.confirm_delete": "Du kestetsa varge \"{color_name}\"?",
|
||||
"color.delete": "Keste festaretol",
|
||||
"color.import_pack": "Nasii klaani fu varge",
|
||||
"color.name": "Namae",
|
||||
"color.namespace.delete.prompt": "Du kestetsa afto vargetumam? Afto zol keste alting ine vargetumam au sebja!",
|
||||
"color.namespace.delete.title": "Keste vargetumam",
|
||||
"color.new": "Neo varge",
|
||||
"color.placeholder": "Varge",
|
||||
"color.primary": "Lestevikti varge",
|
||||
"color.primary_required": "Lestevikti varge (Trengena)",
|
||||
"color.secondary": "Minusvikti varge",
|
||||
"color.title.no_color": "Nil varge",
|
||||
"color_manager.title": "Jewalt varge fu festaretol",
|
||||
"drop_import.description": "Afto mlafu sama andr mlafuplas ke umdaus ine mlafuhuomi",
|
||||
"drop_import.duplicates_choice.plural": "Afto mlafu {count} sama andr mlafuplas ke umdaus ine mlafuhuomi.",
|
||||
"drop_import.duplicates_choice.singular": "Afto mlafu sama andr mlafuplas ke umdaus ine mlafuhuomi.",
|
||||
"drop_import.progress.label.initial": "Gotova neo mlafu ima...",
|
||||
"drop_import.progress.label.plural": "Gotova neo mlafu ima...\n{count} Mlafu nasiijena.{suffix}",
|
||||
"drop_import.progress.label.singular": "Gotova neo mlafu ima...\n1 Mlafu nasiijena.{suffix}",
|
||||
"drop_import.progress.window_title": "Nasii mlafu",
|
||||
"drop_import.title": "Uslovanaijena mlafu",
|
||||
"edit.color_manager": "Jewalt varge fu festaretol",
|
||||
"edit.copy_fields": "Mverm shiruzmafal",
|
||||
"edit.paste_fields": "Nasii shiru",
|
||||
"edit.tag_manager": "Jewalt festaretol",
|
||||
"entries.duplicate.merge": "Visk sama shiruzmakaban",
|
||||
"entries.duplicate.merge.label": "Visk sama shiruzmakaban ima...",
|
||||
"entries.duplicate.refresh": "Gotova sama shiruzmakaban gen",
|
||||
"entries.duplicates.description": "Mverm fu shiruzmakaban implajena na plus ka ein shiruzmakaban ke tsunaga na sama mlafu na shiruzmabaksu. Na visk afto, zol festa festaretol au mlafushiruzma al mverm fu mlafu kara ine ein stuur shiruzmakaban. Hej nai sama \"mverm fu mlafu\", ke mverm fu mlafu fu du, ekso TagStudio.",
|
||||
"entries.mirror": "&Maha melon fu",
|
||||
"entries.mirror.confirmation": "Du mahatsa melon fu afto {count} shiruzmakaban?",
|
||||
"entries.mirror.label": "Maha melon fu {idx}/{total} shiruzmakaban ima...",
|
||||
"entries.mirror.title": "Maha melon fu shiruzmakaban ima",
|
||||
"entries.mirror.window_title": "Maha melon fu shiruzmakaban",
|
||||
"entries.running.dialog.new_entries": "Nasii {total} neo shiruzmakaban fu mlafu ima...",
|
||||
"entries.running.dialog.title": "Nasii neo shiruzmakaban fu mlafu ima",
|
||||
"entries.tags": "Festaretol",
|
||||
"entries.unlinked.delete": "Keste tsunaganaijena shiruzmakaban",
|
||||
"entries.unlinked.delete.confirm": "Du kestetsa afto {count} shiruzmakaban?",
|
||||
"entries.unlinked.delete.deleting": "Keste shiruzmakaban ima",
|
||||
"entries.unlinked.delete.deleting_count": "Keste {idx}/{count} tsunaganaijena shiruzmakaban ima",
|
||||
"entries.unlinked.delete_alt": "Ke&ste tsunaganaijena shiruzmakaban",
|
||||
"entries.unlinked.description": "Tont shiruzmakaban fu mlafuhuomi tsunagajena na mlafu ine joku mlafukaban fu du. Li mlafu tsunagajena na shiruzmakaban ugokijena os kestejena ekso TagStudio, sit sore tsunaganaijena.<br><br>Tsunaganaijena shiruzmakaban deki tsunaga gen na suha per mlafukaban fu du os keste li du vil.",
|
||||
"entries.unlinked.missing_count.none": "Tsunaganaijena shiruzmakaban: Jamnai",
|
||||
"entries.unlinked.missing_count.some": "Tsunaganaijena shiruzmakaban: {count}",
|
||||
"entries.unlinked.refresh_all": "&Gotova al gen",
|
||||
"entries.unlinked.relink.attempting": "Iskat ima na tsunaga gen {idx}/{missing_count} shiruzmakaban, {fixed_count} tsunagajena gen",
|
||||
"entries.unlinked.relink.manual": "&Tsunaga gen mit hant",
|
||||
"entries.unlinked.relink.title": "Tsunaga shiruzmakaban gen",
|
||||
"entries.unlinked.scanning": "Suha mlafuhuomi ima per tsunaganaijena shiruzmakaban...",
|
||||
"entries.unlinked.search_and_relink": "&Suha &&Tsunaga gen",
|
||||
"entries.unlinked.title": "Fiks tsunaganaijena shiruzmakaban",
|
||||
"field.copy": "Mverm shiruzmafal",
|
||||
"field.edit": "Kawari shiruzmafal",
|
||||
"field.paste": "Nasii shiruzmafal"
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
"about.description": "TagStudio — это приложение для организации фотографий и прочих файлов основанное на системе \"тегов\", которое фокусируется на пользовательских свободе и гибкости. Никаких проприетарных форматов и программ, никакой кучи сопроводительных файлов, и никакого переворота вашей файловой системы.",
|
||||
"about.documentation": "Документация",
|
||||
"about.license": "Лицензия",
|
||||
"about.module.found": "Найдено",
|
||||
"about.title": "О программе TagStudio",
|
||||
"about.website": "Веб-сайт",
|
||||
"app.git": "Коммит Git",
|
||||
"app.pre_release": "Пре-релиз",
|
||||
"app.title": "{base_title} - Библиотека '{library_dir}'",
|
||||
@@ -85,6 +87,7 @@
|
||||
"file.open_location.generic": "Открыть файл в проводнике",
|
||||
"file.open_location.mac": "Показать в поисковике",
|
||||
"file.open_location.windows": "Открыть в проводнике",
|
||||
"file.path": "Путь до файла",
|
||||
"folders_to_tags.close_all": "Закрыть всё",
|
||||
"folders_to_tags.converting": "Конвертировать папки в теги",
|
||||
"folders_to_tags.description": "Создаёт теги для записей согласно имеющейся иерархии папок.\nВнизу указаны все теги, которые будут созданы, а также записи к которым они будут применены.",
|
||||
@@ -134,6 +137,9 @@
|
||||
"ignore_list.mode.include": "Включить",
|
||||
"ignore_list.mode.label": "Список режимов:",
|
||||
"ignore_list.title": "Расширения файлов",
|
||||
"json_migration.creating_database_tables": "Создание таблиц базы данных SQL...",
|
||||
"json_migration.finish_migration": "Завершить миграцию",
|
||||
"json_migration.heading.colors": "Цвета:",
|
||||
"landing.open_create_library": "Открыть/создать библиотеку {shortcut}",
|
||||
"library.field.add": "Добавить поле",
|
||||
"library.field.confirm_remove": "Вы уверены, что хотите удалить поле \"{name}\"?",
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
{
|
||||
"about.description": "ilo Tagstudio li ilo pi lawa lipu pi lawa sitelen li kepeken nasin pi poki pona. ilo Tagstudio li wile pana e lawa mute tawa e jan kepeken. nasin pi open ala li lon ala, en ma pi lipu poka li lon ala, en sina li ante ala e nasin lipu ale sina.",
|
||||
"about.description": "ilo Tagstudio li ilo pi lawa lipu li ilo pi lawa sitelen li kepeken nasin pi poki pona. ilo Tagstudio li wile pana e lawa mute tawa e jan kepeken. nasin pi open ala li lon ala, en ma pi lipu poka li lon ala, en sina li ante ala e nasin lipu ale sina.",
|
||||
"about.documentation": "lipu sona",
|
||||
"about.license": "lipu lawa",
|
||||
"about.module.found": "lukin",
|
||||
"about.title": "sona pi ilo Tagstudio",
|
||||
"about.website": "lipu linluwi",
|
||||
"app.git": "Git Commit",
|
||||
"app.pre_release": "ilo pi pakala lili",
|
||||
"app.pre_release": "nanpa pi pakala mute",
|
||||
"app.title": "{base_title} - tomo '{library_dir}'",
|
||||
"color.color_border": "o kepeken kule nanpa tu lon selo",
|
||||
"color.confirm_delete": "sina wile ala wile weka e kule \"{color_name}\"?",
|
||||
"color.delete": "o weka e poki",
|
||||
"color.name": "nimi",
|
||||
"color.namespace.delete.prompt": "sina wile ala wile weka e ma nimi kule ni? weka la kule ale lon ma ni li weka kin!",
|
||||
"color.namespace.delete.title": "o weka e ma nimi kule",
|
||||
"color.new": "kule sin",
|
||||
"color.placeholder": "kule",
|
||||
"color.primary": "kule nanpa wan",
|
||||
"color.primary_required": "kule nanpa wan (wile mute)",
|
||||
"color.secondary": "kule nanpa tu",
|
||||
"color.title.no_color": "kule ala",
|
||||
"color_manager.title": "o lawa e kule poki",
|
||||
"dependency.missing.title": "mi lukin ala e {dependency}",
|
||||
"drop_import.description": "lipu ni li sama nasin lipu lon tomo",
|
||||
"drop_import.duplicates_choice.plural": "lipu {count} ni li jo e nasin lipu sama lon tomo.",
|
||||
"drop_import.duplicates_choice.singular": "lipu ni li jo e nasin lipu sama lon tomo.",
|
||||
"edit.color_manager": "o lawa e kule poki",
|
||||
"edit.tag_manager": "o lawa e poki",
|
||||
"entries.duplicate.merge": "o wan e ijo sama",
|
||||
@@ -24,6 +36,8 @@
|
||||
"entries.mirror.label": "mi jasima e ijo {idx}/{total}...",
|
||||
"entries.mirror.title": "mi jasima e ijo",
|
||||
"entries.mirror.window_title": "o jasima e ijo",
|
||||
"entries.running.dialog.new_entries": "mi pana e lipu sin {total}...",
|
||||
"entries.running.dialog.title": "mi pana e lipu sin",
|
||||
"entries.tags": "poki",
|
||||
"entries.unlinked.delete": "o weka e ijo pi ijo lon ala",
|
||||
"entries.unlinked.delete.confirm": "mi weka e ijo {count}. ni li pona anu seme?",
|
||||
@@ -38,6 +52,7 @@
|
||||
"entries.unlinked.scanning": "mi o alasa e ijo pi ijo lon ala...",
|
||||
"entries.unlinked.search_and_relink": "o alasa o pana e ijo lon tawa ijo",
|
||||
"entries.unlinked.title": "o pona e ijo pi ijo lon ala",
|
||||
"ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}<br>{ffprobe}: {ffprobe_status}",
|
||||
"field.copy": "o jo e sona sama",
|
||||
"field.edit": "o ante e sona",
|
||||
"field.paste": "o pana e sona sama",
|
||||
@@ -62,6 +77,7 @@
|
||||
"file.open_location.generic": "open e ijo lon ilo alasa",
|
||||
"file.open_location.mac": "o alasa lon ilo Finder",
|
||||
"file.open_location.windows": "o alasa lon ilo File Explorer",
|
||||
"file.path": "nasin lipu",
|
||||
"folders_to_tags.close_all": "o pini e lukin pi ijo ale",
|
||||
"folders_to_tags.converting": "mi o pali e poki tan poki tomo",
|
||||
"folders_to_tags.description": "ni li pali e poki tan poki tona li pana e poki sin tawa ijo lon tomo.\n ilo ni li pali e anpa.",
|
||||
@@ -69,9 +85,11 @@
|
||||
"folders_to_tags.title": "o pali e poki tan poki tomo",
|
||||
"generic.add": "o pana",
|
||||
"generic.apply": "o pana",
|
||||
"generic.apply_alt": "&o pana",
|
||||
"generic.cancel": "o ala",
|
||||
"generic.cancel_alt": "&o ala",
|
||||
"generic.close": "o pini",
|
||||
"generic.continue": "o awen",
|
||||
"generic.continue": "o awen tawa",
|
||||
"generic.copy": "o jo e sona sama",
|
||||
"generic.cut": "o lanpan",
|
||||
"generic.delete": "o weka",
|
||||
@@ -80,12 +98,16 @@
|
||||
"generic.done_alt": "&pona",
|
||||
"generic.edit": "o ante",
|
||||
"generic.edit_alt": "&o ante",
|
||||
"generic.filename": "nimi lipu",
|
||||
"generic.missing": "weka",
|
||||
"generic.navigation.back": "o tawa weka",
|
||||
"generic.navigation.next": "o tawa poka",
|
||||
"generic.none": "ala",
|
||||
"generic.paste": "o pana e sona sama",
|
||||
"generic.recent_libraries": "tomo pi tenpo poka",
|
||||
"generic.rename": "o nimi sin",
|
||||
"generic.rename_alt": "&o nimi sin",
|
||||
"generic.reset": "o open sin",
|
||||
"generic.save": "o awen",
|
||||
"home.search": "o alasa",
|
||||
"home.search_entries": "o alasa lon ijo",
|
||||
@@ -103,52 +125,87 @@
|
||||
"ignore_list.mode.label": "nasin kulupu:",
|
||||
"ignore_list.title": "nimi lon nimi ijo anpa",
|
||||
"json_migration.description": "<br>o open e tawa tomo o lukin e pini. sina pilin e \"o pini e tawa\" taso la, mi kepeken <i>ala</i> e tomo ante. <br><br>sona tomo li jo e nanpa sama anu toki \"sama\" la ale li pona. nanpa ante li loje li jo e sitelen \"<b>(!)</b>\" lon poka ona.<br><center><i>tomo li suli la pali ni li lanpan e tenpo mute.</i></center>",
|
||||
"json_migration.discrepancies_found": "mi lukin e ike pi tomo sina",
|
||||
"json_migration.discrepancies_found.description": "mi lukin e ike lon nasin tomo open lon nasin tomo ante. o lukin, o awen tawa anu ala.",
|
||||
"json_migration.finish_migration": "o pini e tawa",
|
||||
"json_migration.heading.aliases": "nimi ante:",
|
||||
"json_migration.heading.colors": "kule:",
|
||||
"json_migration.heading.differ": "ike",
|
||||
"json_migration.heading.file_extension_list": "kulupu pi namako lipu:",
|
||||
"json_migration.heading.match": "sama",
|
||||
"json_migration.heading.names": "nimi:",
|
||||
"json_migration.heading.parent_tags": "poki mama:",
|
||||
"json_migration.heading.paths": "nasin:",
|
||||
"json_migration.heading.shorthands": "nimi lili:",
|
||||
"json_migration.heading.tags": "poki:",
|
||||
"json_migration.info.description": "lipu awen tomo lon nanpa <b>9.4 en tawa</b> li wile tawa nasin nanpa <b>9.5+</b> sin.<br><h2>sina o sona e ni:</h2><ul><li>lipu awen tomo sina lon li weka <b><i>ala</i></b></li><li>lipu pi jo sina li weka <b><i>ala</i></b>, li tawa <b><i>ala</i></b>, li ante <b><i>ala</i></b></li><li>sina ken ala open e nasin awen nanpa 9.5+ lon nanpa tawa pi ilo Tagstudio</li></ul><h3>seme li ante:</h3><ul><li>mi kepeken ala \"ma poki\" li kepeken \"poki ijo poki\". sina pana ala e poki tawa ma, poki li tawa pana lipu. la mi lawa e ona tawa poki ijo tan poki mama pi nasin ijo \"poki ala poki\" lon ma pi poki ante. poki ale li ken poki ijo, en poki kili li lawa e ona sama lon anpa poki mama pi nasin ijo \"poki ijo\". poki \"Favorite\" en poki \"Archived\" li lon anpa poki \"Meta Tags\". meso la poki \"Meta Tags\" li poki ijo.</li><li>kule poki li ante li suli. kule li nimi sin li lili, taso kule poki ala li ante tawa sama lon nanpa 9.5.</li></ul><ul>",
|
||||
"json_migration.migrating_files_entries": "mi tawa e lipu {entries:,d}...",
|
||||
"json_migration.migration_complete": "tawa li pini!",
|
||||
"json_migration.migration_complete_with_discrepancies": "tawa li pini, mi lukin e ike",
|
||||
"json_migration.title": "o kama awen e tawa nasin: \"{path}\"",
|
||||
"json_migration.title.new_lib": "<h2>tomo pi ilo nanpa 9.5+</h2>",
|
||||
"json_migration.title.old_lib": "<h2>tomo pi ilo nanpa 9.4</h2>",
|
||||
"landing.open_create_library": "o open anu pali sin e tomo {shortcut}",
|
||||
"library.field.add": "pana e sona",
|
||||
"library.field.confirm_remove": "sina weka e sona poki \"{name}\". ni li pona anu seme?",
|
||||
"library.field.mixed_data": "sona ante",
|
||||
"library.field.remove": "weka e sona",
|
||||
"library.missing": "ma li lon ala",
|
||||
"library.missing": "tomo li lon ala",
|
||||
"library.name": "tomo",
|
||||
"library.refresh.scanning_preparing": "mi alasa e ijo sin lon tomo...\nmi kama pona...",
|
||||
"library.refresh.title": "mi kama jo e sin lon tomo",
|
||||
"library.scan_library.title": "mi o lukin e tomo",
|
||||
"library_object.name": "nimi",
|
||||
"library_object.name_required": "nimi (wile mute)",
|
||||
"macros.running.dialog.new_entries": "mi pali lon ijo sin {count}/{total}...",
|
||||
"macros.running.dialog.title": "mi pali lon ijo sin",
|
||||
"menu.delete_selected_files_ambiguous": "o tawa e lipu tawa {trash_term}",
|
||||
"menu.delete_selected_files_plural": "o tawa e lipu tawa {trash_term}",
|
||||
"menu.delete_selected_files_singular": "o tawa e lipu tawa {trash_term}",
|
||||
"menu.edit": "o ante",
|
||||
"menu.edit.ignore_list": "o lukin ala e ijo ni",
|
||||
"menu.edit.manage_file_extensions": "o lawa e namako lipu",
|
||||
"menu.edit.manage_tags": "o lawa e poki",
|
||||
"menu.file": "ijo",
|
||||
"menu.file.close_library": "&o pini e tomo",
|
||||
"menu.file.new_library": "o sin e tomo",
|
||||
"menu.file.open_create_library": "o open/sin e tomo",
|
||||
"menu.file.open_create_library": "o open/pali e tomo",
|
||||
"menu.file.open_library": "o open e tomo",
|
||||
"menu.file.save_library": "o awen e sona tomo",
|
||||
"menu.help": "mi jo e toki seme",
|
||||
"menu.help.about": "sona",
|
||||
"menu.macros": "ilo pali",
|
||||
"menu.macros.folders_to_tags": "kulupu lipu tawa poki",
|
||||
"menu.tools": "ilo",
|
||||
"menu.view": "o lukin",
|
||||
"menu.window": "lipu",
|
||||
"namespace.create.title": "o pali sin e ma nimi",
|
||||
"namespace.new.button": "o pali sin e ma nimi",
|
||||
"namespace.new.prompt": "o pali sin e ma nimi tawa pana e kule sina!",
|
||||
"preview.no_selection": "ijo ala li anu",
|
||||
"settings.global": "lawa toki pi ma ale",
|
||||
"settings.language": "toki",
|
||||
"settings.library": "lawa toki pi tomo mi",
|
||||
"settings.open_library_on_start": "ilo Tagstudio li open la o open e tomo ni",
|
||||
"settings.page_size": "suli lipu",
|
||||
"settings.restart_required": "sina open sin e ilo Tagstudio la ante li lon.",
|
||||
"settings.show_filenames_in_grid": "o sitelen e nimi ijo lon leko sitelen",
|
||||
"settings.show_recent_libraries": "o sitelen e tomo pi tenpo poka",
|
||||
"settings.theme.dark": "pimeja",
|
||||
"settings.theme.light": "walo",
|
||||
"settings.title": "lawa toki",
|
||||
"splash.opening_library": "mi open e tomo \"{library_path}\"...",
|
||||
"status.deleted_file_plural": "mi weka e lipu {count}!",
|
||||
"status.deleted_file_singular": "mi weka e lipu 1!",
|
||||
"status.deleted_none": "mi weka e lipu ala.",
|
||||
"status.deleted_partial_warning": "mi weka e lipu {count} taso! o lukin tan ni: lipu li weka anu ijo li kepeken e ona anu seme.",
|
||||
"status.deleting_file": "mi weka e lipu [{i}/{count}]: \"{path}\"...",
|
||||
"status.library_backup_success": "tomo sama li lon: \"{path}\" ({time_span})",
|
||||
"status.library_closing": "mi pini e tomo...",
|
||||
"status.library_save_success": "tomo li awen li weka!",
|
||||
"status.library_search_query": "mi alasa lon tomo...",
|
||||
"status.library_version_expected": "mi wile e:",
|
||||
"status.library_version_found": "mi lukin e:",
|
||||
"status.results": "jo",
|
||||
"status.results_found": "mi sona e ijo {count} ({time_span})",
|
||||
"tag.add": "o pana e poki",
|
||||
@@ -158,7 +215,10 @@
|
||||
"tag.all_tags": "poki ale",
|
||||
"tag.color": "kule",
|
||||
"tag.confirm_delete": "sina wile ala wile weka e poki \"{tag_name}\"?",
|
||||
"tag.create": "o pali sin e poki",
|
||||
"tag.create_add": "o pali sin && o pana e \"{query}\"",
|
||||
"tag.edit": "o ante e poki",
|
||||
"tag.is_category": "poki ala poki",
|
||||
"tag.name": "nimi",
|
||||
"tag.new": "poki sin",
|
||||
"tag.parent_tags": "poki mama",
|
||||
@@ -167,20 +227,26 @@
|
||||
"tag.remove": "o weka e poki",
|
||||
"tag.search_for_tag": "o alasa e poki",
|
||||
"tag.shorthand": "nimi lili",
|
||||
"tag.tag_name_required": "nimi poki (wile mute)",
|
||||
"tag_manager.title": "poki tomo",
|
||||
"trash.context.ambiguous": "o tawa e lipu tawa {trash_term}",
|
||||
"trash.context.plural": "o tawa e lipu tawa {trash_term}",
|
||||
"trash.context.singular": "o tawa e lipu tawa {trash_term}",
|
||||
"trash.dialog.disambiguation_warning.plural": "ni li weka e ona tan ilo Tagstudio <i>tan<i> nasin lipu sina!",
|
||||
"trash.dialog.disambiguation_warning.plural": "ni li weka e ona tan ilo Tagstudio <i>tan</i> nasin lipu sina!",
|
||||
"trash.dialog.disambiguation_warning.singular": "ni li weka e ona tan ilo Tagstudio <i>tan</i> nasin lipu sina!",
|
||||
"trash.dialog.move.confirmation.plural": "sina wile ala wile tawa e lipu ni {count} tawa {trash_term}?",
|
||||
"trash.dialog.move.confirmation.singular": "sina wile ala wile tawa e lipu ni tawa {trash_term}?",
|
||||
"trash.dialog.permanent_delete_warning": "<b>toki suli!</b> lipu ni li ken ala tawa {trash_term} la, ona li <b>weka lon tenpo ale!</b>",
|
||||
"trash.dialog.title.plural": "o weka e lipu",
|
||||
"trash.dialog.title.singular": "o weka e lipu",
|
||||
"trash.name.generic": "poki pi lipu weka",
|
||||
"trash.name.windows": "poki pi lipu weka",
|
||||
"trash.name.generic": "poki pi ijo weka",
|
||||
"trash.name.windows": "poki pi ijo weka",
|
||||
"view.size.0": "lili mute",
|
||||
"view.size.1": "lili",
|
||||
"view.size.2": "meso",
|
||||
"view.size.3": "suli",
|
||||
"view.size.4": "suli mute",
|
||||
"window.message.error_opening_library": "mi open e tomo la pakala li lon.",
|
||||
"window.title.error": "pakala",
|
||||
"window.title.open_create_library": "o open/pali e tomo"
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
"about.description": "TagStudio, kullanıcıya özgürlük ve esneklik sunmaya odaklanan, etiketler kullanarak fotoğraflarınızı ve dosyalarınızı yönetebilmenizi sağlayan bir uygulamasıdır. Kapalı kaynak programlar veya formatlar kullanmaz (Kodu herkese açık!), sisteminizin yapısında değişiklikler yapmaz ve arkada derya deniz dolusu yan dosyalar bırakmaz.",
|
||||
"about.documentation": "Dökümantasyon",
|
||||
"about.license": "Lisans",
|
||||
"about.module.found": "Bulundu",
|
||||
"about.title": "TagStudio Hakkında",
|
||||
"about.website": "Website",
|
||||
"app.git": "Git Kaydet",
|
||||
"app.pre_release": "Test-Sürümü",
|
||||
"app.title": "{base_title} - Kütüphane '{library_dir}'",
|
||||
@@ -30,8 +32,8 @@
|
||||
"drop_import.progress.window_title": "Dosyaları İçe Aktar",
|
||||
"drop_import.title": "Çakışan Dosya(lar)",
|
||||
"edit.color_manager": "Etiket Renklerini Yönet",
|
||||
"edit.copy_fields": "Alanları Kopyala",
|
||||
"edit.paste_fields": "Alanları Yapıştır",
|
||||
"edit.copy_fields": "Ek Bilgileri Kopyala",
|
||||
"edit.paste_fields": "Ek Bilgileri Yapıştır",
|
||||
"edit.tag_manager": "Etiketleri Yönet",
|
||||
"entries.duplicate.merge": "Yinelenen Kayıtları Birleştir",
|
||||
"entries.duplicate.merge.label": "Yinelenen Kayıtlar Birleştiriliyor...",
|
||||
@@ -60,9 +62,9 @@
|
||||
"entries.unlinked.scanning": "Kütüphane, Kopmuş Kayıtlar için Taranıyor...",
|
||||
"entries.unlinked.search_and_relink": "&Ara && Yeniden Eşleştir",
|
||||
"entries.unlinked.title": "Kopmuş Kayıtları Düzelt",
|
||||
"field.copy": "Alanı Kopyala",
|
||||
"field.edit": "Alanı Düzenle",
|
||||
"field.paste": "Alanı Yapıştır",
|
||||
"field.copy": "Ek Bilgiyi Kopyala",
|
||||
"field.edit": "Ek Bilgiyi Düzenle",
|
||||
"field.paste": "Ek Bilgiyi Yapıştır",
|
||||
"file.date_added": "Eklenme Tarihi",
|
||||
"file.date_created": "Oluşturulma Tarihi",
|
||||
"file.date_modified": "Değiştirilme Tarihi",
|
||||
@@ -76,7 +78,7 @@
|
||||
"file.duplicates.fix": "Yinelenen Dosyaları Düzelt",
|
||||
"file.duplicates.matches": "Bulunan Yinelenen Dosyalar: {count}",
|
||||
"file.duplicates.matches_uninitialized": "Bulunan Yinelenen Dosyalar: Yok",
|
||||
"file.duplicates.mirror.description": "Kayıt verilerini bulunan her bir yinelemeye yansıtır, alanları kopyalamadan veya silmeden tüm verileri birleştirir. Bu operasyon herhangi bir dosya veya veri silmeyecek.",
|
||||
"file.duplicates.mirror.description": "Kayıt verilerini bulunan her bir yinelemeye yansıtır, ek bilgileri kopyalamadan veya silmeden tüm verileri birleştirir. Bu operasyon herhangi bir dosya veya veri silmeyecek.",
|
||||
"file.duplicates.mirror_entries": "&Kayıtları Yansıt",
|
||||
"file.duration": "Uzunluk",
|
||||
"file.not_found": "Dosya Bulunamadı",
|
||||
@@ -106,6 +108,7 @@
|
||||
"generic.edit": "Düzenle",
|
||||
"generic.edit_alt": "&Düzenle",
|
||||
"generic.filename": "Dosya adı",
|
||||
"generic.missing": "Kayıp",
|
||||
"generic.navigation.back": "Geri",
|
||||
"generic.navigation.next": "İleri",
|
||||
"generic.none": "Yok",
|
||||
@@ -145,7 +148,7 @@
|
||||
"json_migration.heading.differ": "Uyuşmazlık",
|
||||
"json_migration.heading.entires": "Kayıtlar:",
|
||||
"json_migration.heading.extension_list_type": "Uzantı Listesi Türü:",
|
||||
"json_migration.heading.fields": "Alanlar:",
|
||||
"json_migration.heading.fields": "Ek Bilgiler:",
|
||||
"json_migration.heading.file_extension_list": "Dosya Uzantı Listesi:",
|
||||
"json_migration.heading.match": "Eşleşti",
|
||||
"json_migration.heading.names": "Adlar:",
|
||||
@@ -153,7 +156,7 @@
|
||||
"json_migration.heading.paths": "Konumlar:",
|
||||
"json_migration.heading.shorthands": "Kısaltmalar:",
|
||||
"json_migration.heading.tags": "Etiketler:",
|
||||
"json_migration.info.description": "TagStudio'nun <b>9.4 ve altı</b> sürümleriyle oluşturulan kütüphane kayıt dosyaları, yeni <b>v9.5+</b> formatına dönüştürülmesi gerekecek.<br><h2>Bilmeniz gerekenler:</h2><ul><li>Mevcut kütüphane kayıt dosyanız <b><i>SİLİNMEYECEK</i></b></li><li>Kişisel dosyalarınız <b><i>SİLİNMEYECEK</i></b>, taşınmayacak veya değiştirilmeyecek</li><li>Yeni v9.5+ kayıt formatı TagStudio'nun eski sürümlerinde açılamaz</li></ul><h3>Değişenler:</h3><ul><li>\"Etiket Alanları\" \"Etiket Kategorileri\" ile değiştirildi. Etiketler artık önce alanlara eklenmek yerine doğrudan dosya kayıtlarına ekleniyor. Daha sonra, etiket düzenleme menüsünde yeni \"Kategori mi\" özelliğiyle işaretlenmiş üst etiketlere göre otomatik olarak kategorilere ayrılırlar. Herhangi bir etiket kategori olarak işaretlenebilir ve alt etiketler, kategori olarak işaretlenmiş üst etiketlerin altına yerleştirilir. \"Favori\" ve \"Arşivlenmiş\" etiketleri artık varsayılan olarak kategori olarak işaretlenmiş yeni bir \"Meta Etiketler\" etiketinden miras alıyor.</li><li>Etiket renkleri ayarlandı ve genişletildi. Bazı renkler yeniden adlandırıldı veya birleştirildi, ancak tüm etiket renkleri hala v9.5'te tam veya yakın eşleşmelere dönüştürülecektir.</li></ul><ul>",
|
||||
"json_migration.info.description": "TagStudio'nun <b>9.4 ve altı</b> sürümleriyle oluşturulan kütüphane kayıt dosyaları, yeni <b>v9.5+</b> formatına dönüştürülmesi gerekecek.<br><h2>Bilmeniz gerekenler:</h2><ul><li>Mevcut kütüphane kayıt dosyanız <b><i>SİLİNMEYECEK</i></b></li><li>Kişisel dosyalarınız <b><i>SİLİNMEYECEK</i></b>, taşınmayacak veya değiştirilmeyecek</li><li>Yeni v9.5+ kayıt formatı TagStudio'nun eski sürümlerinde açılamaz</li></ul><h3>Değişenler:</h3><ul><li>\"Etiket Ek Bilgileri\" \"Etiket Kategorileri\" ile değiştirildi. Etiketler artık önce ek bilgilere eklenmek yerine doğrudan dosya kayıtlarına ekleniyor. Daha sonra, etiket düzenleme menüsünde yeni \"Kategori mi\" özelliğiyle işaretlenmiş üst etiketlere göre otomatik olarak kategorilere ayrılırlar. Herhangi bir etiket kategori olarak işaretlenebilir ve alt etiketler, kategori olarak işaretlenmiş üst etiketlerin altına yerleştirilir. \"Favori\" ve \"Arşivlenmiş\" etiketleri artık varsayılan olarak kategori olarak işaretlenmiş yeni bir \"Meta Etiketler\" etiketinden miras alıyor.</li><li>Etiket renkleri ayarlandı ve genişletildi. Bazı renkler yeniden adlandırıldı veya birleştirildi, ancak tüm etiket renkleri hala v9.5'te tam veya yakın eşleşmelere dönüştürülecektir.</li></ul><ul>",
|
||||
"json_migration.migrating_files_entries": "{entries:,d} Dosya Kaydı Dönüştürülüyor...",
|
||||
"json_migration.migration_complete": "Dönüştürme Tamamlandı!",
|
||||
"json_migration.migration_complete_with_discrepancies": "Dönüştürme Tamamlandı, Uyuşmazlıklar Bulundu",
|
||||
@@ -162,10 +165,10 @@
|
||||
"json_migration.title.new_lib": "<h2>v9.5+ Kütüphane</h2>",
|
||||
"json_migration.title.old_lib": "<h2>v9.4 Kütüphane</h2>",
|
||||
"landing.open_create_library": "Kütüphane Aç/Oluştur {shortcut}",
|
||||
"library.field.add": "Alan Ekle",
|
||||
"library.field.confirm_remove": "Bu \"{name}\" alanını silmek istediğinden emin misin?",
|
||||
"library.field.add": "Ek Bilgi Ekle",
|
||||
"library.field.confirm_remove": "Bu \"{name}\" ek bilgisini silmek istediğinden emin misin?",
|
||||
"library.field.mixed_data": "Karışık Veri",
|
||||
"library.field.remove": "Alan Kaldır",
|
||||
"library.field.remove": "Ek Bilgiyi Kaldır",
|
||||
"library.missing": "Lokasyon bulunamadı",
|
||||
"library.name": "Kütüphane",
|
||||
"library.refresh.scanning.plural": "Yeni Dosyalar İçin Dizinler Taranıyor...\n{searched_count} Dosya Tarandı, {found_count} Yeni Dosya Bulundu",
|
||||
|
||||
@@ -23,11 +23,17 @@ elif system == "Darwin":
|
||||
icon = "src/tagstudio/resources/icon.icns"
|
||||
|
||||
|
||||
datafiles = [
|
||||
("src/tagstudio/qt/*.json", "tagstudio/qt"),
|
||||
("src/tagstudio/qt/*.qrc", "tagstudio/qt"),
|
||||
("src/tagstudio/resources", "tagstudio/resources"),
|
||||
]
|
||||
|
||||
a = Analysis(
|
||||
["src/tagstudio/main.py"],
|
||||
pathex=[],
|
||||
pathex=["src"],
|
||||
binaries=[],
|
||||
datas=[("src/tagstudio", "tagstudio")],
|
||||
datas=datafiles,
|
||||
hiddenimports=[],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
|
||||
@@ -114,7 +114,8 @@ def library(request):
|
||||
@pytest.fixture
|
||||
def search_library() -> Library:
|
||||
lib = Library()
|
||||
lib.open_library(Path(CWD / "fixtures" / "search_library"))
|
||||
status = lib.open_library(Path(CWD / "fixtures" / "search_library"))
|
||||
assert status.success
|
||||
return lib
|
||||
|
||||
|
||||
@@ -133,13 +134,15 @@ def qt_driver(qtbot, library):
|
||||
with TemporaryDirectory() as tmp_dir:
|
||||
|
||||
class Args:
|
||||
config_file = Path(tmp_dir) / "tagstudio.ini"
|
||||
settings_file = Path(tmp_dir) / "settings.toml"
|
||||
cache_file = Path(tmp_dir) / "tagstudio.ini"
|
||||
open = Path(tmp_dir)
|
||||
ci = True
|
||||
|
||||
with patch("tagstudio.qt.ts_qt.Consumer"), patch("tagstudio.qt.ts_qt.CustomRunnable"):
|
||||
driver = QtDriver(Args())
|
||||
|
||||
driver.app = Mock()
|
||||
driver.main_window = Mock()
|
||||
driver.preview_panel = Mock()
|
||||
driver.flow_container = Mock()
|
||||
|
||||
BIN
tests/fixtures/empty_libraries/DB_VERSION_9/.TagStudio/ts_library.sqlite
vendored
Normal file
BIN
tests/fixtures/empty_libraries/DB_VERSION_9/.TagStudio/ts_library.sqlite
vendored
Normal file
Binary file not shown.
Binary file not shown.
@@ -28,5 +28,5 @@ def test_refresh_missing_files(library: Library):
|
||||
assert list(registry.fix_unlinked_entries()) == [0, 1]
|
||||
|
||||
# `bar.md` should be relinked to new correct path
|
||||
results = library.search_library(FilterState.from_path("bar.md"))
|
||||
results = library.search_library(FilterState.from_path("bar.md", page_size=500))
|
||||
assert results[0].path == Path("bar.md")
|
||||
|
||||
139
tests/qt/test_file_path_options.py
Normal file
139
tests/qt/test_file_path_options.py
Normal file
@@ -0,0 +1,139 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from PySide6.QtGui import (
|
||||
QAction,
|
||||
)
|
||||
from PySide6.QtWidgets import QMenu, QMenuBar
|
||||
from pytestqt.qtbot import QtBot
|
||||
|
||||
from tagstudio.core.enums import ShowFilepathOption
|
||||
from tagstudio.core.library.alchemy.library import Library, LibraryStatus
|
||||
from tagstudio.core.library.alchemy.models import Entry
|
||||
from tagstudio.qt.modals.settings_panel import SettingsPanel
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
from tagstudio.qt.widgets.preview_panel import PreviewPanel
|
||||
|
||||
|
||||
# Tests to see if the file path setting is applied correctly
|
||||
@pytest.mark.parametrize(
|
||||
"filepath_option",
|
||||
[
|
||||
ShowFilepathOption.SHOW_FULL_PATHS.value,
|
||||
ShowFilepathOption.SHOW_RELATIVE_PATHS.value,
|
||||
ShowFilepathOption.SHOW_FILENAMES_ONLY.value,
|
||||
],
|
||||
)
|
||||
def test_filepath_setting(qtbot: QtBot, qt_driver: QtDriver, filepath_option: ShowFilepathOption):
|
||||
settings_panel = SettingsPanel(qt_driver)
|
||||
qtbot.addWidget(settings_panel)
|
||||
|
||||
# Mock the update_recent_lib_menu method
|
||||
with patch.object(qt_driver, "update_recent_lib_menu", return_value=None):
|
||||
# Set the file path option
|
||||
settings_panel.filepath_combobox.setCurrentIndex(filepath_option)
|
||||
settings_panel.update_settings(qt_driver)
|
||||
|
||||
# Assert the setting is applied
|
||||
assert qt_driver.settings.show_filepath == filepath_option
|
||||
|
||||
|
||||
# Tests to see if the file paths are being displayed correctly
|
||||
@pytest.mark.parametrize(
|
||||
"filepath_option, expected_path",
|
||||
[
|
||||
(
|
||||
ShowFilepathOption.SHOW_FULL_PATHS,
|
||||
lambda library: Path(library.library_dir / "one/two/bar.md"),
|
||||
),
|
||||
(ShowFilepathOption.SHOW_RELATIVE_PATHS, lambda _: Path("one/two/bar.md")),
|
||||
(ShowFilepathOption.SHOW_FILENAMES_ONLY, lambda _: Path("bar.md")),
|
||||
],
|
||||
)
|
||||
def test_file_path_display(
|
||||
qt_driver: QtDriver, library: Library, filepath_option: ShowFilepathOption, expected_path
|
||||
):
|
||||
panel = PreviewPanel(library, qt_driver)
|
||||
|
||||
# Select 2
|
||||
qt_driver.toggle_item_selection(2, append=False, bridge=False)
|
||||
panel.update_widgets()
|
||||
|
||||
qt_driver.settings.show_filepath = filepath_option
|
||||
|
||||
# Apply the mock value
|
||||
entry = library.get_entry(2)
|
||||
assert isinstance(entry, Entry)
|
||||
filename = entry.path
|
||||
assert library.library_dir is not None
|
||||
panel.file_attrs.update_stats(filepath=library.library_dir / filename)
|
||||
|
||||
# Generate the expected file string.
|
||||
# This is copied directly from the file_attributes.py file
|
||||
# can be imported as a function in the future
|
||||
display_path = expected_path(library)
|
||||
file_str: str = ""
|
||||
separator: str = f"<a style='color: #777777'><b>{os.path.sep}</a>" # Gray
|
||||
for i, part in enumerate(display_path.parts):
|
||||
part_ = part.strip(os.path.sep)
|
||||
if i != len(display_path.parts) - 1:
|
||||
file_str += f"{"\u200b".join(part_)}{separator}</b>"
|
||||
else:
|
||||
if file_str != "":
|
||||
file_str += "<br>"
|
||||
file_str += f"<b>{"\u200b".join(part_)}</b>"
|
||||
|
||||
# Assert the file path is displayed correctly
|
||||
assert panel.file_attrs.file_label.text() == file_str
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"filepath_option, expected_title",
|
||||
[
|
||||
(
|
||||
ShowFilepathOption.SHOW_FULL_PATHS.value,
|
||||
lambda path, base_title: f"{base_title} - Library '{path}'",
|
||||
),
|
||||
(
|
||||
ShowFilepathOption.SHOW_RELATIVE_PATHS.value,
|
||||
lambda path, base_title: f"{base_title} - Library '{path.name}'",
|
||||
),
|
||||
(
|
||||
ShowFilepathOption.SHOW_FILENAMES_ONLY.value,
|
||||
lambda path, base_title: f"{base_title} - Library '{path.name}'",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_title_update(qt_driver: QtDriver, filepath_option: ShowFilepathOption, expected_title):
|
||||
base_title = qt_driver.base_title
|
||||
test_path = Path("/dev/null")
|
||||
open_status = LibraryStatus(
|
||||
success=True,
|
||||
library_path=test_path,
|
||||
message="",
|
||||
msg_description="",
|
||||
)
|
||||
# Set the file path option
|
||||
qt_driver.settings.show_filepath = filepath_option
|
||||
menu_bar = QMenuBar()
|
||||
|
||||
qt_driver.open_recent_library_menu = QMenu(menu_bar)
|
||||
qt_driver.manage_file_ext_action = QAction(menu_bar)
|
||||
qt_driver.save_library_backup_action = QAction(menu_bar)
|
||||
qt_driver.close_library_action = QAction(menu_bar)
|
||||
qt_driver.refresh_dir_action = QAction(menu_bar)
|
||||
qt_driver.tag_manager_action = QAction(menu_bar)
|
||||
qt_driver.color_manager_action = QAction(menu_bar)
|
||||
qt_driver.new_tag_action = QAction(menu_bar)
|
||||
qt_driver.fix_dupe_files_action = QAction(menu_bar)
|
||||
qt_driver.fix_unlinked_entries_action = QAction(menu_bar)
|
||||
qt_driver.clear_thumb_cache_action = QAction(menu_bar)
|
||||
qt_driver.folders_to_tags_action = QAction(menu_bar)
|
||||
|
||||
# Trigger the update
|
||||
qt_driver.init_library(test_path, open_status)
|
||||
|
||||
# Assert the title is updated correctly
|
||||
qt_driver.main_window.setWindowTitle.assert_called_with(expected_title(test_path, base_title))
|
||||
28
tests/qt/test_global_settings.py
Normal file
28
tests/qt/test_global_settings.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from tagstudio.core.global_settings import GlobalSettings, Theme
|
||||
|
||||
|
||||
def test_read_settings():
|
||||
with TemporaryDirectory() as tmp_dir:
|
||||
settings_path = Path(tmp_dir) / "settings.toml"
|
||||
with open(settings_path, "a") as settings_file:
|
||||
settings_file.write("""
|
||||
language = "de"
|
||||
open_last_loaded_on_startup = true
|
||||
autoplay = true
|
||||
show_filenames_in_grid = true
|
||||
page_size = 1337
|
||||
show_filepath = 0
|
||||
dark_mode = 2
|
||||
""")
|
||||
|
||||
settings = GlobalSettings.read_settings(settings_path)
|
||||
assert settings.language == "de"
|
||||
assert settings.open_last_loaded_on_startup
|
||||
assert settings.autoplay
|
||||
assert settings.show_filenames_in_grid
|
||||
assert settings.page_size == 1337
|
||||
assert settings.show_filepath == 0
|
||||
assert settings.theme == Theme.SYSTEM
|
||||
@@ -1,7 +1,12 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from tagstudio.core.library.alchemy.enums import FilterState
|
||||
from tagstudio.core.library.json.library import ItemType
|
||||
from tagstudio.qt.widgets.item_thumb import ItemThumb
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
|
||||
# def test_update_thumbs(qt_driver):
|
||||
# qt_driver.frame_content = [
|
||||
# Entry(
|
||||
@@ -61,7 +66,7 @@ from tagstudio.qt.widgets.item_thumb import ItemThumb
|
||||
# assert qt_driver.selected == [0, 1, 2]
|
||||
|
||||
|
||||
def test_library_state_update(qt_driver):
|
||||
def test_library_state_update(qt_driver: "QtDriver"):
|
||||
# Given
|
||||
for entry in qt_driver.lib.get_entries(with_joins=True):
|
||||
thumb = ItemThumb(ItemType.ENTRY, qt_driver.lib, qt_driver, (100, 100))
|
||||
@@ -73,7 +78,7 @@ def test_library_state_update(qt_driver):
|
||||
assert len(qt_driver.frame_content) == 2
|
||||
|
||||
# filter by tag
|
||||
state = FilterState.from_tag_name("foo").with_page_size(10)
|
||||
state = FilterState.from_tag_name("foo", page_size=10)
|
||||
qt_driver.filter_items(state)
|
||||
assert qt_driver.filter.page_size == 10
|
||||
assert len(qt_driver.frame_content) == 1
|
||||
@@ -88,7 +93,7 @@ def test_library_state_update(qt_driver):
|
||||
assert list(entry.tags)[0].name == "foo"
|
||||
|
||||
# When state property is changed, previous one is overwritten
|
||||
state = FilterState.from_path("*bar.md")
|
||||
state = FilterState.from_path("*bar.md", page_size=qt_driver.settings.page_size)
|
||||
qt_driver.filter_items(state)
|
||||
assert len(qt_driver.frame_content) == 1
|
||||
entry = qt_driver.lib.get_entry_full(qt_driver.frame_content[0])
|
||||
|
||||
@@ -22,6 +22,7 @@ EMPTY_LIBRARIES = "empty_libraries"
|
||||
str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_6")),
|
||||
str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_7")),
|
||||
str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_8")),
|
||||
str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_9")),
|
||||
],
|
||||
)
|
||||
def test_library_migrations(path: str):
|
||||
|
||||
@@ -7,18 +7,19 @@ from PySide6.QtCore import QSettings
|
||||
from tagstudio.core.constants import TS_FOLDER_NAME
|
||||
from tagstudio.core.driver import DriverMixin
|
||||
from tagstudio.core.enums import SettingItems
|
||||
from tagstudio.core.global_settings import GlobalSettings
|
||||
from tagstudio.core.library.alchemy.library import LibraryStatus
|
||||
|
||||
|
||||
class TestDriver(DriverMixin):
|
||||
def __init__(self, settings):
|
||||
def __init__(self, settings: GlobalSettings, cache: QSettings):
|
||||
self.settings = settings
|
||||
self.cached_values = cache
|
||||
|
||||
|
||||
def test_evaluate_path_empty():
|
||||
# Given
|
||||
settings = QSettings()
|
||||
driver = TestDriver(settings)
|
||||
driver = TestDriver(GlobalSettings(), QSettings())
|
||||
|
||||
# When
|
||||
result = driver.evaluate_path(None)
|
||||
@@ -29,8 +30,7 @@ def test_evaluate_path_empty():
|
||||
|
||||
def test_evaluate_path_missing():
|
||||
# Given
|
||||
settings = QSettings()
|
||||
driver = TestDriver(settings)
|
||||
driver = TestDriver(GlobalSettings(), QSettings())
|
||||
|
||||
# When
|
||||
result = driver.evaluate_path("/0/4/5/1/")
|
||||
@@ -41,9 +41,9 @@ def test_evaluate_path_missing():
|
||||
|
||||
def test_evaluate_path_last_lib_not_exists():
|
||||
# Given
|
||||
settings = QSettings()
|
||||
settings.setValue(SettingItems.LAST_LIBRARY, "/0/4/5/1/")
|
||||
driver = TestDriver(settings)
|
||||
cache = QSettings()
|
||||
cache.setValue(SettingItems.LAST_LIBRARY, "/0/4/5/1/")
|
||||
driver = TestDriver(GlobalSettings(), cache)
|
||||
|
||||
# When
|
||||
result = driver.evaluate_path(None)
|
||||
@@ -55,13 +55,16 @@ def test_evaluate_path_last_lib_not_exists():
|
||||
def test_evaluate_path_last_lib_present():
|
||||
# Given
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
settings_file = tmpdir + "/test_settings.ini"
|
||||
settings = QSettings(settings_file, QSettings.Format.IniFormat)
|
||||
settings.setValue(SettingItems.LAST_LIBRARY, tmpdir)
|
||||
settings.sync()
|
||||
cache_file = tmpdir + "/test_settings.ini"
|
||||
cache = QSettings(cache_file, QSettings.Format.IniFormat)
|
||||
cache.setValue(SettingItems.LAST_LIBRARY, tmpdir)
|
||||
cache.sync()
|
||||
|
||||
settings = GlobalSettings()
|
||||
settings.open_last_loaded_on_startup = True
|
||||
|
||||
makedirs(Path(tmpdir) / TS_FOLDER_NAME)
|
||||
driver = TestDriver(settings)
|
||||
driver = TestDriver(settings, cache)
|
||||
|
||||
# When
|
||||
result = driver.evaluate_path(None)
|
||||
|
||||
@@ -10,7 +10,7 @@ from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Entry, Tag
|
||||
|
||||
|
||||
def test_library_add_alias(library, generate_tag):
|
||||
def test_library_add_alias(library: Library, generate_tag):
|
||||
tag = library.add_tag(generate_tag("xxx", id=123))
|
||||
assert tag
|
||||
|
||||
@@ -19,50 +19,64 @@ def test_library_add_alias(library, generate_tag):
|
||||
alias_names: set[str] = set()
|
||||
alias_names.add("test_alias")
|
||||
library.update_tag(tag, parent_ids, alias_names, alias_ids)
|
||||
alias_ids = library.get_tag(tag.id).alias_ids
|
||||
tag = library.get_tag(tag.id)
|
||||
assert tag is not None
|
||||
alias_ids = set(tag.alias_ids)
|
||||
|
||||
assert len(alias_ids) == 1
|
||||
|
||||
|
||||
def test_library_get_alias(library, generate_tag):
|
||||
def test_library_get_alias(library: Library, generate_tag):
|
||||
tag = library.add_tag(generate_tag("xxx", id=123))
|
||||
assert tag
|
||||
|
||||
parent_ids: set[int] = set()
|
||||
alias_ids: set[int] = set()
|
||||
alias_ids: list[int] = []
|
||||
alias_names: set[str] = set()
|
||||
alias_names.add("test_alias")
|
||||
library.update_tag(tag, parent_ids, alias_names, alias_ids)
|
||||
alias_ids = library.get_tag(tag.id).alias_ids
|
||||
tag = library.get_tag(tag.id)
|
||||
assert tag is not None
|
||||
alias_ids = tag.alias_ids
|
||||
|
||||
assert library.get_alias(tag.id, alias_ids[0]).name == "test_alias"
|
||||
alias = library.get_alias(tag.id, alias_ids[0])
|
||||
assert alias is not None
|
||||
assert alias.name == "test_alias"
|
||||
|
||||
|
||||
def test_library_update_alias(library, generate_tag):
|
||||
tag: Tag = library.add_tag(generate_tag("xxx", id=123))
|
||||
assert tag
|
||||
def test_library_update_alias(library: Library, generate_tag):
|
||||
tag: Tag | None = library.add_tag(generate_tag("xxx", id=123))
|
||||
assert tag is not None
|
||||
|
||||
parent_ids: set[int] = set()
|
||||
alias_ids: set[int] = set()
|
||||
alias_ids: list[int] = []
|
||||
alias_names: set[str] = set()
|
||||
alias_names.add("test_alias")
|
||||
library.update_tag(tag, parent_ids, alias_names, alias_ids)
|
||||
alias_ids = library.get_tag(tag.id).alias_ids
|
||||
tag = library.get_tag(tag.id)
|
||||
assert tag is not None
|
||||
alias_ids = tag.alias_ids
|
||||
|
||||
assert library.get_alias(tag.id, alias_ids[0]).name == "test_alias"
|
||||
alias = library.get_alias(tag.id, alias_ids[0])
|
||||
assert alias is not None
|
||||
assert alias.name == "test_alias"
|
||||
|
||||
alias_names.remove("test_alias")
|
||||
alias_names.add("alias_update")
|
||||
library.update_tag(tag, parent_ids, alias_names, alias_ids)
|
||||
|
||||
tag = library.get_tag(tag.id)
|
||||
assert tag is not None
|
||||
assert len(tag.alias_ids) == 1
|
||||
assert library.get_alias(tag.id, tag.alias_ids[0]).name == "alias_update"
|
||||
alias = library.get_alias(tag.id, tag.alias_ids[0])
|
||||
assert alias is not None
|
||||
assert alias.name == "alias_update"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
|
||||
def test_library_add_file(library):
|
||||
def test_library_add_file(library: Library):
|
||||
"""Check Entry.path handling for insert vs lookup"""
|
||||
assert library.folder is not None
|
||||
|
||||
entry = Entry(
|
||||
path=Path("bar.txt"),
|
||||
@@ -75,7 +89,7 @@ def test_library_add_file(library):
|
||||
assert library.has_path_entry(entry.path)
|
||||
|
||||
|
||||
def test_create_tag(library, generate_tag):
|
||||
def test_create_tag(library: Library, generate_tag):
|
||||
# tag already exists
|
||||
assert not library.add_tag(generate_tag("foo", id=1000))
|
||||
|
||||
@@ -85,10 +99,11 @@ def test_create_tag(library, generate_tag):
|
||||
assert tag.id == 123
|
||||
|
||||
tag_inc = library.add_tag(generate_tag("yyy"))
|
||||
assert tag_inc is not None
|
||||
assert tag_inc.id > 1000
|
||||
|
||||
|
||||
def test_tag_self_parent(library, generate_tag):
|
||||
def test_tag_self_parent(library: Library, generate_tag):
|
||||
# tag already exists
|
||||
assert not library.add_tag(generate_tag("foo", id=1000))
|
||||
|
||||
@@ -97,24 +112,25 @@ def test_tag_self_parent(library, generate_tag):
|
||||
assert tag
|
||||
assert tag.id == 123
|
||||
|
||||
library.update_tag(tag, {tag.id}, {}, {})
|
||||
library.update_tag(tag, {tag.id}, [], [])
|
||||
tag = library.get_tag(tag.id)
|
||||
assert tag is not None
|
||||
assert len(tag.parent_ids) == 0
|
||||
|
||||
|
||||
def test_library_search(library, generate_tag, entry_full):
|
||||
def test_library_search(library: Library, generate_tag, entry_full):
|
||||
assert library.entries_count == 2
|
||||
tag = list(entry_full.tags)[0]
|
||||
|
||||
results = library.search_library(
|
||||
FilterState.from_tag_name(tag.name),
|
||||
FilterState.from_tag_name(tag.name, page_size=500),
|
||||
)
|
||||
|
||||
assert results.total_count == 1
|
||||
assert len(results) == 1
|
||||
|
||||
|
||||
def test_tag_search(library):
|
||||
def test_tag_search(library: Library):
|
||||
tag = library.tags[0]
|
||||
|
||||
assert library.search_tags(tag.name.lower())
|
||||
@@ -130,24 +146,26 @@ def test_get_entry(library: Library, entry_min):
|
||||
assert len(result.tags) == 1
|
||||
|
||||
|
||||
def test_entries_count(library):
|
||||
def test_entries_count(library: Library):
|
||||
assert library.folder is not None
|
||||
entries = [Entry(path=Path(f"{x}.txt"), folder=library.folder, fields=[]) for x in range(10)]
|
||||
new_ids = library.add_entries(entries)
|
||||
assert len(new_ids) == 10
|
||||
|
||||
results = library.search_library(FilterState.show_all().with_page_size(5))
|
||||
results = library.search_library(FilterState.show_all(page_size=5))
|
||||
|
||||
assert results.total_count == 12
|
||||
assert len(results) == 5
|
||||
|
||||
|
||||
def test_parents_add(library, generate_tag):
|
||||
def test_parents_add(library: Library, generate_tag):
|
||||
# Given
|
||||
tag: Tag = library.tags[0]
|
||||
tag: Tag | None = library.tags[0]
|
||||
assert tag.id is not None
|
||||
|
||||
parent_tag = generate_tag("parent_tag_01")
|
||||
parent_tag = library.add_tag(parent_tag)
|
||||
assert parent_tag is not None
|
||||
assert parent_tag.id is not None
|
||||
|
||||
# When
|
||||
@@ -156,10 +174,11 @@ def test_parents_add(library, generate_tag):
|
||||
# Then
|
||||
assert tag.id is not None
|
||||
tag = library.get_tag(tag.id)
|
||||
assert tag is not None
|
||||
assert tag.parent_ids
|
||||
|
||||
|
||||
def test_remove_tag(library, generate_tag):
|
||||
def test_remove_tag(library: Library, generate_tag):
|
||||
tag = library.add_tag(generate_tag("food", id=123))
|
||||
|
||||
assert tag
|
||||
@@ -171,7 +190,7 @@ def test_remove_tag(library, generate_tag):
|
||||
|
||||
|
||||
@pytest.mark.parametrize("is_exclude", [True, False])
|
||||
def test_search_filter_extensions(library, is_exclude):
|
||||
def test_search_filter_extensions(library: Library, is_exclude: bool):
|
||||
# Given
|
||||
entries = list(library.get_entries())
|
||||
assert len(entries) == 2, entries
|
||||
@@ -181,7 +200,7 @@ def test_search_filter_extensions(library, is_exclude):
|
||||
|
||||
# When
|
||||
results = library.search_library(
|
||||
FilterState.show_all(),
|
||||
FilterState.show_all(page_size=500),
|
||||
)
|
||||
|
||||
# Then
|
||||
@@ -192,7 +211,7 @@ def test_search_filter_extensions(library, is_exclude):
|
||||
assert (entry.path.suffix == ".txt") == is_exclude
|
||||
|
||||
|
||||
def test_search_library_case_insensitive(library):
|
||||
def test_search_library_case_insensitive(library: Library):
|
||||
# Given
|
||||
entries = list(library.get_entries(with_joins=True))
|
||||
assert len(entries) == 2, entries
|
||||
@@ -202,7 +221,7 @@ def test_search_library_case_insensitive(library):
|
||||
|
||||
# When
|
||||
results = library.search_library(
|
||||
FilterState.from_tag_name(tag.name.upper()),
|
||||
FilterState.from_tag_name(tag.name.upper(), page_size=500),
|
||||
)
|
||||
|
||||
# Then
|
||||
@@ -212,12 +231,12 @@ def test_search_library_case_insensitive(library):
|
||||
assert results[0].id == entry.id
|
||||
|
||||
|
||||
def test_preferences(library):
|
||||
def test_preferences(library: Library):
|
||||
for pref in LibraryPrefs:
|
||||
assert library.prefs(pref) == pref.default
|
||||
|
||||
|
||||
def test_remove_entry_field(library, entry_full):
|
||||
def test_remove_entry_field(library: Library, entry_full):
|
||||
title_field = entry_full.text_fields[0]
|
||||
|
||||
library.remove_entry_field(title_field, [entry_full.id])
|
||||
@@ -226,7 +245,7 @@ def test_remove_entry_field(library, entry_full):
|
||||
assert not entry.text_fields
|
||||
|
||||
|
||||
def test_remove_field_entry_with_multiple_field(library, entry_full):
|
||||
def test_remove_field_entry_with_multiple_field(library: Library, entry_full):
|
||||
# Given
|
||||
title_field = entry_full.text_fields[0]
|
||||
|
||||
@@ -242,7 +261,7 @@ def test_remove_field_entry_with_multiple_field(library, entry_full):
|
||||
assert len(entry.text_fields) == 1
|
||||
|
||||
|
||||
def test_update_entry_field(library, entry_full):
|
||||
def test_update_entry_field(library: Library, entry_full):
|
||||
title_field = entry_full.text_fields[0]
|
||||
|
||||
library.update_entry_field(
|
||||
@@ -255,7 +274,7 @@ def test_update_entry_field(library, entry_full):
|
||||
assert entry.text_fields[0].value == "new value"
|
||||
|
||||
|
||||
def test_update_entry_with_multiple_identical_fields(library, entry_full):
|
||||
def test_update_entry_with_multiple_identical_fields(library: Library, entry_full):
|
||||
# Given
|
||||
title_field = entry_full.text_fields[0]
|
||||
|
||||
@@ -278,6 +297,7 @@ def test_update_entry_with_multiple_identical_fields(library, entry_full):
|
||||
|
||||
def test_mirror_entry_fields(library: Library, entry_full):
|
||||
# new entry
|
||||
assert library.folder is not None
|
||||
target_entry = Entry(
|
||||
folder=library.folder,
|
||||
path=Path("xxx"),
|
||||
@@ -295,12 +315,14 @@ def test_mirror_entry_fields(library: Library, entry_full):
|
||||
|
||||
# get new entry from library
|
||||
new_entry = library.get_entry_full(entry_id)
|
||||
assert new_entry is not None
|
||||
|
||||
# mirror fields onto new entry
|
||||
library.mirror_entry_fields(new_entry, entry_full)
|
||||
|
||||
# get new entry from library again
|
||||
entry = library.get_entry_full(entry_id)
|
||||
assert entry is not None
|
||||
|
||||
# make sure fields are there after getting it from the library again
|
||||
assert len(entry.fields) == 2
|
||||
@@ -311,6 +333,7 @@ def test_mirror_entry_fields(library: Library, entry_full):
|
||||
|
||||
|
||||
def test_merge_entries(library: Library):
|
||||
assert library.folder is not None
|
||||
a = Entry(
|
||||
folder=library.folder,
|
||||
path=Path("a"),
|
||||
@@ -327,10 +350,14 @@ def test_merge_entries(library: Library):
|
||||
try:
|
||||
ids = library.add_entries([a, b])
|
||||
entry_a = library.get_entry_full(ids[0])
|
||||
assert entry_a is not None
|
||||
entry_b = library.get_entry_full(ids[1])
|
||||
assert entry_b is not None
|
||||
tag_0 = library.add_tag(Tag(id=1000, name="tag_0"))
|
||||
tag_1 = library.add_tag(Tag(id=1001, name="tag_1"))
|
||||
assert tag_1 is not None
|
||||
tag_2 = library.add_tag(Tag(id=1002, name="tag_2"))
|
||||
assert tag_2 is not None
|
||||
library.add_tags_to_entries(ids[0], [tag_0.id, tag_2.id])
|
||||
library.add_tags_to_entries(ids[1], [tag_1.id])
|
||||
library.merge_entries(entry_a, entry_b)
|
||||
@@ -345,7 +372,7 @@ def test_merge_entries(library: Library):
|
||||
AssertionError()
|
||||
|
||||
|
||||
def test_remove_tags_from_entries(library, entry_full):
|
||||
def test_remove_tags_from_entries(library: Library, entry_full):
|
||||
removed_tag_id = -1
|
||||
for tag in entry_full.tags:
|
||||
removed_tag_id = tag.id
|
||||
@@ -370,7 +397,7 @@ def test_search_entry_id(library: Library, query_name: int, has_result):
|
||||
assert (result is not None) == has_result
|
||||
|
||||
|
||||
def test_update_field_order(library, entry_full):
|
||||
def test_update_field_order(library: Library, entry_full):
|
||||
# Given
|
||||
title_field = entry_full.text_fields[0]
|
||||
|
||||
@@ -416,98 +443,100 @@ def test_library_prefs_multiple_identical_vals():
|
||||
|
||||
|
||||
def test_path_search_ilike(library: Library):
|
||||
results = library.search_library(FilterState.from_path("bar.md"))
|
||||
results = library.search_library(FilterState.from_path("bar.md", page_size=500))
|
||||
assert results.total_count == 1
|
||||
assert len(results.items) == 1
|
||||
|
||||
|
||||
def test_path_search_like(library: Library):
|
||||
results = library.search_library(FilterState.from_path("BAR.MD"))
|
||||
results = library.search_library(FilterState.from_path("BAR.MD", page_size=500))
|
||||
assert results.total_count == 0
|
||||
assert len(results.items) == 0
|
||||
|
||||
|
||||
def test_path_search_default_with_sep(library: Library):
|
||||
results = library.search_library(FilterState.from_path("one/two"))
|
||||
results = library.search_library(FilterState.from_path("one/two", page_size=500))
|
||||
assert results.total_count == 1
|
||||
assert len(results.items) == 1
|
||||
|
||||
|
||||
def test_path_search_glob_after(library: Library):
|
||||
results = library.search_library(FilterState.from_path("foo*"))
|
||||
results = library.search_library(FilterState.from_path("foo*", page_size=500))
|
||||
assert results.total_count == 1
|
||||
assert len(results.items) == 1
|
||||
|
||||
|
||||
def test_path_search_glob_in_front(library: Library):
|
||||
results = library.search_library(FilterState.from_path("*bar.md"))
|
||||
results = library.search_library(FilterState.from_path("*bar.md", page_size=500))
|
||||
assert results.total_count == 1
|
||||
assert len(results.items) == 1
|
||||
|
||||
|
||||
def test_path_search_glob_both_sides(library: Library):
|
||||
results = library.search_library(FilterState.from_path("*one/two*"))
|
||||
results = library.search_library(FilterState.from_path("*one/two*", page_size=500))
|
||||
assert results.total_count == 1
|
||||
assert len(results.items) == 1
|
||||
|
||||
|
||||
def test_path_search_ilike_glob_equality(library: Library):
|
||||
results_ilike = library.search_library(FilterState.from_path("one/two"))
|
||||
results_glob = library.search_library(FilterState.from_path("*one/two*"))
|
||||
results_ilike = library.search_library(FilterState.from_path("one/two", page_size=500))
|
||||
results_glob = library.search_library(FilterState.from_path("*one/two*", page_size=500))
|
||||
assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items]
|
||||
results_ilike, results_glob = None, None
|
||||
|
||||
results_ilike = library.search_library(FilterState.from_path("bar.md"))
|
||||
results_glob = library.search_library(FilterState.from_path("*bar.md*"))
|
||||
results_ilike = library.search_library(FilterState.from_path("bar.md", page_size=500))
|
||||
results_glob = library.search_library(FilterState.from_path("*bar.md*", page_size=500))
|
||||
assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items]
|
||||
results_ilike, results_glob = None, None
|
||||
|
||||
results_ilike = library.search_library(FilterState.from_path("bar"))
|
||||
results_glob = library.search_library(FilterState.from_path("*bar*"))
|
||||
results_ilike = library.search_library(FilterState.from_path("bar", page_size=500))
|
||||
results_glob = library.search_library(FilterState.from_path("*bar*", page_size=500))
|
||||
assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items]
|
||||
results_ilike, results_glob = None, None
|
||||
|
||||
results_ilike = library.search_library(FilterState.from_path("bar.md"))
|
||||
results_glob = library.search_library(FilterState.from_path("*bar.md*"))
|
||||
results_ilike = library.search_library(FilterState.from_path("bar.md", page_size=500))
|
||||
results_glob = library.search_library(FilterState.from_path("*bar.md*", page_size=500))
|
||||
assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items]
|
||||
results_ilike, results_glob = None, None
|
||||
|
||||
|
||||
def test_path_search_like_glob_equality(library: Library):
|
||||
results_ilike = library.search_library(FilterState.from_path("ONE/two"))
|
||||
results_glob = library.search_library(FilterState.from_path("*ONE/two*"))
|
||||
results_ilike = library.search_library(FilterState.from_path("ONE/two", page_size=500))
|
||||
results_glob = library.search_library(FilterState.from_path("*ONE/two*", page_size=500))
|
||||
assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items]
|
||||
results_ilike, results_glob = None, None
|
||||
|
||||
results_ilike = library.search_library(FilterState.from_path("BAR.MD"))
|
||||
results_glob = library.search_library(FilterState.from_path("*BAR.MD*"))
|
||||
results_ilike = library.search_library(FilterState.from_path("BAR.MD", page_size=500))
|
||||
results_glob = library.search_library(FilterState.from_path("*BAR.MD*", page_size=500))
|
||||
assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items]
|
||||
results_ilike, results_glob = None, None
|
||||
|
||||
results_ilike = library.search_library(FilterState.from_path("BAR.MD"))
|
||||
results_glob = library.search_library(FilterState.from_path("*bar.md*"))
|
||||
results_ilike = library.search_library(FilterState.from_path("BAR.MD", page_size=500))
|
||||
results_glob = library.search_library(FilterState.from_path("*bar.md*", page_size=500))
|
||||
assert [e.id for e in results_ilike.items] != [e.id for e in results_glob.items]
|
||||
results_ilike, results_glob = None, None
|
||||
|
||||
results_ilike = library.search_library(FilterState.from_path("bar.md"))
|
||||
results_glob = library.search_library(FilterState.from_path("*BAR.MD*"))
|
||||
results_ilike = library.search_library(FilterState.from_path("bar.md", page_size=500))
|
||||
results_glob = library.search_library(FilterState.from_path("*BAR.MD*", page_size=500))
|
||||
assert [e.id for e in results_ilike.items] != [e.id for e in results_glob.items]
|
||||
results_ilike, results_glob = None, None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(["filetype", "num_of_filetype"], [("md", 1), ("txt", 1), ("png", 0)])
|
||||
def test_filetype_search(library, filetype, num_of_filetype):
|
||||
results = library.search_library(FilterState.from_filetype(filetype))
|
||||
def test_filetype_search(library: Library, filetype, num_of_filetype):
|
||||
results = library.search_library(FilterState.from_filetype(filetype, page_size=500))
|
||||
assert len(results.items) == num_of_filetype
|
||||
|
||||
|
||||
@pytest.mark.parametrize(["filetype", "num_of_filetype"], [("png", 2), ("apng", 1), ("ng", 0)])
|
||||
def test_filetype_return_one_filetype(file_mediatypes_library, filetype, num_of_filetype):
|
||||
results = file_mediatypes_library.search_library(FilterState.from_filetype(filetype))
|
||||
def test_filetype_return_one_filetype(file_mediatypes_library: Library, filetype, num_of_filetype):
|
||||
results = file_mediatypes_library.search_library(
|
||||
FilterState.from_filetype(filetype, page_size=500)
|
||||
)
|
||||
assert len(results.items) == num_of_filetype
|
||||
|
||||
|
||||
@pytest.mark.parametrize(["mediatype", "num_of_mediatype"], [("plaintext", 2), ("image", 0)])
|
||||
def test_mediatype_search(library, mediatype, num_of_mediatype):
|
||||
results = library.search_library(FilterState.from_mediatype(mediatype))
|
||||
def test_mediatype_search(library: Library, mediatype, num_of_mediatype):
|
||||
results = library.search_library(FilterState.from_mediatype(mediatype, page_size=500))
|
||||
assert len(results.items) == num_of_mediatype
|
||||
|
||||
@@ -6,7 +6,7 @@ from tagstudio.core.query_lang.util import ParsingError
|
||||
|
||||
|
||||
def verify_count(lib: Library, query: str, count: int):
|
||||
results = lib.search_library(FilterState.from_search_query(query))
|
||||
results = lib.search_library(FilterState.from_search_query(query, page_size=500))
|
||||
assert results.total_count == count
|
||||
assert len(results.items) == count
|
||||
|
||||
@@ -136,4 +136,4 @@ def test_parent_tags(search_library: Library, query: str, count: int):
|
||||
)
|
||||
def test_syntax(search_library: Library, invalid_query: str):
|
||||
with pytest.raises(ParsingError) as e_info: # noqa: F841
|
||||
search_library.search_library(FilterState.from_search_query(invalid_query))
|
||||
search_library.search_library(FilterState.from_search_query(invalid_query, page_size=500))
|
||||
|
||||
Reference in New Issue
Block a user