Compare commits

..

39 Commits

Author SHA1 Message Date
Travis Abendshien
7c5b8e51e6 chore: bump version to v9.5.2 2025-03-31 15:39:46 -07:00
Weblate (bot)
efb4c4a6ed translations: update Hungarian, French, Toki Pona (#891)
* Translated using Weblate (Hungarian)

Currently translated at 100.0% (312 of 312 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (309 of 309 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Szíjártó Levente Pál <szijartoleventepal@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/hu/
Translation: TagStudio/Strings

* Translated using Weblate (French)

Currently translated at 100.0% (312 of 312 strings)

Translated using Weblate (French)

Currently translated at 100.0% (309 of 309 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Med <45147847+kitsumed@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/fr/
Translation: TagStudio/Strings

* Translated using Weblate (Toki Pona)

Currently translated at 80.9% (250 of 309 strings)

Co-authored-by: Bee Crankson <ProfB.crankson@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/tok/
Translation: TagStudio/Strings

---------

Co-authored-by: Szíjártó Levente Pál <szijartoleventepal@gmail.com>
Co-authored-by: Med <45147847+kitsumed@users.noreply.github.com>
Co-authored-by: Bee Crankson <ProfB.crankson@gmail.com>
2025-03-31 15:33:17 -07:00
Travis Abendshien
f88200f38e fix(ui): seamlessly loop videos (#902) 2025-03-31 15:28:08 -07:00
VasigaranAndAngel
7f7d861800 refactor: fix type hints and overrides in flowlayout.py (#880)
* refactor and fixes

* type annotations and parameter name changes

* Update src/tagstudio/qt/flowlayout.py

Co-authored-by: Jann Stute <46534683+Computerdores@users.noreply.github.com>

* Update src/tagstudio/qt/flowlayout.py

Co-authored-by: Jann Stute <46534683+Computerdores@users.noreply.github.com>

* ruff format

---------

Co-authored-by: Jann Stute <46534683+Computerdores@users.noreply.github.com>
2025-03-31 11:51:36 -07:00
Tony
cccd858078 fix(ui): remove media player border (#900) 2025-03-31 11:45:26 -07:00
VasigaranAndAngel
46996d50e5 refactor: fix various missing and broken type hints (#901)
* fix type hints

* text_wrapper.py type fix and minor improvements

* fields.py fixes
2025-03-31 11:14:15 -07:00
Travis Abendshien
1939f118f1 ci: add reportUnknownLambdaType = false to pyproject.toml 2025-03-31 09:49:32 -07:00
Travis Abendshien
3b5a9605d1 fix: use proper not check against MatLike type
Fixes video thumbnails not rendering.
2025-03-31 02:13:06 -07:00
Travis Abendshien
7dd1905b6e translations: add Japanese to settings, catch FileNotFound error 2025-03-30 23:37:22 -07:00
Travis Abendshien
9c24272caf settings: update default settings values 2025-03-30 21:28:09 -07:00
Travis Abendshien
d7d7e21d13 feat(ui): add more default icons and file type equivalencies (#882)
* feat(ui): expand file and thumbnail support

* feat: add iwork and powerpoint thumb support

Note: a lot of the zip-based code is becoming duplicated - this should be consolidated in the future.

* fix: remove decompression bomb check and catch others

* feat: add .aiff file equivalencies

* ui: update database icon

* feat: add .effect and .shader to shader set

* fix: correct malformed or missing media types

* feat: add misc code/plaintext types to media types

* fix: catch BadZipFile error for iWork thumbs

* chore: add type hints to thumb_renderer dicts

* refactor: change most internal render methods to static
2025-03-30 19:24:10 -07:00
Travis Abendshien
33e6bc180d ui: recent libraries list improvements (#881)
* ui: improve missing library message

* ui: update recent library max to 10
2025-03-30 19:23:52 -07:00
csponge
13afb0f664 feat(ui): merge media controls (#805)
* feat: merge media controls.

Initial commit to merge audio/video files. There are
still a few bugs around widget sizing that need fixing.

* fix: center widgets in preview area

Add widgets to a sublayout to allow for centering
in a QStackedLayout.

Remove references to the legacy video player in
the thumb preview.

* fix: resolve commit suggestions.

Subclass QSlider to handle click events
and allow for easier seeking.

Implement context menu along with autoplay
setting for the media widget.

Pause video when media player is clicked
instead of opening file.

* fix: start media muted

Start video/audio muted on initial load of
the media player.

Remove code causing mypy issue.

Add new method for getting slider click state.

* refactor: use layouts instead of manual positioning.

Add various layouts for positioning widgets instead
of manually moving widgets.

Change the volume slider orientation at smaller
media sizes.

* fix: color position label white

Fix position label color to white so it stays visible
regardless of theme.

* fix: allow dragging slider after click

* Apply suggestions from code review

fix: apply suggestions from code review.

Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>

* fix: remove references to legacy video player.

Combine the stats logic for video/audio into one method.

Fix several issues after incorrectly implementing suggestions.

* fix: add loop setting and other actions.

* refactor: simplify widget state management.

Make a single method to control widget state.

Works with the main QStackLayout and cleans up
widget state if it is needed (i.e., stopping the media
player when switching to a different preview).

* fix: add pages to QStackLayout to fix widget position.

Fixes a regression in commit 4c6934. We need the pages
to properly center the widgets in the QStackLayout.

* fix: ensure media_player doesn't exceed maximum size if thumbnail.

Fix and issue where the media_player would expand past the
thumbnail on resize.

* refactor: move settings to new system
2025-03-30 19:23:24 -07:00
Leonard2
27fb54ed65 build: update spec file to use proper pathex and datas paths (#895)
* build: add "src" to pathex.

Instead of having to pre-install tagstudio, this will have PyInstaller directly build it from source.

* build: be more selective about the data files.

Since PyInstaller builds from source now there is no need to manually add it.

---------

Co-authored-by: Léonard <Leeooonaard@gmail.com>
2025-03-30 17:34:01 -07:00
Jann Stute
e3b2eaf96a perf: improve responsiveness of GIF entries (#894)
* feat: create QMovie with file path instead of copying image around

* fix: make ignore pyright specific so mypy doesn't complain

* fix: restore ability to delete file while viewing it
2025-03-30 17:21:46 -07:00
Jann Stute
33dd330c73 fix: close pdf file object in thumb renderer (#893) 2025-03-30 16:36:04 -07:00
Emmanuel Ferdman
1c02e75dd9 docs: update ThumbRenderer source (#896)
Signed-off-by: Emmanuel Ferdman <emmanuelferdman@gmail.com>
2025-03-30 09:18:37 -07:00
Xarvex
6400236823 fix(nix/package): add missing dependencies from #859
Adds toml and pydantic as Python packages, and ignore tests that require
mutating files.

Fixes: #890
2025-03-26 17:26:17 -05:00
Travis Abendshien
a4e61f9387 fix: remove unescaped ampersand from "about.description" (#885) 2025-03-25 15:04:45 -07:00
Jann Stute
adb996e1d2 feat: new settings menu + settings backend (#859)
* feat: add tab widget

* refactor: move languages dict to translations.py

* refactor: move build of Settings Modal to SettingsPanel class

* feat: hide title label

* feat: global settings class

* fix: initialise settings

* fix: properly store grid files changes

* fix: placeholder text for library settings

* feat: add ui elements for remaining global settings

* feat: add page size setting

* fix: version mismatch between pydantic and typing_extensions

* fix: update test_driver.py

* fix(test_file_path_options): replace patch with change of settings

* feat: setting for dark mode

* fix: only show restart_label when necessary

* fix: change modal from "done" type to "Save/Cancel" type

* feat: add test for GlobalSettings

* docs: mark roadmap item as completed

* fix(test_filepath_setting): Mock the app field of QtDriver

* Update src/tagstudio/main.py

Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>

* fix: address review suggestions

* fix: page size setting

* feat: change dark mode option to theme dropdown

* fix: test was expecting wrong behaviour

* fix: test was testing for correct behaviour, fix behaviour instead

* fix: test fr fr

* fix: tests fr fr fr

* fix: tests fr fr fr fr

* fix: update test

* fix: tests fr fr fr fr fr

* fix: select all was selecting hidden entries

* fix: create more thumbitems as necessary
2025-03-25 15:02:53 -07:00
Weblate (bot)
e112788466 translations: update Hungarian, Spanish, French, Toki Pona (#884)
* Translated using Weblate (Hungarian)

Currently translated at 100.0% (302 of 302 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Szíjártó Levente Pál <szijartoleventepal@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/hu/
Translation: TagStudio/Strings

* Translated using Weblate (Spanish)

Currently translated at 100.0% (302 of 302 strings)

Co-authored-by: Joan <joancanalscrehuet@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/es/
Translation: TagStudio/Strings

* Translated using Weblate (French)

Currently translated at 100.0% (302 of 302 strings)

Co-authored-by: Med <45147847+kitsumed@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/fr/
Translation: TagStudio/Strings

* Translated using Weblate (Toki Pona)

Currently translated at 75.8% (229 of 302 strings)

Translated using Weblate (Toki Pona)

Currently translated at 75.9% (227 of 299 strings)

Co-authored-by: Bee Crankson <ProfB.crankson@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/tok/
Translation: TagStudio/Strings

---------

Co-authored-by: Szíjártó Levente Pál <szijartoleventepal@gmail.com>
Co-authored-by: Joan <joancanalscrehuet@gmail.com>
Co-authored-by: Med <45147847+kitsumed@users.noreply.github.com>
Co-authored-by: Bee Crankson <ProfB.crankson@gmail.com>
2025-03-25 15:01:01 -07:00
Travis Abendshien
64dc88afa9 fix(ui): display 0 frame webp files in preview panel 2025-03-22 12:09:31 -07:00
VasigaranAndAngel
477173661a refactor: type hints and improvements in file_opener.py (#876)
* type fixes and minor improvements

* remove `# noqa: N802`
2025-03-21 08:30:25 -07:00
Travis Abendshien
9d60c78adc ci: update pyright rules
- Ignore "reportIgnoreCommentWithoutRule"
- Ignore "reportMissingTypeArgument"
2025-03-20 01:15:45 -07:00
Travis Abendshien
5b5e878a69 fix: use UNION instead of UNION ALL (#877) 2025-03-20 01:09:46 -07:00
Travis Abendshien
d1eb7d646e fix: hide mnemonics on macOS (#856) 2025-03-20 01:09:23 -07:00
Travis Abendshien
880ca07a6f fix: stop ffmpeg cmd windows, refactor ffmpeg_checker (#855)
* fix: remove log statement as it is redundant (#840)

* refactor: rework ffmpeg_checker.py

Move backend logic from ffmpeg_checker.py to vendored/ffmpeg.py, add translation strings for ffmpeg_checker, update vendored/ffmpeg.py

* fix: stop ffmpeg cmd windows, fix version outputs

* chore: ensure stdout is cast to str

---------

Co-authored-by: Jann Stute <46534683+Computerdores@users.noreply.github.com>
2025-03-20 01:09:02 -07:00
Weblate (bot)
0701a45e75 translations: add Japanese, Viossa; update others (#861)
* Translated using Weblate (Russian)

Currently translated at 89.9% (269 of 299 strings)

Co-authored-by: Dott-rus <antonamelin8@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/ru/
Translation: TagStudio/Strings

* Translated using Weblate (Japanese)

Currently translated at 52.8% (158 of 299 strings)

Added translation using Weblate (Japanese)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: needledetector <kenshinzumi.mail@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/ja/
Translation: TagStudio/Strings

* Translated using Weblate (Czech)

Currently translated at 15.7% (47 of 299 strings)

Translated using Weblate (Czech)

Currently translated at 14.3% (43 of 299 strings)

Co-authored-by: Filip Jaruška <filip.jaruska@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/cs/
Translation: TagStudio/Strings

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (299 of 299 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Szíjártó Levente Pál <szijartoleventepal@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/hu/
Translation: TagStudio/Strings

* Translated using Weblate (Spanish)

Currently translated at 100.0% (299 of 299 strings)

Translated using Weblate (Spanish)

Currently translated at 96.9% (290 of 299 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Joan <joancanalscrehuet@gmail.com>
Co-authored-by: Nginearing <142851004+Nginearing@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/es/
Translation: TagStudio/Strings

* Translated using Weblate (French)

Currently translated at 100.0% (299 of 299 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Med <45147847+kitsumed@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/fr/
Translation: TagStudio/Strings

* Translated using Weblate (Toki Pona)

Currently translated at 67.8% (203 of 299 strings)

Co-authored-by: Bee Crankson <ProfB.crankson@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/tok/
Translation: TagStudio/Strings

* Translated using Weblate (Viossa)

Currently translated at 21.0% (63 of 299 strings)

Translated using Weblate (Viossa)

Currently translated at 21.0% (63 of 299 strings)

Added translation using Weblate (Viossa)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Nginearing <142851004+Nginearing@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/qpv/
Translation: TagStudio/Strings

---------

Co-authored-by: Dott-rus <antonamelin8@gmail.com>
Co-authored-by: needledetector <kenshinzumi.mail@gmail.com>
Co-authored-by: Filip Jaruška <filip.jaruska@gmail.com>
Co-authored-by: Szíjártó Levente Pál <szijartoleventepal@gmail.com>
Co-authored-by: Joan <joancanalscrehuet@gmail.com>
Co-authored-by: Nginearing <142851004+Nginearing@users.noreply.github.com>
Co-authored-by: Med <45147847+kitsumed@users.noreply.github.com>
Co-authored-by: Bee Crankson <ProfB.crankson@gmail.com>
2025-03-20 01:07:58 -07:00
Gawi
35f409089a docs: fix typos and grammar (#879) 2025-03-20 00:43:34 -07:00
Xarvex
010a4524d6 fix(flake): remove pinned input, only consume in Nix shell (#872) 2025-03-17 18:36:06 -07:00
Xarvex
861df898e2 feat(resources): provide desktop file (#870)
* feat(resources): provide desktop file

Co-authored-by: Florian Zier <9168602+zierf@users.noreply.github.com>

* fix(ts_qt): remove duplicate logic

* fix(ts_qt): add fallback values

---------

Co-authored-by: Florian Zier <9168602+zierf@users.noreply.github.com>
2025-03-17 18:35:45 -07:00
Xarvex
0ac06a125a fix(ui): do not set palette for Linux-like systems that offer theming (#869) 2025-03-17 18:34:04 -07:00
Xarvex
b8ee63ef73 fix(nix/package): account for GTK platform (#868) 2025-03-17 18:33:33 -07:00
Xarvex
a5e535ba78 feat(ci): development tooling refresh and split documentation (#867)
* feat(nix/shell): use uv for faster evaluation

* feat(contrib): define developer configurations

* feat(ci): configure pre-commit to use project dependencies, add mypy

* fix(docs): typo

* docs: split develop and install, document integrations

* nit(contrib): add shellcheck directive to envrc's

* docs: move third-party dependencies to install page

* nit(flake): use pythonPackages variable

---------

Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
2025-03-17 18:32:08 -07:00
Travis Abendshien
ed6ac246f4 docs: update schema_changes.md for DB_VERSION 9 2025-03-12 17:40:07 -07:00
Jann Stute
31833245a4 feat: add filename and path sorting (#842)
* feat: add filename sorting to dropdown

* feat: add file path sorting to dropdown

* feat: implement path sorting

* feat: add filename column and bump db version

* feat: implement filename sorting

* doc: tick off roadmap item for filename sorting

* fix: use existing filename translation instead

* fix: populate Entry.filename in constructor

* fix: add missing assertion in search_library fixture

* fix: update search test library

* feat: add db migration test

* fix: add missing library for test
2025-03-12 17:28:42 -07:00
Weblate (bot)
93dcfdd51c translations: update Turkish, Portuguese(BR), Hungarian, French (#857)
* Translated using Weblate (Turkish)

Currently translated at 100.0% (294 of 294 strings)

Co-authored-by: Nyghl <hknimre@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/tr/
Translation: TagStudio/Strings

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 89.1% (262 of 294 strings)

Co-authored-by: Helder Lima <vinicius-helder@hotmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/pt_BR/
Translation: TagStudio/Strings

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (294 of 294 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Szíjártó Levente Pál <szijartoleventepal@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/hu/
Translation: TagStudio/Strings

* Translated using Weblate (French)

Currently translated at 100.0% (294 of 294 strings)

Co-authored-by: Med <45147847+kitsumed@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/fr/
Translation: TagStudio/Strings

---------

Co-authored-by: Nyghl <hknimre@gmail.com>
Co-authored-by: Helder Lima <vinicius-helder@hotmail.com>
Co-authored-by: Szíjártó Levente Pál <szijartoleventepal@gmail.com>
Co-authored-by: Med <45147847+kitsumed@users.noreply.github.com>
2025-03-12 17:27:53 -07:00
HermanKassler
f680ecb648 feat: add setting to not display full filepath (#841)
* feat: add show file path option to settings menu (#4)

* feat: implement file path option for file attributes (#10)

* feat: implement file path option for file attributes

---------

Co-authored-by: Zarko Sesto <sesto@kth.se>

* feat: update ui after changing file path setting (#9)

* feat: implement file path options for main window (#11)

Co-authored-by: Zarko Sesto <sesto@kth.se>

* feat: add realtime feedback for setting changes (#13)

Co-authored-by: Zarko Sesto <sesto@kth.se>

style: formatted code changes with ruff  (#16)

* tests: Added tests to test file path display and settings menu

enhancement: formated using RUFF

test: addeded test to check title bar update

* fix: move check for file option to correct spot in open_library (#17)

* fix: change translate_with_setter to new method (#18)

* fix: update call to translator to be inline with upstream (#19)

* refactor: update some imports to reflect refactor on upstream (#20)

* refactor: update import paths to reflect recent reformat

* format: run ruff format

* translation: add translations for filepath setting

* refactor: store filepath options in integer enum

---------

Co-authored-by: RubenSocha <40490633+RubenSocha@users.noreply.github.com>
Co-authored-by: ErzaDuNord <102242407+ErzaDuNord@users.noreply.github.com>
Co-authored-by: Zarko Sesto <sesto@kth.se>
Co-authored-by: BitGatito <usmanbinmujeeb@gmail.com>
2025-03-12 15:51:55 -07:00
Travis Abendshien
234ddec78b refactor: remove unused ui files
Remove unused UI files (Port #605)

Co-Authored-By: VasigaranAndAngel <72515046+VasigaranAndAngel@users.noreply.github.com>
2025-03-12 03:35:35 -07:00
94 changed files with 2886 additions and 1915 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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
View 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
View 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
View 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
View 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".

View File

@@ -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. |

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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
![Tag Disambiguation Example](../assets/tag_disambiguation_example.png)
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

View File

@@ -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.
![Tag Categories Example](../assets/tag_categories_example.png)

View File

@@ -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.
![Tag Color Manager](../assets/tag_color_manager.png)

View File

@@ -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]

View File

@@ -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.

View File

@@ -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. |

View File

@@ -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
View File

@@ -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"
}
},

View File

@@ -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; };

View File

@@ -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

View File

@@ -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 = {

View File

@@ -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 = {

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View 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())

View File

@@ -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)

View File

@@ -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:

View File

@@ -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.

View File

@@ -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

View File

@@ -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,

View File

@@ -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:

View File

@@ -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)

View File

@@ -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:

View File

@@ -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(

View File

@@ -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:

View File

@@ -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)

View File

@@ -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",

View File

@@ -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")

View 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)

View File

@@ -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,
)

View File

@@ -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 = []

View File

@@ -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

View File

@@ -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)

View File

@@ -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}")

View File

@@ -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

View File

@@ -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)

View File

@@ -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"

View File

@@ -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()

View File

@@ -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)

View File

@@ -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>&lt;</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>&gt;</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>

View File

@@ -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

View File

@@ -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

View File

@@ -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 ========================================================

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()))

View File

@@ -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)

View File

@@ -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)
),
)
)

View File

@@ -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()

View File

@@ -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

View File

@@ -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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

View 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;

View File

@@ -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...",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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.",

View File

@@ -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 nont 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 dajouter dabord 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",

View File

@@ -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",

View 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": "タグ:"
}

View File

@@ -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}",

View 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"
}

View File

@@ -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}\"?",

View File

@@ -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"
}

View File

@@ -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",

View File

@@ -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={},

View File

@@ -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()

View File

@@ -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")

View 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))

View 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

View File

@@ -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])

View File

@@ -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):

View File

@@ -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)

View File

@@ -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

View File

@@ -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))