Compare commits

...

61 Commits

Author SHA1 Message Date
Travis Abendshien
981cc60135 chore: bump version to v9.5.1 2025-03-06 16:32:19 -08:00
Travis Abendshien
2073a284fe fix: separate about screen title from translations (#836)
* fix: separate about screen title from translations

* chore: format with ruff's evil twin
2025-03-06 16:22:54 -08:00
Jann Stute
6d1ff90355 test: add tests for translations (#833)
* refactor: simplify pathlib imports

* feat: add tests for invalid format keys and missing / unnecessary translations

* feat: parametrise format key test

* feat: parametrise completeness test

* fix: include more useful information in the error message

* refactor: remove unused method

* refactor: extract translation loading to a separate function

* fix: include more useful information in the error message

* fix: don't fail test when language hasn't been completely translated
2025-03-06 15:28:48 -08:00
Weblate (bot)
4fe76f13fd fix(translations): fix invalid placeholders (#835)
* Translated using Weblate (Turkish)

Currently translated at 24.9% (72 of 289 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/tr/
Translation: TagStudio/Strings

* Translated using Weblate (Filipino)

Currently translated at 44.6% (129 of 289 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/fil/
Translation: TagStudio/Strings

* Translated using Weblate (Tamil)

Currently translated at 24.9% (72 of 289 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/ta/
Translation: TagStudio/Strings

* Translated using Weblate (Danish)

Currently translated at 3.8% (11 of 289 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/da/
Translation: TagStudio/Strings

* Translated using Weblate (Spanish)

Currently translated at 96.1% (278 of 289 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/es/
Translation: TagStudio/Strings

* Translated using Weblate (Norwegian Bokmål)

Currently translated at 17.9% (52 of 289 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/nb_NO/
Translation: TagStudio/Strings

* Translated using Weblate (Toki Pona)

Currently translated at 36.3% (105 of 289 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/tok/
Translation: TagStudio/Strings

---------

Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
2025-03-06 15:22:57 -08:00
Weblate (bot)
6690f5bc09 translations: update Portuguese(BR), German, Russian, Spanish, French; fix formatting keys (#826)
* Translated using Weblate (Turkish)

Currently translated at 22.4% (65 of 289 strings)

Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/tr/
Translation: TagStudio/Strings

* Translated using Weblate (Filipino)

Currently translated at 43.9% (127 of 289 strings)

Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/fil/
Translation: TagStudio/Strings

* Translated using Weblate (Tamil)

Currently translated at 22.4% (65 of 289 strings)

Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/ta/
Translation: TagStudio/Strings

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 88.9% (257 of 289 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 88.9% (257 of 289 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 64.7% (187 of 289 strings)

Co-authored-by: Helder Lima <vinicius-helder@hotmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/pt_BR/
Translation: TagStudio/Strings

* Translated using Weblate (German)

Currently translated at 98.9% (286 of 289 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Theasacraft <spam@accounts.samuelbellmann.de>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/de/
Translation: TagStudio/Strings

* Translated using Weblate (Danish)

Currently translated at 3.8% (11 of 289 strings)

Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/da/
Translation: TagStudio/Strings

* Translated using Weblate (Russian)

Currently translated at 73.3% (212 of 289 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: werdei <6pe3ep1lu@mozmail.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/ru/
Translation: TagStudio/Strings

* Translated using Weblate (Polish)

Currently translated at 97.5% (282 of 289 strings)

Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/pl/
Translation: TagStudio/Strings

* Translated using Weblate (Spanish)

Currently translated at 95.8% (277 of 289 strings)

Translated using Weblate (Spanish)

Currently translated at 95.8% (277 of 289 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Joan <joancanalscrehuet@gmail.com>
Co-authored-by: Travis Abendshien <46939827+CyanVoxel@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% (289 of 289 strings)

Translated using Weblate (French)

Currently translated at 100.0% (289 of 289 strings)

Translated using Weblate (French)

Currently translated at 100.0% (289 of 289 strings)

Translated using Weblate (French)

Currently translated at 100.0% (289 of 289 strings)

Co-authored-by: Aless <dev@aless.ch>
Co-authored-by: Bamowen <mathieu.monsauret@gmail.com>
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 (Swedish)

Currently translated at 25.2% (73 of 289 strings)

Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/sv/
Translation: TagStudio/Strings

* Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 68.8% (199 of 289 strings)

Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/zh_Hant/
Translation: TagStudio/Strings

* Translated using Weblate (Norwegian Bokmål)

Currently translated at 16.9% (49 of 289 strings)

Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/nb_NO/
Translation: TagStudio/Strings

* Translated using Weblate (Toki Pona)

Currently translated at 33.2% (96 of 289 strings)

Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/tok/
Translation: TagStudio/Strings

---------

Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Co-authored-by: Helder Lima <vinicius-helder@hotmail.com>
Co-authored-by: Theasacraft <spam@accounts.samuelbellmann.de>
Co-authored-by: werdei <6pe3ep1lu@mozmail.com>
Co-authored-by: Joan <joancanalscrehuet@gmail.com>
Co-authored-by: Aless <dev@aless.ch>
Co-authored-by: Bamowen <mathieu.monsauret@gmail.com>
Co-authored-by: Med <45147847+kitsumed@users.noreply.github.com>
2025-03-06 14:02:21 -08:00
Travis Abendshien
7e75087b24 fix: restore translate_formatted() method as format() (#830)
* fix: restore `translate_formatted()` method

* fix: set "Create & Add" button text

* refactor: rename "translate_formatted" to "format"

* translations: replace invalid format key names with "{unknown_key}"
2025-03-06 13:05:56 -08:00
Paula Rumeu
07b7d40926 docs: fix category typo (#834) 2025-03-06 03:54:44 -08:00
Travis Abendshien
80aab1ec96 chore: tweak pull request template 2025-03-04 14:27:40 -08:00
Travis Abendshien
bcf3b2f96b fix: prevent future library versions from being opened 2025-03-03 15:02:35 -08:00
Travis Abendshien
d94befed5d Merge branch 'main' of https://github.com/TagStudioDev/TagStudio 2025-03-03 14:18:25 -08:00
Travis Abendshien
d91ee5dbf7 chore: bump version to v9.5.0 2025-03-03 14:16:44 -08:00
Travis Abendshien
ea8d074c51 chore: remove leftover log statement 2025-03-03 14:16:34 -08:00
Travis Abendshien
186c6e2139 ui: change default tag color to white 2025-03-03 14:15:10 -08:00
Xarvex
c695f1765f chore(ci): add PR template (#824) 2025-03-03 14:14:46 -06:00
Travis Abendshien
077f91af88 ui: don't show the deprecated "Autofill" macro 2025-03-03 11:39:32 -08:00
Travis Abendshien
accf50ccf9 ui: change default sorting direction to descending 2025-03-03 11:32:41 -08:00
Travis Abendshien
9f9a5ccf44 fix: catch OverflowError when displaying video duration 2025-03-03 11:26:07 -08:00
Weblate (bot)
ee23ef3451 translations: update Filipino, Portuguese(BR), German, Hungarian, Polish, Spanish, French (#809)
* Translated using Weblate (Filipino)

Currently translated at 43.9% (127 of 289 strings)

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

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 64.7% (187 of 289 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 63.3% (183 of 289 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 49.4% (143 of 289 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 49.4% (143 of 289 strings)

Co-authored-by: Alexander Lennart Formiga Johnsson <alexander_formiga@hotmail.com>
Co-authored-by: DaviMarquezeli <Davi.Marquezeli@gmail.com>
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 (German)

Currently translated at 98.9% (286 of 289 strings)

Translated using Weblate (German)

Currently translated at 98.9% (286 of 289 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: JoeJoeTV <hannes1033@gmail.com>
Co-authored-by: Kurt S <mail@kurtys.de>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/de/
Translation: TagStudio/Strings

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (289 of 289 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 (Polish)

Currently translated at 97.5% (282 of 289 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: qronikarz <qronikarz@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/pl/
Translation: TagStudio/Strings

* Translated using Weblate (Spanish)

Currently translated at 57.7% (167 of 289 strings)

Translated using Weblate (Spanish)

Currently translated at 55.0% (159 of 289 strings)

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/es/
Translation: TagStudio/Strings

* Translated using Weblate (French)

Currently translated at 100.0% (289 of 289 strings)

Translated using Weblate (French)

Currently translated at 100.0% (289 of 289 strings)

Translated using Weblate (French)

Currently translated at 100.0% (289 of 289 strings)

Translated using Weblate (French)

Currently translated at 100.0% (289 of 289 strings)

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

---------

Co-authored-by: searinminecraft <114207889+searinminecraft@users.noreply.github.com>
Co-authored-by: Alexander Lennart Formiga Johnsson <alexander_formiga@hotmail.com>
Co-authored-by: DaviMarquezeli <Davi.Marquezeli@gmail.com>
Co-authored-by: Helder Lima <vinicius-helder@hotmail.com>
Co-authored-by: JoeJoeTV <hannes1033@gmail.com>
Co-authored-by: Kurt S <mail@kurtys.de>
Co-authored-by: Szíjártó Levente Pál <szijartoleventepal@gmail.com>
Co-authored-by: qronikarz <qronikarz@users.noreply.hosted.weblate.org>
Co-authored-by: Nginearing <142851004+Nginearing@users.noreply.github.com>
Co-authored-by: Med <45147847+kitsumed@users.noreply.github.com>
Co-authored-by: Obscaeris <translation@obscaeris.com>
2025-03-03 11:17:33 -08:00
Jann Stute
2a787592b6 fix: resouce leak in translate_with_setter by deleting the offending code (#817)
* feat: implement first class for translated widget

* feat: add further translated widget types

* refactor: replace usages of translate_qobject with appropriate classes

* refactor: remove wrapper classes

* refactor: remove unnecessary used of Translations.formatted

* refactor: remove further unnecessary format calls

* refactor: replace calls for Translations.formatted with calls to str.format and remove some unused code

* refactor: remove TranslatedString class

* refactor: replace translate_with_setter with direct calls to the setter
2025-03-03 11:13:49 -08:00
Travis Abendshien
152c2b20ba docs: add "sort by filename" to roadmap 2025-03-02 22:13:00 -08:00
Travis Abendshien
1b5b40792c Create FUNDING.yml 2025-03-02 21:58:42 -08:00
SkeleyM
4b381e78ba docs: fix slight spelling error (#820) 2025-02-27 14:23:05 -08:00
Travis Abendshien
b1126d5313 fix: open libraries from v9.5.0-pr1 in newer versions (#815)
* ui: show more informative library error messages

* tests: add sqlite db migration tests

* tests: fix and refactor migration tests

* fix: apply db8 schema changes before repairing db6

* docs: add save file format change log

* chore: remove db version explanations from docstrings
2025-02-24 15:14:54 -08:00
Travis Abendshien
f9ca743b64 docs: add custom color info 2025-02-20 15:14:51 -08:00
Travis Abendshien
04bd1bc027 fix(docs): restore broken links, ignore callout formatting 2025-02-20 14:24:12 -08:00
Haider Ali
ecc0bb57fb docs: change "Tag Manager" to "Manage Tags" (#806) 2025-02-17 20:42:48 -08:00
Travis Abendshien
d11b514bab fix: use remove() instead of difference() for self collision checks 2025-02-17 15:39:43 -08:00
Travis Abendshien
fe94d84b94 ci: ignore specific pyright flags 2025-02-17 14:52:59 -08:00
Travis Abendshien
db408f09c4 chore: bump version to v9.5.0-pr4 2025-02-17 14:47:22 -08:00
Travis Abendshien
61b9fcf764 feat(ui): add language setting (#803)
* feat(ui): add language setting

* translations: implement remaining todos

* ui: show language names in settings instead of codes

* translations: add Dutch setting, anticipating #798
2025-02-17 14:45:30 -08:00
Travis Abendshien
28de21ade7 feat(ui)!: user-created tag colors (#801)
* feat: custom tag colors

* ui: minor ui polish

* ui: add confirmation for deleting colors

* ui: match tag_color_preview focused style

* ui: reduce spacing between color swatch groups

* ui!: change default behavior of secondary color

The secondary color now acts as only the text color by default, with the new `color_border` bool serving to optionally restore the previous text + colored border behavior.

* ui: adjust focused tag/color button styles

* fix: avoid namespace collision

* fix: make reserved namespace check case-insensitive

* ui: add namespace description + prompt

* fix: don't reset tag color if none are chosen

* refactor(ui): use form layout for build_color

* fix(ui): dynamically scale field title widget

* feat(ui): add additional tag shade colors

Add "burgundy", "dark-teal", and "dark_lavender" tag colors.

* fix: don't check for self in collision checks

* fix: update tag references on color update

* fix(ui): stop fields widgets expanding indefinitely
2025-02-17 14:36:34 -08:00
Weblate (bot)
2173d1d4f4 translations: add Dutch; update Hungarian, Spanish, French (#798)
* Translated using Weblate (Hungarian)

Currently translated at 100.0% (259 of 259 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 (Dutch)

Currently translated at 40.1% (104 of 259 strings)

Added translation using Weblate (Dutch)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Pheubel <luca.guagliardo@live.nl>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/nl/
Translation: TagStudio/Strings

* Translated using Weblate (Spanish)

Currently translated at 51.7% (134 of 259 strings)

Translated using Weblate (Spanish)

Currently translated at 51.3% (133 of 259 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (259 of 259 strings)

Translated using Weblate (French)

Currently translated at 100.0% (259 of 259 strings)

Translated using Weblate (French)

Currently translated at 100.0% (259 of 259 strings)

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

---------

Co-authored-by: Szíjártó Levente Pál <szijartoleventepal@gmail.com>
Co-authored-by: Pheubel <luca.guagliardo@live.nl>
Co-authored-by: Nginearing <142851004+Nginearing@users.noreply.github.com>
Co-authored-by: noceno <pasitobajet@gmail.com>
Co-authored-by: Med <45147847+kitsumed@users.noreply.github.com>
Co-authored-by: Obscaeris <translation@obscaeris.com>
2025-02-17 14:33:21 -08:00
SkeleyM
c1ec8a6650 feat(ui): clickable links in about section (#799) 2025-02-14 09:32:00 -08:00
Travis Abendshien
69d3a6ed09 docs: update library search 2025-02-14 09:25:57 -08:00
Travis Abendshien
e481ab64c9 docs: consolidate feature roadmap 2025-02-14 08:42:44 -08:00
Travis Abendshien
986ccabc81 chore: bump version to v9.5.0-pr3 2025-02-10 11:22:00 -08:00
Travis Abendshien
297fdf22e8 feat: add smartcase and globless path searches (#743)
* fix: return path_strings in session

* feat: add smartcase and globless path search

Known issues: failing tests, sluggish autocomplete

* fix: all operational searches

* fix: limit path autocomplete to 100 items

* tests: add test cases
2025-02-10 10:26:02 -08:00
Weblate (bot)
319ef9a5fe translations: update Danish, Hungarian (#785)
* Translated using Weblate (Danish)

Currently translated at 4.6% (11 of 237 strings)

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

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (237 of 237 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (236 of 236 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

---------

Co-authored-by: Jumle <juliusv135@gmail.com>
Co-authored-by: Szíjártó Levente Pál <szijartoleventepal@gmail.com>
2025-02-10 10:23:58 -08:00
Travis Abendshien
6b646f8955 perf: optimize query methods and reduce preview panel updates (#794)
* fix(ui): don't update preview for non-selected badge toggles

* ui: optimize badge updating

NOTE: In theory this approach opens the doors for a visual state mismatch between the tags shown and the tags on the actual entry. The performance increase comes from the fact that `get_entry_full()` isn't called, which is the real bottleneck here.

* perf: optimize query methods
2025-02-09 18:33:47 -08:00
Travis Abendshien
a2b9237be4 fix(ui): expand usage of esc for closing modals (#793)
* fix(ui): expand usage of esc for closing modals

* chore: remove log statements

* refactor: use Qt enum in place of magic number

* ui: use enter key to save panel widgets
2025-02-09 18:33:29 -08:00
ChloeVZ
abc7cc3915 docs: improve python and venv setup instructions in CONTRIBUTING.md (#791)
* docs: refer to pyenv in CONTRIBUTING.md

This commit was made in order to account for users who may already have
a Python installation that isn't 3.12.

A good real world scenario where this may be a problem is users running
on rolling release Linux distributions, such as Arch Linux which
provides bleeding edge updates in a weekly basis.

Continuing with Arch Linux as an example, it provides Python 3.13, which
the current version of TagStudio doesn't seem to work with, an issue I
encountered myself this morning.

In some systems like Windows, this may not matter, but it is an issue
nonetheless, and users using a package manager centric system can't be
expected to downgrade/upgrade their Python version for TagStudio, as
this may cause issues in their system.

The easiest way to solve this is with pyenv, which enables users to
install other Python versions independent from each other, aside from
the system install, and allows specifying a version to use for a
directory (And its subdirectories), the current terminal session, or the
entire system if desired.

Here is a short, technical and objective summary of the changes:
- Updated `.gitignore` to to ignore `.python-version` which pyenv uses.
- Updated CONTRIBUTING.md to refer to pyenv for versioning issues.
    - pyenv is listed as a prerequisite, specifying in parenthesis that it's only for when your Python install isn't 3.12.
    - The "Creating a Python Virtual Environment" section now has an additional step at the beginning, to check that the current Python installation is 3.12.
        - As part of this added step, there are steps to use pyenv to install Python 3.12 separately from the existing Python installation, and activate it for the TagStudio root folder, in case it's not the appropriate version.
    - The numbering of the other steps was offset accordingly.
    - The "Manually Launching (Outside of an IDE)" section now refers to the above first step, should the user encounter some kind of error.

* docs: refer to alternate shells in CONTRIBUTING.md

Some users may not be using the default shell for their system, and
Linux users are the most likely to do this, though this can be seen in
macOS as well. People will often use shells like fish which is not POSIX
compliant but works very well and has great auto-completion, it's very
user friendly.

The reason why I care about this is that the instructions specify to use
.venv/bin/activate which is a bash script and will not run on fish or
csh, and if you take a look at the script, indeed it says it will only
run with bash.

Python will provide alternate scripts for other shells, and at least on
my system (CachyOS), those are scripts for Powershell, bash, fish and
csh. People using these alternate shells, who don't know this may be
confused when running the script referenced in CONTRIBUTING.md and
seeing it fail completely.

The only real change in this commit was adding under the "Linux/macOS"
command in step 3 (step 2 prior to my last commit) of the "Creating a
Python Virtual Environment" section, a short instruction to switch to
the default shell, or use the appropriate script for the user's
preferred shell if any (Since, at least on my system, there aren't
scripts for every shell, for example there is no activate.zsh file).

* fix: modal windows now have the Qt.Dialog flag

Set the Qt.Dialog flag for modal windows, such as "Add Field" so that
they will be seen as dialogs in the backend. This change is unlikely to
be at all noticeable in systems like Windows, but for users running
tiling window managers like bspwm or Sway, this will prevent these modal
windows from being tiled alongside the main window, instead they will be
floating on top, which is the expected behaviour seen on floating window
managers, like the ones used by Windows and macOS.

Added self.setWindowFlag(Qt.Dialog, on=True) # type: ignore to the
following files:
- tagstudio/src/qt/modals/add_field.py
- tagstudio/src/qt/modals/delete_unlinked.py
- tagstudio/src/qt/modals/drop_import.py
- tagstudio/src/qt/modals/file_extension.py
- tagstudio/src/qt/modals/fix_dupes.py
- tagstudio/src/qt/modals/fix_unlinked.py
- tagstudio/src/qt/modals/folders_to_tags.py
- tagstudio/src/qt/modals/mirror_entities.py
- tagstudio/src/qt/widgets/paged_panel/paged_panel.py
- tagstudio/src/qt/widgets/panel.py
- tagstudio/src/qt/widgets/progress.py

Note that without adding # type: ignore, MyPy *will* give out an error,
presumably because PySide6 is missing type hints for several things, and
this may *or* may not be a justifiable use of # type: ignore, which
throws the MyPy type checking for that line out the window.

Ref: #392, #464

* fix: I forgot to run ruff format

* revert: changes to docs and gitignore

This revert is for moving the referenced changes to their own pull request.

Refs: 15f4f38, a25ef8c

* docs: other shells and pyenv in CONTRIBUTING.md

This commit changes CONTRIBUTING.md to refer to pyenv in the case of
issues with Python versions, as well as downloading the correct version
off of the Python website alternatively. It also now elaborates on the
process of running the Python virtual environment on Linux and macOS, by
referencing activation scripts for alternative shells such as fish and
CSH.

The table added to CONTRIBUTING.md is taken from the official Python
documentation: https://docs.python.org/3.12/library/venv.html

* revert: accidental inclution of #464

Refs: a51550f, 5854ccc
2025-02-06 21:41:01 -08:00
Travis Abendshien
a3df70bb8d feat: port file trashing (#409) to v9.5 (#792)
* feat: port file trashing (#409) to sql

* translations: translate file deletion actions

* fix: rename method from refactor conflict

* refactor: implement feedback
2025-02-05 19:25:10 -08:00
Travis Abendshien
466af1e6a6 refactor(ui): recycle tag list in TagSearchPanel (#788)
* feat(ui): recycle tag list in `TagSearchPanel`

* chore: address mypy warnings

* fix: order results from sql before limiting

* fix(ui): check for self.exclude before remaking sets

* fix(ui): only init tag manager and file ext manager once

* fix(ui:): remove redundant tag search panel updates

* update code comments and docstrings

* feat(ui): add tag view limit dropdown

* ensure disconnection of file_extension_panel.saved
2025-02-05 19:15:28 -08:00
Travis Abendshien
26d3b1908b fix(ui): hide library actions when no library is open (#787) 2025-02-04 13:53:03 -08:00
Travis Abendshien
f38a79b06e chore: bump version to v9.5.0-pr2 2025-02-03 16:22:02 -08:00
Travis Abendshien
dbf7353bdf fix(ui): improve tagging ux (#784)
* fix(ui): always reset tag search panel when opened

* feat: return parent tags in tag search

Known issue: this bypasses the tag_limit

* refactor: use consistant `datetime` imports

* refactor: sort by base tag name to improve performance

* fix: escape `&` when displaying tag names

* ui: show "create and add" tag with other results

* fix: optimize and fix tag result sorting

* feat(ui): allow tags in list to be selected and added by keyboard

* ui: use `esc` to reset search focus and/or close modal

* fix(ui): add pressed+focus styling to "create tag" button

* ui: use `esc` key to close `PanelWidget`

* ui: move disambiguation button to right side

* ui: expand clickable area of "-" tag button, improve styling

* ui: add "Ctrl+M" shortcut to open tag manager

* fix(ui): show "add tags" window title when accessing from home
2025-02-03 16:15:40 -08:00
Travis Abendshien
480328b83b fix: patch incorrect description type & invalid disambiguation_id refs (#782)
* fix: update DESCRIPTION to the TEXT_BOX type

* fix: remove `disambiguation_id` refs with a tag is deleted
2025-02-03 15:32:11 -08:00
Travis Abendshien
6a54323307 refactor: wrap migration_iterator lambda in a try/except block (#773) 2025-02-03 15:31:43 -08:00
Travis Abendshien
f48b363383 fix: catch ParsingError (#779)
* fix: move `path_strings` var inside `with` block

* refactor: move lambda to local function

* fix: catch `ParsingError` and return no results

* refactor: move `ParsingError` handling out to `QtDriver`

Reverts changes made to `test_search.py` and `enums.py`.
2025-02-03 15:31:12 -08:00
Travis Abendshien
634e1c7fe9 chore: update pyproject.toml 2025-02-02 23:29:53 -08:00
Travis Abendshien
90a826d128 fix(ui): reduce field title width to make room for edit and delete buttons 2025-02-02 17:13:08 -08:00
SkeleyM
93fc28e092 fix: allow tag names with colons in search (#765)
* Refactor allowing colons

* fix formatting
2025-02-02 15:56:50 -08:00
SkeleyM
80c7e81e69 fix: save all tag attributes from "Create & Add" modal (#762) 2025-02-02 13:49:17 -08:00
SkeleyM
f212e2393a fix(docs): fix screenshot sometimes not rendering (#775)
* Fix image not showing up

* formatting
2025-02-02 13:44:34 -08:00
Travis Abendshien
d7958892b7 docs: add more links to index.md 2025-02-01 14:09:17 -08:00
Travis Abendshien
5be7dfc314 docs: add library_search page 2025-02-01 14:06:41 -08:00
Travis Abendshien
6e402ac34d docs: add note about glob searching in the readme 2025-01-31 23:45:31 -08:00
Travis Abendshien
9bdbafa40c docs: add information about "tag manager" 2025-01-31 23:42:37 -08:00
Travis Abendshien
2215403201 fix: don't wrap field names too early 2025-01-31 23:40:47 -08:00
pinhead
16ebd89196 docs: fix typo for "category" in usage.md (#760) 2025-01-31 21:46:30 -08:00
Travis Abendshien
f5ff4d78c1 docs: update field and library pages 2025-01-31 17:23:33 -08:00
110 changed files with 5061 additions and 1548 deletions

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
patreon: cyanvoxel

30
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,30 @@
### Summary
<!--
^^^ Summarize the changes done and why they were done above.
By submitting this pull request, you certify that you have read the
[CONTRIBUTING.md](https://github.com/TagStudioDev/TagStudio/blob/main/CONTRIBUTING.md).
IMPORTANT FOR FEATURES: Please verify that a feature request or some other form
of communication with maintainers was already conducted in terms of approving.
Thank you for your eagerness to contribute!
-->
### Tasks Completed
<!-- No requirements, just context for reviewers. -->
- Platforms Tested:
- [ ] Windows x86
- [ ] Windows ARM
- [ ] macOS x86
- [ ] macOS ARM
- [ ] Linux x86
- [ ] Linux ARM
<!-- If an unspecified platform was tested, please add it here -->
- Tested For:
- [ ] Basic functionality
- [ ] PyInstaller executable <!-- Not necessarily required, but appreciated! -->
<!-- If other important criteria was tested for, please add it here -->

4
.gitignore vendored
View File

@@ -88,9 +88,7 @@ profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
.python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.

View File

@@ -44,15 +44,30 @@ If you wish to launch the source version of TagStudio outside of your IDE:
> [!TIP]
> On Linux and macOS, you can launch the `tagstudio.sh` script to skip the following process, minus the `requirements-dev.txt` installation step. _Using the script is fine if you just want to launch the program from source._
1. In the root repository directory, create a python virtual environment:
1. Make sure you're using the correct Python version:
- If the output matches `Python 3.12.x` (where the x is any number) then you're using the correct Python version and can skip to step 2. Otherwise, you can install the correct Python version from the [Python](https://www.python.org/downloads/) website, or you can use a tool like [pyenv](https://github.com/pyenv/pyenv/) to install the correct version without changes to your system:
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.
2. In the root repository directory, create a python virtual environment:
`python3 -m venv .venv`
2. Activate your environment:
3. Activate your environment:
- Windows w/Powershell: `.venv\Scripts\Activate.ps1`
- Windows w/Command Prompt: `.venv\Scripts\activate.bat`
- Linux/macOS: `source .venv/bin/activate`
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` |
|PWSH |`.venv/bin/activate.ps1` |
3. Install the required packages:
4. Install the required packages:
- `pip install -r requirements.txt`
- If developing (includes Ruff and Mypy): `pip install -r requirements-dev.txt`
@@ -61,6 +76,8 @@ _Learn more about setting up a virtual environment [here](https://docs.python.or
### Manually Launching (Outside of an IDE)
If you encounter errors about the Python version, or seemingly vague script errors, [pyenv](https://github.com/pyenv/pyenv/) may solve your issue. See step 1 of [Creating a Python Virtual Environment](#creating-a-python-virtual-environment).
- **Windows** (start_win.bat)
- To launch TagStudio, launch the `start_win.bat` file. You can modify this .bat file or create a shortcut and add one or more additional arguments if desired.

View File

@@ -82,7 +82,7 @@ Translation hosting generously provided by [Weblate](https://weblate.org/en/). C
### Search
- Search for file entries based on tags, file path (`path:`), file types (`filetype:`), and even media types! (`mediatype:`)
- Search for file entries based on tags, file path (`path:`), file types (`filetype:`), and even media types! (`mediatype:`). Path searches currently use [glob](<https://en.wikipedia.org/wiki/Glob_(programming)>) syntax, so you may need to wrap your filename or filepath in asterisks while searching. This will not be strictly necessary in future versions of the program.
- Use and combine boolean operators (`AND`, `OR`, `NOT`) along with parentheses groups, quotation escaping, and underscore substitution to create detailed search queries
- Use special search conditions (`special:untagged` and `special:empty`) to find file entries without tags or fields, respectively
@@ -164,7 +164,11 @@ Create a new tag by accessing the "New Tag" option from the Edit menu or by pres
- Parent tags with the disambiguation check next to them will be used to help disambiguate tag names that may not be unique.
- For example: If you had a tag for "Freddy Fazbear", you might add "Five Nights at Freddy's" as one of the parent tags. If the disambiguation box is checked next to "Five Nights at Freddy's" parent tag, then the tag "Freddy Fazbear" will display as "Freddy Fazbear (Five Nights at Freddy's)". Furthermore, if the "Five Nights at Freddy's" tag has a shorthand like "FNAF", then the "Freddy Fazbear" tag will display as "Freddy Fazbear (FNAF)".
- The **color** option lets you select an optional color palette to use for your tag.
- The **"Is Cagegory"** property lets you treat this tag as a category under which itself and any child tags inheriting from it will be sorted by inside the preview panel.
- The **"Is Category"** property lets you treat this tag as a category under which itself and any child tags inheriting from it will be sorted by inside the preview panel.
#### Tag Manager
You can manage your library of tags from opening the "Tag Manager" panel from Edit -> "Tag Manager". From here you can create, search for, edit, and permanently delete any tags you've created in your library.
### Editing Tags

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 105 KiB

View File

@@ -10,7 +10,8 @@ Pre-built binaries from trusted sources are available on the [FFmpeg website](ht
![Windows Download Location](../assets/ffmpeg_windows_download.png)
!!! note
<!-- prettier-ignore -->
!!! warning
Do NOT download the source code by mistake!
To Install:
@@ -19,10 +20,10 @@ To Install:
2. Move extracted contents to a unique folder (i.e; `c:\ffmpeg` or `c:\Program Files\ffmpeg`)
3. Add FFmpeg to your system PATH
1. In Windows, search for or go to "Edit the system environment variables" under the Control Panel
2. Under "User Variables", select "Path" then edit
3. Click new and add `<Your folder>\bin` (e.g; `c:\ffmpeg\bin` or `c:\Program Files\ffmpeg\bin`)
4. Click "Okay"
1. In Windows, search for or go to "Edit the system environment variables" under the Control Panel
2. Under "User Variables", select "Path" then edit
3. Click new and add `<Your folder>\bin` (e.g; `c:\ffmpeg\bin` or `c:\Program Files\ffmpeg\bin`)
4. Click "Okay"
### Package Managers

View File

@@ -9,8 +9,11 @@ title: Home
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.
<figure width="60%" markdown="span">
![TagStudio screenshot](assets/screenshot.png)
<figcaption>TagStudio Alpha v9.5.0 running on macOS Sequoia.</figcaption>
</figure>
## Feature Roadmap
@@ -21,13 +24,13 @@ The [Feature Roadmap](updates/roadmap.md) lists all of the planned core features
### Libraries
- Create libraries/vaults centered around a system directory. Libraries contain a series of entries: the representations of your files combined with metadata fields. Each entry represents a file in your librarys directory, and is linked to its location.
- Create [libraries](./library/index.md) centered around a system directory. Libraries contain a series of entries: the representations of your files combined with metadata fields. Each entry represents a file in your librarys directory, and is linked to its location.
- Address moved, deleted, or otherwise "unlinked" files by using the "Fix Unlinked Entries" option in the Tools menu.
### Tagging + Custom Metadata
### Tagging + Metadata Fields
- Add custom powerful tags to your library entries
- Add metadata to your library entries, including:
- Add custom powerful [tags](./library/tag.md) to your library entries
- Add [metadata fields](./library/field.md) to your library entries, including:
- Name, Author, Artist (Single-Line Text Fields)
- Description, Notes (Multiline Text Fields)
- Create rich tags composed of a name, color, a list of aliases, and a list of “parent tags” - these being tags in which these tags inherit values from.
@@ -37,13 +40,13 @@ The [Feature Roadmap](updates/roadmap.md) lists all of the planned core features
### Search
- Search for file entries based on tags, file path (`path:`), file types (`filetype:`), and even media types! (`mediatype:`)
- [Search](./library/library_search.md) for file entries based on tags, file path (`path:`), file types (`filetype:`), and even media types! (`mediatype:`)
- Use and combine boolean operators (`AND`, `OR`, `NOT`) along with parentheses groups, quotation escaping, and underscore substitution to create detailed search queries
- Use special search conditions (`special:untagged` and `special:empty`) to find file entries without tags or fields, respectively
- Use special search conditions (`special:untagged`) to find file entries without tags or fields, respectively
### File Entries
- Nearly all file types are supported in TagStudio libraries - just not all have dedicated thumbnail support.
- Nearly all [file](./library/entry.md) types are supported in TagStudio libraries - just not all have dedicated thumbnail support.
- Preview most image file types, animated GIFs, videos, plain text documents, audio files, Blender projects, and more!
- Open files or file locations by right-clicking on thumbnails and previews and selecting the respective context menu options. You can also click on the preview panel image to open the file, and click the file path label to open its location.
- Delete files from both your library and drive by right-clicking the thumbnail(s) and selecting the "Move to Trash"/"Move to Recycle Bin" option.

View File

@@ -4,16 +4,17 @@ To download TagStudio, visit the [Releases](https://github.com/TagStudioDev/TagS
**We do not currently publish TagStudio to any package managers. Any TagStudio distributions outside of the GitHub releases page are _unofficial_ and not maintained by us.** Installation support will not be given to users installing from unofficial sources. Use these versions at your own risk.
<!-- prettier-ignore -->
!!! info "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.
<!-- prettier-ignore -->
!!! info "For Linux Users"
On Linux with non-Qt based Desktop Environments you may be unable to open TagStudio. You need to make sure that "xcb-cursor0" or "libxcb-cursor0" packages are installed. For more info check [Missing linux dependencies](https://github.com/TagStudioDev/TagStudio/discussions/182#discussioncomment-9452896)
## Third-Party Dependencies
- For 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](/docs/help/ffmpeg.md) guide.
- For 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.
## Optional Arguments

View File

@@ -1,34 +1,23 @@
# Field
# Fields
Fields are the building blocks of metadata stored in [entries](entry.md). Fields have several base types for representing different kinds of information, including:
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.
#### `text_line`
## Field Types
- A string of text, displayed as a single line.
- e.g: Title, Author, Artist, URL, etc.
### Text Line
#### `text_box`
A string of text, displayed as a single line.
- A long string of text displayed as a box of text.
- e.g: Description, Notes, etc.
- e.g: Title, Author, Artist, URL, etc.
#### `tag_box`
### Text Box
- A box of [tags](tag.md) defined and added by the user.
- Multiple tag boxes can be used to separate classifications of tags.
- e.g: Content Tags, Meta Tags, etc.
A long string of text displayed as a box of text.
#### `datetime` [WIP]
- e.g: Description, Notes, etc.
- A date and time value.
- e.g: Date Created, Date Modified, Date Taken, etc.
### Datetime [WIP]
#### `checkbox` [WIP]
A date and time value.
- A simple two-state checkbox.
- Can be associated with a tag for quick organization.
- e.g: Archive, Favorite, etc.
#### `collation` [obsolete]
- Previously used for associating files to be used in a [collation](../utilities/macro.md#create-collage), will be removed in favor of a more flexible feature in future updates.
- e.g: Date Published, Date Taken, etc.

View File

@@ -1,4 +1,5 @@
# Library
The library is how TagStudio represents your chosen directory, with every file inside of it being displayed as an [entry](entry.md). You can have as many or few libraries as you wish, since each libraries' data is stored within a `.TagStudio` folder at its root.
The library is how TagStudio represents your chosen directory, with every file inside being represented by a [file entry](entry.md). You can have as many or few libraries as you wish, since each libraries' data is stored within a `.TagStudio` folder at its root. From there the library save file itself is stored as `ts_library.sqlite`, with TagStudio versions 9.4 and below using a the legacy `ts_library.json` format.
Note that this means [tags](tag.md) you create only exist _per-library_.

View File

@@ -0,0 +1,121 @@
# Library Search
TagStudio provides various methods to search your library, ranging from TagStudio data such as tags to inherent file data such as paths or media types.
## Boolean Operators
TagStudio allows you to use common [boolean search](https://en.wikipedia.org/wiki/Full-text_search#Boolean_queries) operators when searching your library, along with [grouping](#grouping-and-nesting), [nesting](#grouping-and-nesting), and [character escaping](#escaping-characters). Note that you may need to use grouping in order to get the desired results you're looking for.
### AND
The `AND` operator will only return results that match **both** sides of the operator. `AND` is used implicitly when no boolean operators are given. To use the `AND` operator explicitly, simply type "and" (case insensitive) in-between items of your search.
<!-- prettier-ignore -->
!!! example
Searching for "Tag1 Tag2" will be treated the same as "Tag1 `AND` Tag2" and will only return results that contain both Tag1 and Tag2.
### OR
The `OR` operator will return results that match **either** the left or right side of the operator. To use the `OR` operator simply type "or" (case insensitive) in-between items of your search.
<!-- prettier-ignore -->
!!! example
Searching for "Tag1 `OR` Tag2" will return results that contain either "Tag1", "Tag2", or both.
### NOT
The `NOT` operator will returns results where the condition on the right is **false.** To use the `NOT` operator simply type "not" (case insensitive) in-between items of your search. You can also begin your search with `NOT` to only view results that do not contain the next term that follows.
<!-- prettier-ignore -->
!!! example
Searching for "Tag1 `NOT` Tag2" will only return results that contain "Tag1" while also not containing "Tag2".
### Grouping and Nesting
Searches can be grouped and nested by using parentheses to surround parts of your search query.
<!-- 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.
### Escaping Characters
Sometimes search queries have ambiguous characters and need to be "escaped". This is most common with tag names which contain spaces, or overlap with existing search keywords such as "[path:](#filename-and-path) of exile". To escape most search terms, surround the section of your search in plain quotes. Alternatively, spaces in tag names can be replaced by underscores.
#### Valid Escaped Tag Searches
- "Tag Name With Spaces"
- Tag_Name_With_Spaces
#### Invalid Escaped Tag Searches
- Tag Name With Spaces
- Reason: Ambiguity between a tag named "Tag Name With Spaces" and four individual tags called "Tag", "Name", "With", "Spaces".
## 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.
## Fields
_[Field](field.md) search is currently not in the program, however is coming in a future version._
## File Entry Search
### Filename and Path
Filename and path search is available via the `path:` keyword and comes in a few different styles. By default, any string that follows the `path:` keyword will be searched as a substring inside a file's complete filepath. This means that given a file `folder/my_file.txt`, searching for `path: my_file` or `path: folder` will both return results for that file.
#### Case Sensitivity
TagStudio uses a "[smartcase](https://neovim.io/doc/user/options.html#'smartcase')"-like system for case sensitivity. This means that a search term typed in `lowercase` will be treated as **case-insensitive**, while a term typed in any `MixedCase` will be treated as **case-sensitive**. This makes it quicker to type searches when case sensitivity isn't required, while also providing a simple option to leverage case sensitivity when desired. Note that this means there's technically no way to currently search for a lowercase term while respecting case sensitivity.
#### Glob Syntax
Optionally, you may use [glob](<https://en.wikipedia.org/wiki/Glob_(programming)>) syntax to search filepaths.
#### Examples
Given a file "Artwork/Piece.jpg", the following searches will return results for it:
- `path: artwork/piece.jpg`
- `path: Artwork/Piece.jpg`
- `path: piece.jpg`
- `path: Piece.jpg`
- `path: artwork`
- `path: rtwor`
- `path: ece.jpg`
- `path: iec`
- `path: artwork/*`
- `path: Artwork/*`
- `path: *piece.jpg*`
- `path: *Piece.jpg*`
- `path: *artwork*`
- `path: *Artwork*`
- `path: *rtwor*`
- `path: *ece.jpg*`
- `path: *iec*`
- `path: *.jpg`
While the following searches will **NOT:**
- `path: ARTWORK/Piece.jpg` _(Reason: Mismatched case)_
- `path: *aRtWoRk/Piece*` _(Reason: Mismatched case)_
- `path: PieCe.jpg` _(Reason: Mismatched case)_
- `path: *PieCe.jpg*` _(Reason: Mismatched case)_
## Special Searches
Some predefined searches use the `special:` keyword prefix and give quick results for certain special search queries.
### Untagged
To see all your file entries which don't contain any tags, use the `special: untagged` search.
### Empty
**_NOTE:_** _Currently unavailable in v9.5.0_
To see all your file entries which don't contain any tags _and_ any fields, use the `special: empty` search.

View File

@@ -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 Fredddy'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 Fredddy'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 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.
## Tag Relationships
@@ -60,7 +60,7 @@ Lastly, when searching your files with broader categories such as `Character` or
### Component Tags
**_[Coming in version 9.6](../updates/roadmap.md#96-alpha)_**
**_[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.
@@ -68,15 +68,19 @@ Component tags will be built from a composition-based, or "HAS" type relationshi
### Color
Tags use a default uncolored appearance by default, however can take on a number of built-in and user-created\* colors and color palettes! Tag color palettes can be based on a single color value (see: TagStudio Standard, TagStudio Shades, TagStudio Pastels) or use an optional secondary color to override the border and text colors (see: TagStudio Neon).
Tags use a default uncolored appearance by default, however can take on a number of built-in and user-created colors and color palettes! Tag color palettes can be based on a single color value (see: TagStudio Standard, TagStudio Shades, TagStudio Pastels) or use an optional secondary color use for the text and optionally the tag border (e.g. TagStudio Neon).
![Tag Color Selection](../assets/tag_color_selection.png)
\*_Coming in the full version 9.5.0 release_
#### User-Created Colors
Custom palettes and colors can be created via the [Tag Color Manager](./tag_color.md). These colors will display alongside the built-in colors inside the tag selection window and are separated by their namespace names. Colors which use the secondary color for the tag border will be outlined in that color, otherwise they will only display the secondary color on the bottom of the swatch to indicate at a glance that the text colors are different.
![Custom Tag Color Selection](../assets/custom_tag_color_selection.png)
### Icon
**_[Coming in version 9.6](../updates/roadmap.md#96-alpha)_**
**_[Coming in version 9.6](../updates/roadmap.md#v96)_**
## Tag Properties
@@ -90,7 +94,7 @@ When the "Is Category" property is checked, this tag now acts as a category sepa
#### Is Hidden
**_[Coming in version 9.6](../updates/roadmap.md#96-alpha)_**
**_[Coming in version 9.6](../updates/roadmap.md#v96)_**
When the "Is Hidden" property is checked, any file entries tagged with this tag will not show up in searches by default. This property comes by default with the built-in "Archived" tag.

71
docs/library/tag_color.md Normal file
View File

@@ -0,0 +1,71 @@
# Tag Colors
TagStudio features a variety of built-in tag colors, alongside the ability for users to create their own custom tag color palettes.
## 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.
![Tag Color Manager](../assets/tag_color_manager.png)
## Creating a Namespace
TagStudio uses namespaces to group colors into palettes. Namespaces are a way for you to use the same color name across multiple palettes without having to worry about [name collision](https://en.wikipedia.org/wiki/Name_collision) with other palettes. This is especially useful when sharing your color palettes with others!\*
_\* Color pack sharing coming in a future update_
To create your first namespace, either click the "New Namespace" button or the large button prompt underneath the built-in colors.
![Create Namespace](../assets/create_namespace.png)
### Name
The display name of the namespace, used for presentation.
### ID Slug
An internal ID for the namespace which is automatically derived from the namespace name.
Namespaces beginning with "tagstudio" are reserved by TagStudio and will automatically have their text changed.
<!-- prettier-ignore -->
!!! note
It's currently not possible to manually edit the Namespace ID Slug. This will be possible once sharable color packs are added.
## Creating a Color
Once you've created your first namespace, click the "+" button inside the namespace section to create a color. To edit a color that you've previously created, either click on the color name or right click and select "Edit Color" from the context menu.
![Create Color (Primary Color)](../assets/custom_color_primary_only.png)
### Name
The display name for the color, used for presentation. You may occasionally see the color name followed by the [namespace name](#name) in parentheses to disambiguate it from other colors with the same name.
### ID Slug
Similar to [Namespace ID Slugs](#id-slug), the ID Slug is used as an internal ID and is automatically derived from the tag color name.
<!-- prettier-ignore -->
!!! note
It's currently not possible to manually edit the Color ID Slug. This will be possible once sharable color packs are added.
### Primary Color
The primary color is used as the main tag color and by default is used as the background color with the text and border colors being derived from this color.
### Secondary Color
By default, the secondary color is only used as an optional override for the tag text color. This color can be cleared by clicking the adjacent "Reset" button.
![Create Color (Secondary Color)](../assets/custom_color_no_border.png)
The secondary color can also be used as the tag border color by checking the "Use Secondary Color for Border" box.
![Create Color (Use Secondary for Border)](../assets/custom_color_border.png)
## Using Colors
When editing a tag, click the tag color button to bring up the tag color selection panel. From here you can choose any built-in TagStudio color as well as any of your custom colors.
![Tag Color Selection](../assets/tag_color_selection.png)

View File

@@ -1,11 +1,11 @@
---
tags:
- Upcoming Feature
- Upcoming Feature
---
# Tag Overrides
Tag overrides are the ability to add or remove [parent tags](tag.md#subtags) from a [tag](tag.md) on a per- [entry](entry.md) basis.
Tag overrides are the ability to add or remove [parent tags](tag.md#parent-tags) from a [tag](tag.md) on a per- [entry](entry.md) basis.
## Examples

View File

@@ -10,218 +10,122 @@ Features are broken up into the following priority levels, with nested prioritie
- [MEDIUM] - Important but not necessary
- [LOW] - Just nice to have
## Core Feature List
- [ ] Tags [HIGH]
- [x] ID-based, not string based [HIGH]
- [x] Tag name [HIGH]
- [x] Tag alias list, aka alternate names [HIGH]
- [x] Tag shorthand (specific short alias for displaying) [HIGH]
- [x] Parent/Inheritance subtags [HIGH]
- [ ] Composition/HAS subtags [HIGH]
- [x] Deleting Tags [HIGH]
- [ ] Merging Tags [HIGH]
- [ ] Tag Icons [HIGH]
- [ ] Small Icons [HIGH]
- [ ] Large Icons for Profiles [MEDIUM]
- [ ] Built-in Icon Packs (i.e. Boxicons) [HIGH]
- [ ] User Defined Icons [HIGH]
- [ ] Multiple Languages for Tag Strings [MEDIUM]
- [ ] User-defined tag colors [HIGH]
- [x] ID based, not string or hex [HIGH]
- [x] Color name [HIGH]
- [x] Color value (hex) [HIGH]
- [x] Existing colors are now a set of base colors [HIGH]
- [x] [Tag Categories](../library/tag_categories.md) [HIGH]
- [x] Property available for tags that allow the tag and any inheriting from it to be displayed separately in the preview panel under a title [HIGH]
- [x] Title is tag name [HIGH]
- [ ] [Tag Overrides](../library/tag_overrides.md) [MEDIUM]
- [ ] Per-file overrides of subtags [HIGH]
- [ ] Tag Packs [MEDIUM]
- [ ] Human-readable (i.e. JSON) files containing tag data [HIGH]
- [ ] Importable [HIGH]
- [ ] Exportable [HIGH]
- [ ] Conflict resolution [HIGH]
- [ ] Color Packs [MEDIUM]
- [ ] Human-readable (i.e. JSON) files containing tag data [HIGH]
- [ ] Importable [HIGH]
- [ ] Exportable [HIGH]
- [ ] Exportable Library Data [HIGH]
- [ ] Standard notation format (i.e. JSON) contacting all library data [HIGH]
- [ ] [Macros](../utilities/macro.md) [HIGH]
- [ ] Sharable Macros [MEDIUM]
- [ ] Standard notation format (i.e. JSON) contacting macro instructions [HIGH]
- [ ] Exportable [HIGH]
- [ ] Importable [HIGH]
- [ ] Triggers [HIGH]
- [ ] On new file [HIGH]
- [ ] On library refresh [HIGH]
- [...]
- [ ] Actions [HIGH]
- [ ] Add tag(s) [HIGH]
- [ ] Add field(s) [HIGH]
- [ ] Set field content [HIGH]
- [ ] [...]
- [ ] Settings Menu [HIGH]
- [ ] Application Settings [HIGH]
- [ ] Stored in system user folder/designated folder [HIGH]
- [ ] Library Settings [HIGH]
- [ ] Stored in `.TagStudio` folder [HIGH]
- [ ] Multiple Root Directories per Library [HIGH]
- [ ] [Entry groups](../library/entry_groups.md) [HIGH]
- [ ] Groups for files/entries where the same entry can be in multiple groups [HIGH]
- [ ] Ability to number entries within group [HIGH]
- [ ] Ability to set sorting method for group [HIGH]
- [ ] Ability to set custom thumbnail for group [HIGH]
- [ ] Group is treated as entry with tags and metadata [HIGH]
- [ ] Nested groups [MEDIUM]
- [ ] Fields [HIGH]
- [x] Text Boxes [HIGH]
- [x] Text Lines [HIGH]
- [ ] Dates [HIGH]
- [ ] Custom field names [HIGH]
- [ ] Search engine [HIGH]
- [x] Boolean operators [HIGH]
- [ ] Tag objects + autocomplete [HIGH]
- [x] Filename search [HIGH]
- [x] Filetype search [HIGH]
- [x] Search by extension (e.g. ".jpg", ".png") [HIGH]
- [x] Optional consolidation of extension synonyms (i.e. ".jpg" can equal ".jpeg") [LOW]
- [x] Search by media type (e.g. "image", "video", "document") [MEDIUM]
- [ ] Field content search [HIGH]
- [ ] HAS operator for composition tags [HIGH]
- [ ] OCR search [LOW]
- [ ] Fuzzy Search [LOW]
- [ ] Sortable results [HIGH]
- [ ] Sort by relevance [HIGH]
- [x] Sort by date added [HIGH]
- [ ] Sort by date created [HIGH]
- [ ] Sort by date modified [HIGH]
- [ ] Sort by date taken (photos) [MEDIUM]
- [ ] Sort by file size [HIGH]
- [ ] Sort by file dimension (images/video) [LOW]
- [ ] Automatic Entry Relinking [HIGH] [#36](https://github.com/TagStudioDev/TagStudio/issues/36)
- [ ] Detect Renames [HIGH]
- [ ] Detect Moves [HIGH]
- [ ] Detect Deletions [HIGH]
- [ ] Image Collages [LOW] [#91](https://github.com/TagStudioDev/TagStudio/issues/91)
- [ ] UI [HIGH]
- [ ] Tagging Panel [HIGH]
- [ ] Top Tags [HIGH]
- [ ] Recent Tags [HIGH]
- [ ] Tag Search [HIGH]
- [ ] Pinned Tags [HIGH]
- [ ] Configurable Thumbnails [MEDIUM]
- [ ] Custom thumbnail override [HIGH]
- [ ] Toggle File Extension Label [MEDIUM]
- [ ] Toggle Duration Label [MEDIUM]
- [ ] Custom Tag Badges [LOW]
- [ ] Thumbnails [HIGH]
- [ ] File Duration Label [HIGH]
- [ ] 3D Model Previews [MEDIUM]
- [ ] STL Previews [HIGH]
- [x] Drag and Drop [HIGH]
- [x] Drag files _to_ other programs [HIGH]
- [x] Drag files _to_ file explorer windows [MEDIUM]
- [x] Drag files _from_ file explorer windows [MEDIUM]
- [x] Drag files _from_ other programs [LOW]
- [ ] File Preview Panel [HIGH]
- [ ] Video Playback [HIGH]
- [x] Play/Pause [HIGH]
- [x] Loop [HIGH]
- [x] Toggle Autoplay [MEDIUM]
- [ ] Volume Control [HIGH]
- [x] Toggle Mute [HIGH]
- [ ] Timeline scrubber [HIGH]
- [ ] Fullscreen [MEDIUM]
- [x] Audio Playback [HIGH]
- [x] Play/Pause [HIGH]
- [ ] Loop [HIGH]
- [ ] Toggle Autoplay [MEDIUM]
- [x] Volume Control [HIGH]
- [x] Toggle Mute [HIGH]
- [x] Timeline scrubber [HIGH]
- [ ] Optimizations [HIGH]
- [x] Thumbnail caching [HIGH]
- [ ] File property caching/indexes [HIGH]
## Version Milestones
These version milestones are rough estimations for when the previous core features will be added. For a more definitive idea for when features are coming, please reference the current GitHub [milestones](https://github.com/TagStudioDev/TagStudio/milestones).
_(This list was created after the release of version 9.4)_
<!-- prettier-ignore -->
!!! note
This list was created after the release of version 9.4
### 9.5 (Alpha)
### v9.5
#### Core
- [x] SQL backend [HIGH]
- [x] Translations _(Any applicable)_ [MEDIUM]
- [ ] Tags [HIGH]
- [x] Deleting Tags [HIGH]
- [ ] User-defined tag colors [HIGH]
- [x] ID based, not string or hex [HIGH]
- [x] Color name [HIGH]
- [x] Color value (hex) [HIGH]
- [x] Existing colors are now a set of base colors [HIGH]
- [x] [Tag Categories](../library/tag_categories.md) [HIGH]
- [x] Property available for tags that allow the tag and any inheriting from it to be displayed separately in the preview panel under a title [HIGH]
- [ ] Search engine [HIGH]
- [x] Boolean operators [HIGH]
- [ ] Tag objects + autocomplete [HIGH]
- [x] Filename search [HIGH]
- [x] Filetype search [HIGH]
- [x] Search by extension (e.g. ".jpg", ".png") [HIGH]
- [x] Optional consolidation of extension synonyms (i.e. ".jpg" can equal ".jpeg") [LOW]
- [x] Search by media type (e.g. "image", "video", "document") [MEDIUM]
- [ ] Field content search [HIGH]
- [ ] Sortable results [HIGH]
- [x] Sort by date added [HIGH]
- [ ] Sort by date created [HIGH]
- [ ] Sort by date modified [HIGH]
- [ ] Settings Menu [HIGH]
- [ ] Application Settings [HIGH]
- [ ] Stored in system user folder/designated folder [HIGH]
- [ ] Library Settings [HIGH]
- [ ] Stored in `.TagStudio` folder [HIGH]
- [ ] Optimizations [HIGH]
- [x] Thumbnail caching [HIGH]
### 9.6 (Alpha)
#### Tags
- [x] Deleting Tags [HIGH]
- [ ] User-defined tag colors [HIGH]
- [x] ID based, not string or hex [HIGH]
- [x] Color name [HIGH]
- [x] Color value (hex) [HIGH]
- [x] Existing colors are now a set of base colors [HIGH]
- [x] [Tag Categories](../library/tag_categories.md) [HIGH]
- [x] Property available for tags that allow the tag and any inheriting from it to be displayed separately in the preview panel under a title [HIGH]
#### Search
- [x] Boolean operators [HIGH]
- [x] Filename search [HIGH]
- [x] Filetype search [HIGH]
- [x] Search by extension (e.g. ".jpg", ".png") [HIGH]
- [x] Optional consolidation of extension synonyms (i.e. ".jpg" can equal ".jpeg") [LOW]
- [x] Search by media type (e.g. "image", "video", "document") [MEDIUM]
- [x] Sort by date added [HIGH]
#### UI
- [ ] Translations _(Any applicable)_ [MEDIUM]
#### Performance
- [x] Thumbnail caching [HIGH]
### v9.6
#### Core
- [ ] Cached file property table (media duration, word count, dimensions, etc.) [MEDIUM]
#### Library
- [ ] Tags [HIGH]
- [ ] Merging Tags [HIGH]
- [ ] Composition/HAS subtags [HIGH]
- [ ] Tag Icons [HIGH]
- [ ] Small Icons [HIGH]
- [ ] Large Icons for Profiles [MEDIUM]
- [ ] Built-in Icon Packs (i.e. Boxicons) [HIGH]
- [ ] User Defined Icons [HIGH]
- [ ] Multiple Languages for Tag Strings [MEDIUM]
- [ ] Title is tag name [HIGH]
- [ ] Title has tag color [MEDIUM]
- [ ] Tag marked as category does not display as a tag itself [HIGH]
- [ ] [Tag Overrides](../library/tag_overrides.md) [MEDIUM]
- [ ] Per-file overrides of subtags [HIGH]
- [ ] Fields [HIGH]
- [ ] Dates [HIGH]
- [ ] Custom field names [HIGH]
- [ ] Multiple Root Directories per Library [HIGH]
- [ ] `.ts_ignore` (`.gitignore`-style glob ignoring) [HIGH]
- [ ] Sharable Color Packs [MEDIUM]
- [ ] Human-readable (TOML) files containing tag data [HIGH]
- [ ] Importable [HIGH]
- [ ] Exportable [HIGH]
#### Tags
- [ ] Merging Tags [HIGH]
- [ ] [Component/HAS](../library/tag.md#component-tags) subtags [HIGH]
- [ ] Tag Icons [HIGH]
- [ ] Small Icons [HIGH]
- [ ] Large Icons for Profiles [MEDIUM]
- [ ] Built-in Icon Packs (i.e. Boxicons) [HIGH]
- [ ] User Defined Icons [HIGH]
- [ ] Multiple Languages for Tag Strings [MEDIUM]
- [ ] Title is tag name [HIGH]
- [ ] Title has tag color [MEDIUM]
- [ ] Tag marked as category does not display as a tag itself [HIGH]
- [ ] [Tag Overrides](../library/tag_overrides.md) [MEDIUM]
- [ ] Per-file overrides of subtags [HIGH]
#### Fields
- [ ] Datetime fields [HIGH]
- [ ] Custom field names [HIGH]
#### Search
- [ ] Field content search [HIGH]
- [ ] Sort by date created [HIGH]
- [ ] Sort by date modified [HIGH]
- [ ] Sort by filename [HIGH]
- [ ] HAS operator for composition tags [HIGH]
- [ ] Search bar rework
- [ ] Improved tag autocomplete [HIGH]
- [ ] Tags appear as widgets in search bar [HIGH]
#### UI
- [ ] File duration on video thumbnails [HIGH]
- [ ] 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]
- [ ] Library Settings [HIGH]
- [ ] Stored in `.TagStudio` folder [HIGH]
- [ ] Tagging Panel [HIGH]
Togglebale persistent main window panel or popout. Replaces the current tag manager.
- [ ] Top Tags [HIGH]
- [ ] Recent Tags [HIGH]
- [ ] Tag Search [HIGH]
- [ ] Pinned Tags [HIGH]
- [ ] Search engine [HIGH]
- [ ] HAS operator for composition tags [HIGH]
### 9.7 (Alpha)
- [ ] New tabbed tag building UI to support the new tag features [HIGH]
### v9.7
#### Library
- [ ] Configurable Thumbnails [MEDIUM]
- [ ] Toggle File Extension Label [MEDIUM]
- [ ] Toggle Duration Label [MEDIUM]
- [ ] Custom Tag Badges [LOW]
- [ ] Thumbnails [HIGH]
- [ ] File Duration Label [HIGH]
- [ ] [Entry groups](../library/entry_groups.md) [HIGH]
- [ ] Groups for files/entries where the same entry can be in multiple groups [HIGH]
- [ ] Ability to number entries within group [HIGH]
@@ -230,46 +134,84 @@ _(This list was created after the release of version 9.4)_
- [ ] Group is treated as entry with tags and metadata [HIGH]
- [ ] Nested groups [MEDIUM]
### 9.8 (Possible Beta)
#### Search
- [ ] Sort by relevance [HIGH]
- [ ] Sort by date taken (photos) [MEDIUM]
- [ ] Sort by file size [HIGH]
- [ ] Sort by file dimension (images/video) [LOW]
#### [Macros](../utilities/macro.md)
- [ ] Sharable Macros [MEDIUM]
- [ ] Standard notation format (TOML) contacting macro instructions [HIGH]
- [ ] Exportable [HIGH]
- [ ] Importable [HIGH]
- [ ] Triggers [HIGH]
- [ ] On new file [HIGH]
- [ ] On library refresh [HIGH]
- [ ] [...]
- [ ] Actions [HIGH]
- [ ] Add tag(s) [HIGH]
- [ ] Add field(s) [HIGH]
- [ ] Set field content [HIGH]
- [ ] [...]
#### UI
- [ ] Custom thumbnail overrides [MEDIUM]
- [ ] Toggle File Extension Label [MEDIUM]
- [ ] Toggle Duration Label [MEDIUM]
- [ ] Custom Tag Badges [LOW]
- [ ] Unified Media Player [HIGH]
- [ ] Auto-hiding player controls
- [x] Play/Pause [HIGH]
- [x] Loop [HIGH]
- [x] Toggle Autoplay [MEDIUM]
- [ ] Volume Control [HIGH]
- [x] Toggle Mute [HIGH]
- [ ] Timeline scrubber [HIGH]
- [ ] Fullscreen [MEDIUM]
- [ ] Library list view [HIGH]
- [ ] Configurable page size [HIGH]
### v9.8
#### Library
- [ ] Automatic Entry Relinking [HIGH]
- [ ] Detect Renames [HIGH]
- [ ] Detect Moves [HIGH]
- [ ] Detect Deletions [HIGH]
- [ ] [Macros](../utilities/macro.md) [HIGH]
- [ ] Sharable Macros [MEDIUM]
- [ ] Standard notation format (i.e. JSON) contacting macro instructions [HIGH]
- [ ] Exportable [HIGH]
- [ ] Importable [HIGH]
- [ ] Triggers [HIGH]
- [ ] On new file [HIGH]
- [ ] On library refresh [HIGH]
- [...]
- [ ] Actions [HIGH]
- [ ] Add tag(s) [HIGH]
- [ ] Add field(s) [HIGH]
- [ ] Set field content [HIGH]
- [ ] [...]
### 9.9 (Possible Beta)
#### Search
- [ ] OCR search [LOW]
- [ ] Fuzzy Search [LOW]
### v9.9
#### Library
- [ ] Tag Packs [MEDIUM]
- [ ] Human-readable (i.e. JSON) files containing tag data [HIGH]
- [ ] Importable [HIGH]
- [ ] Exportable [HIGH]
- [ ] Conflict resolution [HIGH]
- [ ] Color Packs [MEDIUM]
- [ ] Human-readable (i.e. JSON) files containing tag data [HIGH]
- [ ] Importable [HIGH]
- [ ] Exportable [HIGH]
- [ ] Exportable Library Data [HIGH]
- [ ] Standard notation format (i.e. JSON) contacting all library data [HIGH]
### 10.0 (Possible Beta/Full Release)
#### Tags
- [ ] Tag Packs [MEDIUM]
- [ ] Human-readable (TOML) files containing tag data [HIGH]
- [ ] Multiple Languages for Tag Strings [MEDIUM]
- [ ] Importable [HIGH]
- [ ] Exportable [HIGH]
- [ ] Conflict resolution [HIGH]
### v10.0
- [ ] All remaining [HIGH] and optional [MEDIUM] features
### Post 10.0
### Post v10.0
#### Core
- [ ] Core Library/API
- [ ] Plugin Support

View File

@@ -0,0 +1,46 @@
# 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".
## 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 |
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 |
| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
| [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 |
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 |
| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
| [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 |
### 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 three new default colors: "Burgundy (TagStudio Shades)", "Dark Teal (TagStudio Shades)", and "Dark Lavender (TagStudio Shades)".
- Updates Neon colors to use the the new `color_border` property.

View File

@@ -37,7 +37,11 @@ Create a new tag by accessing the "New Tag" option from the Edit menu or by pres
- Parent tags with the disambiguation check next to them will be used to help disambiguate tag names that may not be unique.
- For example: If you had a tag for "Freddy Fazbear", you might add "Five Nights at Freddy's" as one of the parent tags. If the disambiguation box is checked next to "Five Nights at Freddy's" parent tag, then the tag "Freddy Fazbear" will display as "Freddy Fazbear (Five Nights at Freddy's)". Furthermore, if the "Five Nights at Freddy's" tag has a shorthand like "FNAF", then the "Freddy Fazbear" tag will display as "Freddy Fazbear (FNAF)".
- The **color** option lets you select an optional color palette to use for your tag.
- The **"Is Cagegory"** property lets you treat this tag as a category under which itself and any child tags inheriting from it will be sorted by inside the preview panel.
- The **"Is Category"** property lets you treat this tag as a category under which itself and any child tags inheriting from it will be sorted by inside the preview panel.
### 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.
## Editing Tags
@@ -47,9 +51,11 @@ To edit a tag, click on it inside the preview panel or right-click the tag and s
Inevitably some of the files inside your library will be renamed, moved, or deleted. If a file has been renamed or moved, TagStudio will display the thumbnail as a red broken chain link. To relink moved files or delete these entries, select the "Manage Unlinked Entries" option under the Tools menu. Click the "Refresh" button to scan your library for unlinked entries. Once complete, you can attempt to “Search & Relink” any unlinked file entries to their respective files, or “Delete Unlinked Entries” in the event the original files have been deleted and you no longer wish to keep their entries inside your library.
<!-- prettier-ignore -->
!!! warning
There is currently no method to relink entries to files that have been renamed - only moved or deleted. This is a high priority for future releases.
<!-- prettier-ignore -->
!!! warning
If multiple matches for a moved file are found (matches are currently defined as files with a matching filename as the original), TagStudio will currently ignore the match groups. Adding a GUI for manual selection, as well as smarter automated relinking, are high priorities for future versions.

View File

@@ -43,4 +43,4 @@ Tool is in development, will allow for user-defined sorting of [fields](../libra
### Folders to Tags
Creates tags from the existing folder structure in the library, which are previewed in a hierarchy view for the user to confirm. A tag will be created for each folder and applied to all entries, with each subfolder being linked to the parent folder as a [parent tag](../library/tag.md#subtags). Tags will initially be named after the folders, but can be fully edited and customized afterwards.
Creates tags from the existing folder structure in the library, which are previewed in a hierarchy view for the user to confirm. A tag will be created for each folder and applied to all entries, with each subfolder being linked to the parent folder as a [parent tag](../library/tag.md#parent-tags). Tags will initially be named after the folders, but can be fully edited and customized afterwards.

View File

@@ -1,3 +1,10 @@
[project]
name = "TagStudio"
description = "A User-Focused Photo & File Management System."
version = "9.5.1"
license = "GPL-3.0-only"
readme = "README.md"
[tool.ruff]
exclude = ["main_window.py", "home_ui.py", "resources.py", "resources_rc.py"]
line-length = 100
@@ -10,28 +17,19 @@ line-length = 100
convention = "google"
[tool.ruff.lint]
select = [
"B",
"D",
"E",
"F",
"FBT003",
"I",
"N",
"SIM",
"T20",
"UP",
]
ignore = [
"D100",
"D101",
"D102",
"D103",
"D104",
"D105",
"D106",
"D107",
]
select = ["B", "D", "E", "F", "FBT003", "I", "N", "SIM", "T20", "UP"]
ignore = ["D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107"]
[tool.pyright]
ignore = [".venv/**"]
include = ["tagstudio/**"]
reportAny = false
reportImplicitStringConcatenation = false
# reportOptionalMemberAccess = false
reportUnannotatedClassAttribute = false
reportUnknownArgumentType = false
reportUnknownMemberType = false
reportUnusedCallResult = false
[tool.mypy]
strict_optional = false

View File

@@ -12,6 +12,7 @@ PySide6_Addons==6.8.0.1
PySide6_Essentials==6.8.0.1
PySide6==6.8.0.1
rawpy==0.22.0
Send2Trash==1.8.3
SQLAlchemy==2.0.34
structlog==24.4.0
typing_extensions>=3.10.0.0,<=4.11.0

View File

@@ -78,7 +78,7 @@ app = BUNDLE(
name='TagStudio.app',
icon=icon,
bundle_identifier='com.cyanvoxel.tagstudio',
version='9.5.0',
version='9.5.1',
info_plist={
'NSAppleScriptEnabled': False,
'NSPrincipalClass': 'NSApplication',

Binary file not shown.

View File

@@ -1 +1,13 @@
{}
{
"app.pre_release": "Forududgivelse",
"color.title.no_color": "Ingen Farve",
"drop_import.description": "De følgende filer har allerede eksisterende stier i biblioteket",
"drop_import.duplicates_choice.plural": "Følgende {count} filer passer allerede til stier der eksistere i biblioteket.",
"drop_import.duplicates_choice.singular": "Den følgende fil matcher en allerede eksisterende sti i biblioteket.",
"drop_import.progress.label.initial": "Importere nye filer...",
"drop_import.progress.label.plural": "Importere nye filer...\n{count} Filer importeret.{suffix}",
"drop_import.progress.label.singular": "Importere nye filer...\n1 fil importeret.{suffix}",
"drop_import.progress.window_title": "Importer Filer",
"drop_import.title": "Konflikterende Fil(er)",
"edit.tag_manager": "Håndtere Tags"
}

View File

@@ -1,25 +1,46 @@
{
"about.content": "<p>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.</p>Lizenz: GPLv3<br>Konfigurations-Pfad: {config_path}<br>FFmpeg: {ffmpeg}<br>FFprobe: {ffprobe}<p><a href=\"https://github.com/TagStudioDev/TagStudio\">GitHub</a> | <a href=\"https://docs.tagstud.io\">Dokumentation</a> | <a href=\"https://discord.com/invite/hRNnVKhF2G\">Discord</a></p>",
"about.title": "Über",
"app.git": "Git Commit",
"app.pre_release": "Pre-Release",
"app.title": "{base_title} - Bibliothek '{library_dir}'",
"drop_import.description": "Die folgenden Dateinamen existieren bereits in der Bibliothek",
"drop_import.duplicates_choice.plural": "Die folgenden {count} Dateinamen existieren bereits in der Bibliothek.",
"drop_import.duplicates_choice.singular": "Der folgende Dateiname existiert bereits in der Bibliothek.",
"color.color_border": "Benutze Sekundärfarbe für die Umrandung",
"color.confirm_delete": "Soll die Farbe \"{color_name}\" wirklich gelöscht werden?",
"color.delete": "Tag löschen",
"color.import_pack": "Farb-Paket importieren",
"color.name": "Name",
"color.namespace.delete.prompt": "Soll dieser Farb-Namensraum wirklich gelöscht werden? Diese aktion wird neben dem Namensraum ALLE darin enthaltenen Farben löschen!",
"color.namespace.delete.title": "Farb-Namensraum löschen",
"color.new": "Neue Farbe",
"color.placeholder": "Farbe",
"color.primary": "Primärfarbe",
"color.primary_required": "Primärfarbe (erforderlich)",
"color.secondary": "Sekundärfarbe",
"color.title.no_color": "Keine Farbe",
"color_manager.title": "Tag-Farben verwalten",
"drop_import.description": "Die folgenden Dateien passen zu Dateipfaden, welche bereits in der Bibliothek existieren",
"drop_import.duplicates_choice.plural": "Die folgenden {count} Dateien passen zu Dateipfaden, welche bereits in der Bibliothek existieren.",
"drop_import.duplicates_choice.singular": "Die folgende Datei passt zu einem Dateipfad, welcher bereits in der Bibliothek existiert.",
"drop_import.progress.label.initial": "Neue Dateien werden importiert...",
"drop_import.progress.label.plural": "Neue Dateien werden importiert...\n{count} Dateien importiert.{suffix}",
"drop_import.progress.label.singular": "Neue Dateien werden importiert...\n1 Datei importiert.{suffix}",
"drop_import.progress.window_title": "Dateien Importieren",
"drop_import.title": "Dateikollisionen",
"drop_import.title": "Dateikollision(en)",
"edit.color_manager": "Tag-Farben verwalten",
"edit.copy_fields": "Felder kopieren",
"edit.paste_fields": "Felder einfügen",
"edit.tag_manager": "Tags Verwalten",
"entries.duplicate.merge": "Doppelte Einträge zusammenführen",
"entries.duplicate.merge.label": "Führe doppelte Einträge zusammen…",
"entries.duplicate.refresh": "Doppelte Einträge aktualisieren",
"entries.duplicates.description": "Doppelte Einträge sind definiert als mehrere Einträge, die auf dieselbe Datei auf der Festplatte verweisen. Durch das Zusammenführen dieser Einträge werden die Tags und Metadaten aller Duplikate zu einem einzigen konsolidierten Eintrag zusammengefasst. Diese sind nicht zu verwechseln mit „doppelten Dateien“, die Duplikate Ihrer Dateien selbst außerhalb von TagStudio sind.",
"entries.mirror": "Kopieren",
"entries.mirror.confirmation": "Sind Sie sich sicher, dass Sie die folgenden {count} Einträge kopieren wollen?",
"entries.mirror.label": "Kopiere {idx}/{total} Einträge...",
"entries.mirror.title": "Einträge werden kopiert",
"entries.mirror.window_title": "Einträge duplizieren",
"entries.mirror": "Spiegeln",
"entries.mirror.confirmation": "Sind Sie sich sicher, dass Sie die folgenden {count} Einträge spiegeln wollen?",
"entries.mirror.label": "Spiegele {idx}/{total} Einträge...",
"entries.mirror.title": "Einträge werden gespiegelt",
"entries.mirror.window_title": "Einträge spiegeln",
"entries.running.dialog.new_entries": "Füge {total} neue Dateieinträge hinzu...",
"entries.running.dialog.title": "Füge neue Dateieinträge hinzu",
"entries.tags": "Tags",
"entries.unlinked.delete": "Unverknüpfte Einträge löschen",
"entries.unlinked.delete.confirm": "Sind Sie sicher, dass Sie die folgenden {count} Einträge löschen wollen?",
@@ -84,12 +105,14 @@
"generic.filename": "Dateiname",
"generic.navigation.back": "Zurück",
"generic.navigation.next": "Weiter",
"generic.none": "Kein(e)",
"generic.overwrite": "Überschreibem",
"generic.overwrite_alt": "Überschreiben",
"generic.paste": "Einfügen",
"generic.recent_libraries": "Aktuelle Bibliotheken",
"generic.rename": "Umbenennen",
"generic.rename_alt": "Umbenennen",
"generic.reset": "Zurücksetzen",
"generic.save": "Speichern",
"generic.skip": "Überspringen",
"generic.skip_alt": "Über&springen",
@@ -123,11 +146,12 @@
"json_migration.heading.fields": "Felder:",
"json_migration.heading.file_extension_list": "Liste der Dateiendungen:",
"json_migration.heading.match": "Übereinstimmend",
"json_migration.heading.names": "Namen:",
"json_migration.heading.parent_tags": "Übergeordnete Tags:",
"json_migration.heading.paths": "Pfade:",
"json_migration.heading.shorthands": "Kurzformen:",
"json_migration.heading.tags": "Tags:",
"json_migration.info.description": "Bibliotheksdaten, welche mit TagStudio Versionen <b>9.4 und niedriger</b> erstellt wurden, müssen in das neue Format <b>v9.5+</b> migriert werden.<br><h2>Was du wissen solltest:</h2><ul><li>Deine bestehenden Bibliotheksdaten werden<b><i>NICHT</i></b> gelöscht.</li><li>Deine persönlichen Dateien werden <b><i>NICHT</i></b> gelöscht, verschoben oder verändert.</li><li>Das neue Format v9.5+ kann nicht von früheren TagStudio Versionen geöffnet werden.</li></ul>",
"json_migration.info.description": "Bibliotheksdaten, welche mit TagStudio-Versionen <b>9.4 und niedriger</b> erstellt wurden, müssen in das neue Format <b>v9.5+</b> migriert werden.<br><h2>Was du wissen solltest:</h2><ul><li>Deine bestehenden Bibliotheksdaten werden<b><i>NICHT</i></b> gelöscht.</li><li>Deine persönlichen Dateien werden <b><i>NICHT</i></b> gelöscht, verschoben oder verändert.</li><li>Das neue Format v9.5+ kann nicht von früheren TagStudio-Versionen geöffnet werden.</li></ul>",
"json_migration.migrating_files_entries": "Migriere {entries:,d} Dateieinträge...",
"json_migration.migration_complete": "Migration abgeschlossen!",
"json_migration.migration_complete_with_discrepancies": "Migration abgeschlossen, Diskrepanzen gefunden",
@@ -147,9 +171,16 @@
"library.refresh.scanning_preparing": "Überprüfe Verzeichnisse auf neue Dateien...\nBereite vor...",
"library.refresh.title": "Verzeichnisse werden aktualisiert",
"library.scan_library.title": "Bibliothek wird scannen",
"macros.running.dialog.new_entries": "Führe konfigurierte Makros für {count}/{total} neue Einträge aus",
"library_object.name": "Name",
"library_object.name_required": "Name (erforderlich)",
"library_object.slug": "ID Schlüssel",
"library_object.slug_required": "ID Schlüssel (erforderlich)",
"macros.running.dialog.new_entries": "Führe konfigurierte Makros für {count}/{total} neue Dateieinträge aus...",
"macros.running.dialog.title": "Ausführen von Makros bei neuen Einträgen",
"media_player.autoplay": "Autoplay",
"menu.delete_selected_files_ambiguous": "Datei(en) nach {trash_term} verschieben",
"menu.delete_selected_files_plural": "Dateien nach {trash_term} verschieben",
"menu.delete_selected_files_singular": "Datei nach {trash_term} verschieben",
"menu.edit": "Bearbeiten",
"menu.edit.ignore_list": "Dateien und Verzeichnisse ignorieren",
"menu.edit.manage_file_extensions": "Dateiendungen verwalten",
@@ -166,39 +197,66 @@
"menu.file.save_backup": "Bibliotheksbackup speichern",
"menu.file.save_library": "Bibliothek speichern",
"menu.help": "&Hilfe",
"menu.help.about": "Über",
"menu.macros": "&Makros",
"menu.macros.folders_to_tags": "Verzeichnisse zu Tags",
"menu.select": "Auswählen",
"menu.settings": "Optionen...",
"menu.tools": "Werkzeuge",
"menu.tools.fix_duplicate_files": "Duplizierte &Dateien reparieren",
"menu.tools.fix_unlinked_entries": "&Unverknüpfte Einträge reparieren",
"menu.view": "Ansicht",
"menu.window": "Fenster",
"namespace.create.description": "Namespaces werden von Tagstudio verwendet, um Gruppen von Objekten (bspw. Tags oder Farben) so darzustellen, dass sie einfach exportiert und geteilt werden können. Namespaces, die mit \"tagstudio\" beginnen sind für interne Vorgänge von Tagstudio reserviert.",
"namespace.create.description_color": "Tagfarben nutzen Namespaces als Farbpalettengruppen. Alle benutzerdefinierten Farben müssen erst einer Namespacegruppe zugeordnet werden.",
"namespace.create.title": "Namensraum erstellen",
"namespace.new.button": "Neuer Namensraum",
"namespace.new.prompt": "Erstelle einen neuen Namensraum um eigene Farben hinzuzufügen!",
"preview.multiple_selection": "<b>{count}</b> Elemente ausgewählt",
"preview.no_selection": "Keine Elemente ausgewählt",
"select.add_tag_to_selected": "Tag zu Ausgewähltem hinzufügen",
"select.all": "Alle auswählen",
"select.clear": "Auswahl leeren",
"settings.clear_thumb_cache.title": "Vorschaubild-Zwischenspeicher leeren",
"settings.language": "Sprache",
"settings.open_library_on_start": "Bibliothek zum Start öffnen",
"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.title": "Einstellungen",
"sorting.direction.ascending": "Aufsteigend",
"sorting.direction.descending": "Absteigend",
"splash.opening_library": "Öffne Bibliothek \"{library_path}\"...",
"status.deleted_file_plural": "{count} Dateien gelöscht!",
"status.deleted_file_singular": "1 Datei gelöscht!",
"status.deleted_none": "Keine Dateien gelöscht.",
"status.deleted_partial_warning": "Es wurden nur {count} Datei(en) gelöscht! Bitte Überprüfen, ob eine der Dateien derzeit fehlt oder in verwendet wird.",
"status.deleting_file": "Lösche Datei [{i}/{count}]: \"{path}\"...",
"status.library_backup_in_progress": "Bibliotheksbackup wird gespeichert...",
"status.library_backup_success": "Bibliotheks-Backup gespeichert unter: \"{path}\" ({time_span})",
"status.library_closed": "Bibliothek geschlossen ({time_span})",
"status.library_closing": "Bibliothek wird geschlossen...",
"status.library_save_success": "Bibliothek gespeichert und geschlossen!",
"status.library_search_query": "Durchsuche die Bibliothek...",
"status.library_version_expected": "Erwartet:",
"status.library_version_found": "Gefunden:",
"status.library_version_mismatch": "BIbliotheksversion stimmt nicht überein!",
"status.results": "Ergebnisse",
"status.results.invalid_syntax": "Ungültige Such-Syntax:",
"status.results_found": "{count} Ergebnisse gefunden ({time_span})",
"tag.add": "Tag hinzufügen",
"tag.add.plural": "Tags hinzufügen",
"tag.add_to_search": "Zur Suche hinzufügen",
"tag.aliases": "Aliase",
"tag.all_tags": "Alle Tags",
"tag.choose_color": "Tag-Farbe auswählen",
"tag.color": "Farbe",
"tag.confirm_delete": "Sind Sie sich sicher, dass Sie den Tag \"{tag_name}\" löschen wollen?",
"tag.create": "Tag erstellen",
"tag.create_add": "Erstellen && Hinzufügen \"{query}\"",
"tag.disambiguation.tooltip": "Diesen Tag zur Unterscheidung verwenden",
"tag.edit": "Tag bearbeiten",
"tag.is_category": "Ist Kategorie",
"tag.name": "Name",
"tag.new": "Neuer Tag",
"tag.parent_tags": "Übergeordnete Tags",
@@ -208,7 +266,19 @@
"tag.search_for_tag": "Nach Tag suchen",
"tag.shorthand": "Kürzel",
"tag.tag_name_required": "Tag Name (Pflichtfeld)",
"tag.view_limit": "Anzeige-Limit:",
"tag_manager.title": "Bibliothek Tags",
"trash.context.ambiguous": "Datei(en) nach {trash_term} verschieben",
"trash.context.plural": "Dateien nach {trash_term} verschieben",
"trash.context.singular": "Datei nach {trash_term} verschieben",
"trash.dialog.disambiguation_warning.plural": "Dies wird sie aus TagStudio <i>UND</i> vom Dateisystem entfernen!",
"trash.dialog.disambiguation_warning.singular": "Dies wird sie aus TagStudio <i>UND</i> vom Dateisystem entfernen!",
"trash.dialog.move.confirmation.plural": "Bist du sicher, dass du diese {count} Dateien in den {trash_term} verschieben möchtest?",
"trash.dialog.move.confirmation.singular": "Bist du sicher, dass du diese Datei in den {trash_term} verschieben möchtest?",
"trash.dialog.title.plural": "Dateien löschen",
"trash.dialog.title.singular": "Datei löschen",
"trash.name.generic": "Mülleimer",
"trash.name.windows": "Papierkorb",
"view.size.0": "Mini",
"view.size.1": "Klein",
"view.size.2": "Mittel",

View File

@@ -2,6 +2,19 @@
"app.git": "Git Commit",
"app.pre_release": "Pre-Release",
"app.title": "{base_title} - Library '{library_dir}'",
"color_manager.title": "Manage Tag Colors",
"color.color_border": "Use Secondary Color for Border",
"color.confirm_delete": "Are you sure you want to delete the color \"{color_name}\"?",
"color.delete": "Delete Tag",
"color.import_pack": "Import Color Pack",
"color.name": "Name",
"color.namespace.delete.prompt": "Are you sure you want to delete this color namespace? This will delete ALL colors in the namespace along with it!",
"color.namespace.delete.title": "Delete Color Namespace",
"color.new": "New Color",
"color.placeholder": "Color",
"color.primary_required": "Primary Color (Required)",
"color.primary": "Primary Color",
"color.secondary": "Secondary Color",
"color.title.no_color": "No Color",
"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.",
@@ -11,6 +24,7 @@
"drop_import.progress.label.singular": "Importing New Files...\n1 File imported.{suffix}",
"drop_import.progress.window_title": "Import Files",
"drop_import.title": "Conflicting File(s)",
"edit.color_manager": "Manage Tag Colors",
"edit.tag_manager": "Manage Tags",
"entries.duplicate.merge.label": "Merging Duplicate Entries...",
"entries.duplicate.merge": "Merge Duplicate Entries",
@@ -69,8 +83,8 @@
"folders_to_tags.description": "Creates tags based on your folder structure and applies them to your entries.\n The structure below shows all the tags that will be created and what entries they will be applied to.",
"folders_to_tags.open_all": "Open All",
"folders_to_tags.title": "Create Tags From Folders",
"about.title": "About",
"about.content": "<h2>TagStudio Alpha {version} ({branch})</h2><p>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.</p>License: GPLv3<br>Config path: {config_path}<br>FFmpeg: {ffmpeg}<br>FFprobe: {ffprobe}<p><a href=\"https://github.com/TagStudioDev/TagStudio\">GitHub</a> | <a href=\"https://docs.tagstud.io\">Documentation</a> | <a href=\"https://discord.com/invite/hRNnVKhF2G\">Discord</a></p>",
"about.title": "About TagStudio",
"about.content": "<p>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.</p>License: GPLv3<br>Config path: {config_path}<br>FFmpeg: {ffmpeg}<br>FFprobe: {ffprobe}<p><a href=\"https://github.com/TagStudioDev/TagStudio\">GitHub</a> | <a href=\"https://docs.tagstud.io\">Documentation</a> | <a href=\"https://discord.com/invite/hRNnVKhF2G\">Discord</a></p>",
"generic.add": "Add",
"generic.apply_alt": "&Apply",
"generic.apply": "Apply",
@@ -96,6 +110,7 @@
"generic.recent_libraries": "Recent Libraries",
"generic.rename_alt": "&Rename",
"generic.rename": "Rename",
"generic.reset": "Reset",
"generic.save": "Save",
"generic.skip_alt": "&Skip",
"generic.skip": "Skip",
@@ -143,6 +158,10 @@
"json_migration.title.old_lib": "<h2>v9.4 Library</h2>",
"json_migration.title": "Save Format Migration: \"{path}\"",
"landing.open_create_library": "Open/Create Library {shortcut}",
"library_object.name_required": "Name (Required)",
"library_object.name": "Name",
"library_object.slug_required": "ID Slug (Required)",
"library_object.slug": "ID Slug",
"library.field.add": "Add Field",
"library.field.confirm_remove": "Are you sure you want to remove this \"{name}\" field?",
"library.field.mixed_data": "Mixed Data",
@@ -157,6 +176,9 @@
"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",
"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}",
"menu.edit.ignore_list": "Ignore Files and Folders",
"menu.edit.manage_file_extensions": "Manage File Extensions",
"menu.edit.manage_tags": "Manage Tags",
@@ -172,16 +194,23 @@
"menu.file.save_backup": "&Save Library Backup",
"menu.file.save_library": "Save Library",
"menu.file": "&File",
"menu.help": "&Help",
"menu.help.about": "About",
"menu.help": "&Help",
"menu.macros.folders_to_tags": "Folders to Tags",
"menu.macros": "&Macros",
"menu.select": "Select",
"menu.settings": "Settings...",
"menu.tools.fix_duplicate_files": "Fix Duplicate &Files",
"menu.tools.fix_unlinked_entries": "Fix &Unlinked Entries",
"menu.tools": "&Tools",
"menu.view": "&View",
"menu.window": "Window",
"namespace.create.description_color": "Tag colors use namespaces as color palette groups. All custom colors must be under a namespace group first.",
"namespace.create.description": "Namespaces are used by TagStudio to separate groups of items such as tags and colors in a way that makes them easy to export and share. Namespaces starting with \"tagstudio\" are reserved by TagStudio for internal use.",
"namespace.create.title": "Create Namespace",
"namespace.new.button": "New Namespace",
"namespace.new.prompt": "Create a New Namespace to Start Adding Custom Colors!",
"preview.multiple_selection": "<b>{count}</b> Items Selected",
"preview.no_selection": "No Items Selected",
"select.add_tag_to_selected": "Add Tag to Selected",
"select.all": "Select All",
@@ -189,12 +218,20 @@
"edit.copy_fields": "Copy Fields",
"edit.paste_fields": "Paste Fields",
"settings.clear_thumb_cache.title": "Clear Thumbnail Cache",
"settings.language": "Language",
"settings.open_library_on_start": "Open Library on Start",
"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.title": "Settings",
"sorting.direction.ascending": "Ascending",
"sorting.direction.descending": "Descending",
"splash.opening_library": "Opening Library \"{library_path}\"...",
"status.deleted_file_plural": "Deleted {count} files!",
"status.deleted_file_singular": "Deleted 1 file!",
"status.deleted_none": "No files deleted.",
"status.deleted_partial_warning": "Only deleted {count} file(s)! Check if any of the files are currently missing or in use.",
"status.deleting_file": "Deleting file [{i}/{count}]: \"{path}\"...",
"status.library_backup_in_progress": "Saving Library Backup...",
"status.library_backup_success": "Library Backup Saved at: \"{path}\" ({time_span})",
"status.library_closed": "Library Closed ({time_span})",
@@ -205,12 +242,14 @@
"status.library_version_found": "Found:",
"status.library_version_mismatch": "Library Version Mismatch!",
"status.results_found": "{count} Results Found ({time_span})",
"status.results.invalid_syntax": "Invalid Search Syntax:",
"status.results": "Results",
"tag_manager.title": "Library Tags",
"tag.add_to_search": "Add to Search",
"tag.add.plural": "Add Tags",
"tag.add": "Add Tag",
"tag.aliases": "Aliases",
"tag.all_tags": "All Tags",
"tag.choose_color": "Choose Tag Color",
"tag.color": "Color",
"tag.confirm_delete": "Are you sure you want to delete the tag \"{tag_name}\"?",
@@ -218,6 +257,7 @@
"tag.create": "Create Tag",
"tag.disambiguation.tooltip": "Use this tag for disambiguation",
"tag.edit": "Edit Tag",
"tag.is_category": "Is Category",
"tag.name": "Name",
"tag.new": "New Tag",
"tag.parent_tags.add": "Add Parent Tag(s)",
@@ -227,6 +267,19 @@
"tag.search_for_tag": "Search for Tag",
"tag.shorthand": "Shorthand",
"tag.tag_name_required": "Tag Name (Required)",
"tag.view_limit": "View Limit:",
"trash.context.ambiguous": "Move file(s) to {trash_term}",
"trash.context.plural": "Move files to {trash_term}",
"trash.context.singular": "Move file to {trash_term}",
"trash.dialog.disambiguation_warning.plural": "This will remove them from TagStudio <i>AND</i> your file system!",
"trash.dialog.disambiguation_warning.singular": "This will remove it from TagStudio <i>AND</i> your file system!",
"trash.dialog.move.confirmation.plural": "Are you sure you want to move these {count} files to the {trash_term}?",
"trash.dialog.move.confirmation.singular": "Are you sure you want to move this file to the {trash_term}?",
"trash.dialog.permanent_delete_warning": "<b>WARNING!</b> If this file can't be moved to the {trash_term}, <b>it will be <b>permanently deleted!</b>",
"trash.dialog.title.plural": "Delete Files",
"trash.dialog.title.singular": "Delete File",
"trash.name.generic": "Trash",
"trash.name.windows": "Recycle Bin",
"view.size.0": "Mini",
"view.size.1": "Small",
"view.size.2": "Medium",

View File

@@ -1,15 +1,34 @@
{
"about.content": "<p>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.</p>. Licencia: GPLv3<br>Archivo de configuración: {config_path}<br>FFmpeg: {ffmpeg}<br>FFprobe: {ffprobe}<p><a href=\"https://github.com/TagStudioDev/TagStudio\">GitHub</a> | <a href=\"https://docs.tagstud.io\">Documentación</a> | <a href=\"https://discord.com/invite/hRNnVKhF2G\">Discord</a></p>",
"about.title": "Acerca de",
"app.git": "Git Commit",
"app.pre_release": "Previas al lanzamiento",
"app.title": "{base_title} - Biblioteca '{library_dir}'",
"drop_import.description": "Los siguientes archivos tienen nombres de archivo que ya existen en la biblioteca",
"drop_import.duplicates_choice.plural": "Los siguientes {count} archivos tienen nombres de archivo que ya existen en la biblioteca.",
"drop_import.duplicates_choice.singular": "El siguiente archivo tiene un nombre de archivo que ya existe en la biblioteca.",
"color.color_border": "Usar color secundario para la cenefa",
"color.confirm_delete": "¿Estás seguro de que quieres eliminar el color \"{color_name}\"?",
"color.delete": "Eliminar la etiqueta",
"color.import_pack": "Importar paquete de colores",
"color.name": "Nombre",
"color.namespace.delete.prompt": "¿Estás seguro de que quieres eliminar el espacio de nombres de este color? ¡Esto eliminará todos los colores en el espacio de nombres junto con él!",
"color.namespace.delete.title": "Eliminar el espacio de nombres de color",
"color.new": "Nuevo color",
"color.placeholder": "Color",
"color.primary": "Color primario",
"color.primary_required": "Color primario (Obligatorio)",
"color.secondary": "Color secundario",
"color.title.no_color": "Sin color",
"color_manager.title": "Administrar los colores de las etiquetas",
"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.",
"drop_import.progress.label.initial": "Importando archivos nuevos...",
"drop_import.progress.label.plural": "Importando archivos nuevos...\n{count} Archivos importado.{suffix}",
"drop_import.progress.label.singular": "Importando archivos nuevos...\n1 Archivo importado.{suffix}",
"drop_import.progress.window_title": "Importar archivos",
"drop_import.title": "Conflictos de archivos",
"edit.color_manager": "Administrar los colores de las etiquetas",
"edit.copy_fields": "Copiar campos",
"edit.paste_fields": "Pegar campos",
"edit.tag_manager": "Administrar etiquetas",
"entries.duplicate.merge": "Fusionar entradas duplicadas",
"entries.duplicate.merge.label": "Fusionando entradas duplicadas...",
@@ -20,6 +39,8 @@
"entries.mirror.label": "Reflejando {idx}/{total} Entradas...",
"entries.mirror.title": "Reflejando entradas",
"entries.mirror.window_title": "Reflejar entradas",
"entries.running.dialog.new_entries": "Añadiendo {total} nuevas entradas de archivos...",
"entries.running.dialog.title": "Añadiendo las nuevas entradas de archivos",
"entries.tags": "Etiquetas",
"entries.unlinked.delete": "Eliminar entradas no vinculadas",
"entries.unlinked.delete.confirm": "¿Está seguro de que desea eliminar las siguientes {count} entradas?",
@@ -45,7 +66,7 @@
"file.dimensions": "Dimensiones",
"file.duplicates.description": "TagStudio es compatible con Importación de resultados de DupeGuru para administrar archivos duplicados.",
"file.duplicates.dupeguru.advice": "Después de la duplicación, puede utilizar DupeGuru para eliminar los archivos no deseados. Luego, utilice la función \"Reparar entradas no vinculadas\" de TagStudio en el menú Herramientas para eliminar las entradas no vinculadas.",
"file.duplicates.dupeguru.file_extension": "Archivos DupeGuru (*.dupeguru)",
"file.duplicates.dupeguru.file_extension": "Archivos de DupeGuru (*.dupeguru)",
"file.duplicates.dupeguru.load_file": "&Cargar archivo DupeGuru",
"file.duplicates.dupeguru.no_file": "No se ha seleccionado ningún archivo DupeGuru",
"file.duplicates.dupeguru.open_file": "Abrir el archivo de resultados de DupeGuru",
@@ -84,12 +105,14 @@
"generic.filename": "Nombre de archivo",
"generic.navigation.back": "Volver",
"generic.navigation.next": "Continuar",
"generic.none": "Ninguno",
"generic.overwrite": "Sobrescribir",
"generic.overwrite_alt": "&Sobrescribir",
"generic.paste": "Pegar",
"generic.recent_libraries": "Bibliotecas recientes",
"generic.rename": "Renombrar",
"generic.rename_alt": "&Renombrar",
"generic.reset": "Reiniciar",
"generic.save": "Guardar",
"generic.skip": "Saltear",
"generic.skip_alt": "&Saltear",
@@ -98,7 +121,7 @@
"home.search_entries": "Buscar entradas",
"home.search_library": "Buscar el biblioteca",
"home.search_tags": "Buscar etiquetas",
"home.thumbnail_size": "Tamaño de las imágenes",
"home.thumbnail_size": "Tamaño de la vista previa",
"home.thumbnail_size.extra_large": "Imágenes extra grandes",
"home.thumbnail_size.large": "Imágenes grandes",
"home.thumbnail_size.medium": "Imágenes medianas",
@@ -111,37 +134,158 @@
"ignore_list.title": "Extensiones del archivo",
"json_migration.checking_for_parity": "Revisando paridad...",
"json_migration.creating_database_tables": "Creando tablas en la base de datos de SQL...",
"json_migration.description": "<br>Inicia y previsualiza el resultado del proceso de migración de la biblioteca. La biblioteca transformada <i>no</i> se utilizará hasta que pulses \"Finalizar migración\". <br><br>Los datos de la biblioteca deberían tener valores que concuerdan o disponen de una etiqueta \"Emparejado\". Los valores que no coinciden se mostrarán en rojo y mostrará el símbolo \"<b>(!)</b>\" a su lado.<br><center><i>Este proceso puede tardas hasta varios minutos en bibliotecas de gran tamaño.</i></center>",
"json_migration.discrepancies_found": "Se han encontrado discrepancias en la biblioteca",
"json_migration.discrepancies_found.description": "Se han encontrado discrepancias entre el formato original y el convertido. Por favor, revisa y elige si quieres continuar con la migración o la quieres cancelar.",
"json_migration.finish_migration": "Finalizar migración",
"json_migration.heading.aliases": "Alias:",
"json_migration.heading.colors": "Colores:",
"json_migration.heading.differ": "Discrepancia",
"json_migration.heading.entires": "Entradas:",
"json_migration.heading.extension_list_type": "Tipo de extensión de la lista:",
"json_migration.heading.fields": "Campos:",
"json_migration.heading.file_extension_list": "Lista de extensiones de archivos:",
"json_migration.heading.match": "Igualado",
"json_migration.heading.names": "Nombres:",
"json_migration.heading.parent_tags": "Etiquetas principales:",
"json_migration.heading.paths": "Rutas:",
"json_migration.heading.shorthands": "Abreviaturas:",
"json_migration.heading.tags": "Etiquetas:",
"json_migration.info.description": "Los archivos de guardado de la biblioteca con una versión de TagStudio <b>9.4 e inferior</b> se tendrán que migrar al nuevo formato de la versión <b>v9.5+</b>.<br><h2>Lo que necesitas saber:</h2><ul><li>Tu archivo de guardado de la biblioteca <b><i>NO</i></b> se eliminará</li><li>Tus archivos personales <b><i>NO</i></b> se eliminarán, moverán, o modificarán</li><li>El archivo con el formato de la nueva v9.5+ no se puede abrir en versiones anteriores de TagStudio</li></ul><h3>Qué ha cambiado:</h3><ul><li>\"Etiquetas Campo\" se han substituido por \"Etiquetas Categoría\". En vez de añadir campos primero, las etiquetas se añadirán directamente a las entradas de los archivos. Después de organizarán automáticamente en categorías basándose en las etiquetas padre marcadas con la nueva propiedad \"Es Categoría\" en el menú de edición de etiquetas. Cualquier etiqueta se puede marcar como categoría, y las etiquetas hija se ordenarán a sí mismas bajo las etiquetas padre marcadas como categoría. Las etiquetas \"Favorito\" y \"Archivado\" ahora heredan de una \"Metaetiqueta\", que están marcadas por defecto como una categoría.</li><li>Los colores de las etiquetas se han modificado y ampliado. Algunos colores se han renombrado o consolidado, aun así, todas las etiquetas de colores se transformarán al mismo color o similar en la versión v9.5.</li></ul><ul>",
"json_migration.migrating_files_entries": "Migrando {entries:,d} entradas de archivos...",
"json_migration.migration_complete": "¡La migración es terminado!",
"json_migration.migration_complete_with_discrepancies": "La migración es terminado, discrepancias descubierto",
"json_migration.start_and_preview": "Comenzar y preestrenar",
"json_migration.title": "Guardar el formato de la migración: \"{path}\"",
"json_migration.title.new_lib": "<h2>v9.5+ biblioteca</h2>",
"json_migration.title.old_lib": "<h2>v9.4 biblioteca</h2>",
"landing.open_create_library": "Abrir/Crear biblioteca {shortcut}",
"library.field.add": "Añadir campo",
"library.field.confirm_remove": "¿Está seguro de que desea eliminar este campo \"%{self.lib.get_field_attr(field, \"name\")}\"?",
"library.field.confirm_remove": "¿Está seguro de que desea eliminar el campo \"{name}\"?",
"library.field.mixed_data": "Datos variados",
"library.field.remove": "Eliminar campo",
"library.missing": "Falta la ubicación",
"library.name": "Biblioteca",
"library.refresh.scanning.plural": "Escaneando directorios en busca de nuevos archivos...\n{searched_count} archivos buscados, {found_count} nuevos archivos encontrados",
"library.refresh.scanning.singular": "Escaneando directorios en busca de nuevos archivos...\n{searched_count} Archivos buscados, {found_count} Nuevos archivos encontrados",
"library.refresh.scanning_preparing": "Buscar archivos nuevos en los directorios...\nPreparando...",
"library.refresh.title": "Refrescar directorios",
"library.scan_library.title": "Escaneando la biblioteca",
"macros.running.dialog.new_entries": "Ejecución de macros configurados en %{x + 1}/%{len(new_ids)} entradas nuevas",
"library_object.name": "Nombre",
"library_object.name_required": "Nombre (Obligatorio)",
"library_object.slug": "Slug ID",
"library_object.slug_required": "Slug ID (Obligatorio)",
"macros.running.dialog.new_entries": "Ejecución de macros configurados en {count}/{total} entradas nuevas...",
"macros.running.dialog.title": "Ejecución de macros en entradas nuevas",
"media_player.autoplay": "Reproducción automática",
"menu.delete_selected_files_ambiguous": "Mover archivo(s) a la {trash_term}",
"menu.delete_selected_files_plural": "Mover archivos a la {trash_term}",
"menu.delete_selected_files_singular": "Mover archivo a la {trash_term}",
"menu.edit": "Editar",
"menu.file": "Archivo",
"menu.help": "Ayuda",
"menu.macros": "Macro",
"menu.tools": "Herramientas",
"menu.edit.ignore_list": "Ignorar archivos y carpetas",
"menu.edit.manage_file_extensions": "Gestionar extensión de archivos",
"menu.edit.manage_tags": "Gestionar etiquetas",
"menu.edit.new_tag": "Nueva &Etiqueta",
"menu.file": "&Archivo",
"menu.file.clear_recent_libraries": "Borrar recientes",
"menu.file.close_library": "&Cerrar biblioteca",
"menu.file.new_library": "Nueva biblioteca",
"menu.file.open_create_library": "&Abrir/Crear biblioteca",
"menu.file.open_library": "Abrir biblioteca",
"menu.file.open_recent_library": "Abrir reciente",
"menu.file.refresh_directories": "&Actualizar directorios",
"menu.file.save_backup": "&Guardar copia de seguridad de la biblioteca",
"menu.file.save_library": "Guardar biblioteca",
"menu.help": "&Ayuda",
"menu.help.about": "Acerca de",
"menu.macros": "&Macros",
"menu.macros.folders_to_tags": "Carpetas a Etiquetas",
"menu.select": "Seleccionar",
"menu.settings": "Ajustes...",
"menu.tools": "&Herramientas",
"menu.tools.fix_duplicate_files": "Reparar &archivos duplicados",
"menu.tools.fix_unlinked_entries": "Reparar entradas &desvinculadas",
"menu.view": "&Ver",
"menu.window": "Ventana",
"namespace.create.description": "Los espacios de nombre se utilizan en TagStudio para separar grupos de elementos, como pueden ser etiquetas y colores, de manera que facilita exportarlos y compartirlos. Los espacios de nombre que empiezan por \"tagstudio\" se reservan para uso interno de TagStudio",
"namespace.create.description_color": "Los colores de las etiquetas utilizan espacios de nombre como grupos de paletas de colores. Todos los colores personalizados deben estar primero bajo un espacio de nombre",
"namespace.create.title": "Crear espacio de nombre",
"namespace.new.button": "Nuevo espacio de nombre",
"namespace.new.prompt": "¡Crea un nuevo espacio de nombre para empezar a añadir colores personalizados!",
"preview.multiple_selection": "<b>{count}</b> Elementos seleccionados",
"preview.no_selection": "No hay elementos seleccionados",
"status.library_backup_success": "Copia de seguridad de la biblioteca guardada en:",
"select.add_tag_to_selected": "Añadir etiqueta a la selección",
"select.all": "Seleccionar todo",
"select.clear": "Borrar selección",
"settings.clear_thumb_cache.title": "Borrar cache de las miniaturas",
"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.",
"settings.show_filenames_in_grid": "Mostrar el nombre de archivo en la cuadrícula",
"settings.show_recent_libraries": "Mostrar bibliotecas recientes",
"settings.title": "Ajustes",
"sorting.direction.ascending": "Ascendiente",
"sorting.direction.descending": "Descendiente",
"splash.opening_library": "Abriendo biblioteca \"{library_path}\"...",
"status.deleted_file_plural": "¡{count} Archivos eliminados!",
"status.deleted_file_singular": "¡Eliminado 1 archivo!",
"status.deleted_none": "Ningún archivo eliminado.",
"status.deleted_partial_warning": "¡Sólo se han eliminado {count} archivo(s)! Comprueba si alguno de los archivos no existe o está en uso.",
"status.deleting_file": "Eliminando archivo [{i}/{count}]: \"{path}\"...",
"status.library_backup_in_progress": "Guardando copia de seguridad de la biblioteca...",
"status.library_backup_success": "Copia de seguridad de la biblioteca guardada en: \"{path}\" ({time_span})",
"status.library_closed": "Biblioteca cerrada ({time_span})",
"status.library_closing": "Cerrando biblioteca...",
"status.library_save_success": "¡Biblioteca guardada y cerrada!",
"status.library_search_query": "Buscando en la biblioteca",
"status.library_search_query": "Buscando biblioteca...",
"status.library_version_expected": "Esperado:",
"status.library_version_found": "Encontrado:",
"status.library_version_mismatch": "¡La versión de la biblioteca no coincide!",
"status.results": "Resultados",
"status.results.invalid_syntax": "Sintaxis de búsqueda no válida:",
"status.results_found": "{count} Resultados encontrados ({time_span})",
"tag.add": "Añadir etiqueta",
"tag.add.plural": "Añadir etiquetas",
"tag.add_to_search": "Añadir a la búsqueda",
"tag.aliases": "Alias",
"tag.all_tags": "Todas las etiquetas",
"tag.choose_color": "Elige el color de la etiqueta",
"tag.color": "Color",
"tag.confirm_delete": "¿Está seguro de que quiere eliminar la etiqueta \"{tag_name}\"?",
"tag.create": "Crear etiqueta",
"tag.create_add": "Crear && añadir \"{query}\"",
"tag.disambiguation.tooltip": "Utiliza esta etiqueta para desambiguar",
"tag.edit": "Editar etiqueta",
"tag.is_category": "Es categoría",
"tag.name": "Nombre",
"tag.new": "Nueva etiqueta",
"tag.parent_tags": "Etiquetas principales",
"tag.parent_tags.add": "Añadir etiquetas principales",
"tag.parent_tags.description": "Esta etiqueta se puede tratar como sustituto de cualquiera de las etiquetas padre en las búsquedas.",
"tag.remove": "Eliminar etiqueta",
"tag.search_for_tag": "Buscar por etiqueta",
"tag.shorthand": "Taquigrafía",
"tag_manager.title": "Etiquetas de la biblioteca"
"tag.tag_name_required": "Nombre etiqueta (Obligatorio)",
"tag.view_limit": "Límite visualización:",
"tag_manager.title": "Etiquetas de la biblioteca",
"trash.context.ambiguous": "Mover archivo(s) a la {trash_term}",
"trash.context.plural": "Mover archivos a la {trash_term}",
"trash.context.singular": "Mover archivo a la {trash_term}",
"trash.dialog.disambiguation_warning.plural": "¡Se eliminarán de TagStudio <i>Y</i> de tus archivos!",
"trash.dialog.disambiguation_warning.singular": "¡Se eliminarán de TagStudio <i>Y</i> de tus archivos!",
"trash.dialog.move.confirmation.plural": "¿Está seguro que quiere mover estos {count} archivos a la {trash_term}?",
"trash.dialog.move.confirmation.singular": "¿Está seguro que quiere mover este archivo a la {trash_term}?",
"trash.dialog.permanent_delete_warning": "<b>ADVERTENCIA!</b> Si este archivo no se puede mover a la {trash_term}, <b>¡Se eliminará para siempre!</b>",
"trash.dialog.title.plural": "Eliminar archivos",
"trash.dialog.title.singular": "Eliminar archivo",
"trash.name.generic": "Basura",
"trash.name.windows": "Papelera de reciclaje",
"view.size.0": "Mini",
"view.size.1": "Pequeño",
"view.size.2": "Medio",
"view.size.3": "Grande",
"view.size.4": "Extra grande",
"window.message.error_opening_library": "Error abriendo la biblioteca.",
"window.title.error": "Error",
"window.title.open_create_library": "Abrir/Crear biblioteca"
}

View File

@@ -1,29 +1,104 @@
{
"entries.duplicate.merge.label": "Sinasama ang mga Duplicate na Entry",
"entries.mirror": "Salamin",
"about.content": "<p>Ang TagStudio ay isang application ng pagsasaayos ng file at larawan na may pinagbabatayan na tag-based na sistema na nakatutok sa pagbibigay ng kalayaan at kakayahang umangkop sa user. Walang mga proprietary na format o program, walang dagat ng mga sidecar file, at walang kaguluhan ng iyong estruktura ng filesystem.</p>Lisensya: GPLv3<br>Path ng config: {config_path}<br>FFMpeg: {ffmpeg}<br>FFProbe: {ffprobe}<p><a href=\"https://github.com/TagStudioDev/TagStudio\">GitHub</a> | <a href=\"https://docs.tagstud.io\">Dokumentasyon</a> | <a href=\"https://discord.gg/invite/hRNnVKhF2G\">Discord</a></p>",
"about.title": "Tungkol sa",
"app.git": "Git Commit",
"app.pre_release": "Pre-Release",
"app.title": "{base_title} - Library '{library_dir}'",
"color.color_border": "Gumamit ng Pangalawang Kulay para sa Border",
"color.confirm_delete": "Sigurado ka ba gusto mong burahin ang kulay na \"{color_name}\"?",
"color.delete": "Burahin ang Tag",
"color.import_pack": "Mag-import ng Color Pack",
"color.name": "Pangalan",
"color.namespace.delete.prompt": "Sigurado ka ba gusto mong burahin ang color namespace na ito? Buburahin nito ang LAHAT ng mga kulay sa namespace kasama nito!",
"color.namespace.delete.title": "Burahin ang Color Namespace",
"color.new": "Bagong Kulay",
"color.placeholder": "Kulay",
"color.primary": "Pangunahing Kulay",
"color.primary_required": "Pangunahing Kulay (Kinakailangan)",
"color.secondary": "Pangalawang Kulay",
"color.title.no_color": "Walang Kulay",
"color_manager.title": "Ipamahala ang Mga Kulay ng Tag",
"drop_import.description": "Ang mga sumusunod na file ay tumutugma sa mga file path na umiiral na sa library",
"drop_import.duplicates_choice.plural": "Ang sumusunod na {count} mga file ay tumutugma sa mga file path na umiiral sa library.",
"drop_import.duplicates_choice.singular": "Ang sumusunod na file ay tumutugma sa file path na umiiral na sa library.",
"drop_import.progress.label.initial": "Ini-import Ang Mga Bagong File…",
"drop_import.progress.label.plural": "Ini-import Ang Mga Bagong File…\nNa-import ang {count} Mga File.{suffix}",
"drop_import.progress.label.singular": "Ini-import Ang Mga Bagong File…\nNa-import ang 1 File.{suffix}",
"drop_import.progress.window_title": "I-import ang Mga File",
"drop_import.title": "(Mga) Sumasalungat na File",
"edit.color_manager": "Ipamahala ang Mga Kulay ng Tag",
"edit.tag_manager": "Ipamahala ang Mga Tag",
"entries.duplicate.merge": "Isama ang Mga Duplicate na Entry",
"entries.duplicate.merge.label": "Sinasama ang mga Duplicate na Entry…",
"entries.duplicate.refresh": "I-refresh ang Mga Duplicate na Entry",
"entries.duplicates.description": "Ang mga duplicate na entry ay tinukoy bilang maramihang mga entry na tumuturo sa parehong file sa disk. Ang pagsasama-sama ng mga ito ay pagsasama-samahin ang mga tag at metadata mula sa lahat ng mga duplicate sa isang solong pinagsama-samang entry. Ang mga ito ay hindi dapat ipagkamali sa \"mga duplicate na file\", na mga duplicate ng iyong mga file mismo sa labas ng TagStudio.",
"entries.mirror": "&Mirror",
"entries.mirror.confirmation": "Sigurado ka ba gusto mong i-mirror ang sumusunod na {count} Mga Entry?",
"entries.mirror.label": "Mini-mirror ang {idx}/{total} Mga Entry…",
"entries.mirror.title": "Mini-mirror ang Mga Entry",
"entries.mirror.window_title": "I-mirror ang Mga Entry",
"entries.running.dialog.new_entries": "Dinadagdag ang {total} Mga Bagong Entry ng File…",
"entries.running.dialog.title": "Dinadagdag ang Mga Bagong Entry ng File",
"entries.tags": "Mga Tag",
"entries.unlinked.delete": "Burahin ang Mga Hindi Naka-link na Entry",
"entries.unlinked.delete.confirm": "Sigurado ka ba gusto mong burahin ang (mga) sumusunod na %{len(self.lib.missing_files)} entry?",
"entries.unlinked.delete.confirm": "Sigurado ka ba gusto mong burahin ang (mga) sumusunod na {count} entry?",
"entries.unlinked.delete.deleting": "Binubura ang Mga Entry",
"entries.unlinked.delete.deleting_count": "Binubura ang %{x[0]+1}/{len(self.lib.missing_files)} (mga) Naka-unlink na Entry",
"entries.unlinked.refresh_all": "I-refresh Lahat",
"entries.unlinked.delete.deleting_count": "Binubura ang {idx}/{count} (mga) Naka-unlink na Entry",
"entries.unlinked.delete_alt": "Burahin ang Mga Naka-unlink na Entry",
"entries.unlinked.description": "Ang bawat entry sa library ay naka-link sa isang file sa isa sa iyong mga direktoryo. Kung ang isang file na naka-link sa isang entry ay inilipat o binura sa labas ng TagStudio, ito ay isinasaalang-alang na naka-unlink.<br><br>Ang mga naka-unlink na entry ay maaring i-link muli sa pamamagitan ng paghahanap sa iyong mga direktoryo o buburahin kung ninanais.",
"entries.unlinked.missing_count.none": "Mga Naka-unlink na Entry: N/A",
"entries.unlinked.missing_count.some": "Mga Naka-unlink na Entry: {count}",
"entries.unlinked.refresh_all": "&I-refresh Lahat",
"entries.unlinked.relink.attempting": "Sinusubukang i-link muli ang {idx}/{missing_count} Mga Entry, {fixed_count} Matagumpay na na-link muli",
"entries.unlinked.relink.manual": "&Manwal na Pag-link Muli",
"entries.unlinked.relink.title": "Nili-link muli ang Mga Entry",
"entries.unlinked.scanning": "Sina-scan ang Library para sa Mga Naka-unlink na Entry…",
"entries.unlinked.search_and_relink": "&Maghanap at Mag-link muli",
"entries.unlinked.title": "Ayusin ang Mga Naka-unlink na Entry",
"field.copy": "Kopyahin ang Field",
"field.edit": "I-edit ang Field",
"field.paste": "I-paste ang Field",
"file.date_added": "Petsang Dinagdag",
"file.date_created": "Petsa na Ginawa",
"file.date_modified": "Binago Noong",
"file.dimensions": "Laki",
"file.duplicates.dupeguru.load_file": "Mag-load ng DupeGuru File",
"file.duplicates.description": "Sinusuportahan ng TagStudio ang pag-import ng mga DupeGuru na resulta para ipamahala ang mga duplicate na file.",
"file.duplicates.dupeguru.advice": "Pagkatapos ng pag-mirror, malaya kang gamitin ang DupeGuru para magbura ng mga hindi gustong file. Pagkatapos, gamitin ang \"Ayusin ang Mga Naka-unlink na Entry\" feature ng TagStudio sa Tools menu para burahin ang mga hindi naka-link na Entry.",
"file.duplicates.dupeguru.file_extension": "Mga DupeGuru File (*.dupeguru)",
"file.duplicates.dupeguru.load_file": "&Mag-load ng DupeGuru File",
"file.duplicates.dupeguru.no_file": "Walang DupeGuru na File na Napili",
"file.duplicates.dupeguru.open_file": "Buksan ang DupeGuru Results File",
"file.duplicates.fix": "Ayusin ang Mga Duplicate na File",
"file.duplicates.matches": "Mga Tumutugmang Duplicate File: {count}",
"file.duplicates.matches_uninitialized": "Mga Tumutugmang Duplicate File: N/A",
"file.duplicates.mirror.description": "Mirror the Entry data across each duplicate match set, combining all data while not removing or duplicating fields. This operation will not delete any files or data.",
"file.duplicates.mirror_entries": "Mga Entry ng Salamin",
"file.duplicates.mirror_entries": "&I-mirror ang mga Entry",
"file.duration": "Haba",
"file.not_found": "Hindi nahanap ang file:",
"file.open_file": "Buksan ang file",
"file.open_location.generic": "Buksan ang file sa explorer",
"file.open_file_with": "Buksan ang file gamit ang",
"file.open_location.generic": "Ipakita ang file sa file explorer",
"file.open_location.mac": "Ipakita sa Finder",
"file.open_location.windows": "Ipakita sa File Explorer",
"folders_to_tags.close_all": "Isara Lahat",
"folders_to_tags.converting": "Kino-convert ang mga folder sa Tag",
"folders_to_tags.description": "Gumawa ng mga tag base sa iyong estruktura ng folder at ia-apply sa iyong mga entry.\nAng istraktura sa ibaba ay pinapakita ang lahat ng mga tag na gagawin at anong mga entry ang ia-apply sa.",
"folders_to_tags.open_all": "Buksan Lahat",
"folders_to_tags.title": "Gumawa ng Mga Tag Mula Sa Mga Folder",
"generic.add": "Magdagdag",
"generic.apply": "I-apply",
"generic.apply_alt": "&I-apply",
"generic.cancel": "Kanselahin",
"generic.cancel_alt": "&Kanselahin",
"generic.close": "Isara",
"generic.continue": "Magpatuloy",
"generic.copy": "Kopyahin",
"generic.cut": "I-cut",
"generic.delete": "Burahin",
"generic.delete_alt": "&Burahin",
"generic.done": "Tapos na",
"generic.done_alt": "&Tapos na",
"generic.edit_alt": "&I-edit",
"generic.recent_libraries": "Mga Kamakailang Library",
"home.search": "Maghanap",
"home.search_entries": "Mga Entry sa Paghahanap",
@@ -39,7 +114,7 @@
"library.name": "Library",
"library.refresh.scanning_preparing": "Sina-scan ang Mga Direktoryo para sa Mga Bagong File...\nNaghahanda...",
"library.refresh.title": "Nire-refresh ang Mga Direktoryo",
"macros.running.dialog.new_entries": "Tinatakbo ang Mga Naka-configure na Macro sa %{x + 1}/%{len(new_ids)} Mga Bagong Entry",
"macros.running.dialog.new_entries": "Tinatakbo ang Mga Naka-configure na Macro sa {count}/{total} Mga Bagong Entry",
"macros.running.dialog.title": "Tumatakbo ng Mga Macro sa Mga Bagong Entry",
"menu.edit": "I-edit",
"menu.file": "File",
@@ -47,7 +122,7 @@
"menu.macros": "Mga macro",
"menu.tools": "Mga tool",
"menu.window": "Window",
"status.library_backup_success": "Na-save ang Library Backup sa:",
"status.library_backup_success": "Na-save ang Library Backup sa: \"{path}\" ({time_span})",
"status.library_save_success": "Sinave at Sinara ang Library!",
"status.library_search_query": "Hinahanap ang library para sa",
"status.results": "Mga Resulta",

View File

@@ -1,23 +1,39 @@
{
"about.content": "<h2>TagStudio Alpha {version} ({branch})</h2><p>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.</p>License: GPLv3<br>Chemin de configuration: {config_path}<br>FFmpeg: {ffmpeg}<br>FFprobe: {ffprobe}<p><a href=\"https://github.com/TagStudioDev/TagStudio\">GitHub</a> | <a href=\"https://docs.tagstud.io\">Documentation</a> | <a href=\"https://discord.com/invite/hRNnVKhF2G\">Discord</a></p>",
"about.content": "<p>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.</p>License: GPLv3<br>Chemin de configuration: {config_path}<br>FFmpeg: {ffmpeg}<br>FFprobe: {ffprobe}<p><a href=\"https://github.com/TagStudioDev/TagStudio\">GitHub</a> | <a href=\"https://docs.tagstud.io\">Documentation</a> | <a href=\"https://discord.com/invite/hRNnVKhF2G\">Discord</a></p>",
"about.title": "À propos",
"app.git": "Git Commit",
"app.pre_release": "Version Préliminaire",
"app.title": "ase_title} - Bibliothèque '{library_dir}'",
"app.title": "{base_title} - Bibliothèque '{library_dir}'",
"color.color_border": "Utiliser la couleur secondaire sur la bordure",
"color.confirm_delete": "Voulez vous vraiment supprimer la couleur \"{color_name}\"?",
"color.delete": "Supprimer le Tag",
"color.import_pack": "Importer un Pack de Couleur",
"color.name": "Nom",
"color.namespace.delete.prompt": "Voulez-vous vraiment supprimer ce groupe de couleurs? Cela supprimera TOUTES les couleurs du groupe!",
"color.namespace.delete.title": "Supprimer le namespace de couleur",
"color.new": "Nouvelle couleur",
"color.placeholder": "Couleur",
"color.primary": "Couleur Primaire",
"color.primary_required": "Couleur Primaire (Requis)",
"color.secondary": "Couleur Secondaire",
"color.title.no_color": "Aucune couleur",
"color_manager.title": "Gérer la Couleur des Tags",
"drop_import.description": "Les fichiers suivants correspondent à des chemins de fichiers déjà existant dans la bibliothèque",
"drop_import.duplicates_choice.plural": "Les noms des {count} fichiers suivants existent déjà dans la Bibliothèque.",
"drop_import.duplicates_choice.singular": "Le fichier suivant a un nom déjà existant dans la bibliothèque.",
"drop_import.progress.label.initial": "Import des Nouveaux Fichiers...",
"drop_import.progress.label.plural": "Import des Nouveaux Fichiers...\n{count} Fichiers Importés.{suffix}",
"drop_import.progress.label.singular": "Import des Nouveaux Fichiers...\n1 Fichier Importé.{suffix}",
"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.",
"drop_import.progress.label.initial": "Importer des Nouveaux Fichiers...",
"drop_import.progress.label.plural": "Importation des Nouveaux Fichiers...\n{count} Fichiers Importés.{suffix}",
"drop_import.progress.label.singular": "Importation des Nouveaux Fichiers...\n1 Fichier Importé.{suffix}",
"drop_import.progress.window_title": "Importer des Fichiers",
"drop_import.title": "Fichier(s) en Conflit",
"edit.color_manager": "Gérer la Couleur des Tags",
"edit.copy_fields": "Copier les Fields",
"edit.paste_fields": "Coller les Fields",
"edit.tag_manager": "Gérer les Tags",
"entries.duplicate.merge": "Fusion des Duplicatas",
"entries.duplicate.merge.label": "Fusionner les duplicatas...",
"entries.duplicate.merge": "Fusion des entrées dupliquées",
"entries.duplicate.merge.label": "Fusionner les entrées dupliquées...",
"entries.duplicate.refresh": "Rafraichir les Entrées en Doublon",
"entries.duplicates.description": "Les entrées dupliquées sont définies comme des entrées multiple qui pointent vers le même fichier sur le disque. Les fusionner va combiner les labels et metadatas de tous les duplicatas vers une seule entrée consolidée. Elles ne doivent pas être confondues avec les \"fichiers en doublon\", qui sont des doublons de vos fichiers en dehors de TagStudio.",
"entries.duplicates.description": "Les entrées dupliquées sont définies comme des entrées multiple qui pointent vers le même fichier sur le disque. Les fusionner va combiner les tags et metadatas de tous les duplicatas vers une seule entrée consolidée. Elles ne doivent pas être confondues avec les \"fichiers en doublon\", qui sont des doublons de vos fichiers en dehors de TagStudio.",
"entries.mirror": "&Refléter",
"entries.mirror.confirmation": "Êtes-vous sûr de vouloir répliquer les {count} Entrées suivantes?",
"entries.mirror.label": "Réplication de {idx}/{total} Entrées...",
@@ -25,7 +41,7 @@
"entries.mirror.window_title": "Entrée Miroir",
"entries.running.dialog.new_entries": "Ajout de {total} Nouvelles entrées de fichier...",
"entries.running.dialog.title": "Ajout de Nouvelles entrées de fichier",
"entries.tags": "Labels",
"entries.tags": "Tags",
"entries.unlinked.delete": "Supprimer les Entrées non Liées",
"entries.unlinked.delete.confirm": "Êtes-vous sûr de vouloir supprimer les {count} entrées suivantes?",
"entries.unlinked.delete.deleting": "Suppression des Entrées",
@@ -51,14 +67,14 @@
"file.duplicates.description": "TagStudio supporte l'importation de résultats DupeGuru pour gérer les doublons de fichier.",
"file.duplicates.dupeguru.advice": "Après réplication, vous êtes libre d'utiliser DupeGuru pour supprimer des fichiers non désirés. Ensuite, utilisez la fonctionnalité \"Réparation des Entrées non Liées\" de TagStudio dans le menu Outils pour supprimer les Entrées non liées.",
"file.duplicates.dupeguru.file_extension": "Fichiers DupeGuru (*.dupeguru)",
"file.duplicates.dupeguru.load_file": "Charger un Fichier DupeGuru",
"file.duplicates.dupeguru.load_file": "&Charger un Fichier DupeGuru",
"file.duplicates.dupeguru.no_file": "Aucun Fichier DupeGuru Sélectionné",
"file.duplicates.dupeguru.open_file": "Ouvrire les Fichiers de Résultats de DupeGuru",
"file.duplicates.fix": "Réparer les Fichiers en Double",
"file.duplicates.matches": "Dupliquer les Correspondances de Fichier : %{count}",
"file.duplicates.matches": "Dupliquer les Correspondances de Fichier: {count}",
"file.duplicates.matches_uninitialized": "Dupliquer les Correspondances de Fichier : N/A",
"file.duplicates.mirror.description": "Repliquer les données d'entrée dans chaque jeu de correspondances en double, en combinant toutes les données sans supprimer ni dupliquer de champs. Cette opération ne supprime aucun fichier ni aucune donnée.",
"file.duplicates.mirror_entries": "Répliquer les Entrées",
"file.duplicates.mirror_entries": "&Répliquer les Entrées",
"file.duration": "Durée",
"file.not_found": "Fichier non trouvé",
"file.open_file": "Ouvrir un Fichier",
@@ -67,8 +83,8 @@
"file.open_location.mac": "Montrer dans le Finder",
"file.open_location.windows": "Montrer dans l'explorateur de Fichiers",
"folders_to_tags.close_all": "Tout Fermer",
"folders_to_tags.converting": "Conversion des dossiers en Labels",
"folders_to_tags.description": "Créé des labels basés sur votre arborescence de dossier et les applique à vos entrées.\nLa structure ci-dessous affiche tous les labels qui seront créés et à quelles entrées ils seront appliqués.",
"folders_to_tags.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.",
"folders_to_tags.open_all": "Tout Ouvrir",
"folders_to_tags.title": "Créer un Label à partir d'un Dossier",
"generic.add": "Ajouter",
@@ -96,6 +112,7 @@
"generic.recent_libraries": "Bibliothèques Récentes",
"generic.rename": "Renommer",
"generic.rename_alt": "&Renommer",
"generic.reset": "Réinitialiser",
"generic.save": "Sauvegarder",
"generic.skip": "Passer",
"generic.skip_alt": "&Passer",
@@ -103,20 +120,21 @@
"home.search": "Rechercher",
"home.search_entries": "Recherche",
"home.search_library": "Rechercher dans la Bibliothèque",
"home.search_tags": "Recherche de Labels",
"home.search_tags": "Recherche de Tags",
"home.thumbnail_size": "Taille de la miniature",
"home.thumbnail_size.extra_large": "Très Grandes Miniatures",
"home.thumbnail_size.large": "Grandes Miniatures",
"home.thumbnail_size.medium": "Miniatures Moyennes",
"home.thumbnail_size.mini": "Mini Miniatures",
"home.thumbnail_size.small": "Petites Miniatures",
"ignore_list.add_extension": "Ajouter une Extension",
"ignore_list.add_extension": "&Ajouter une Extension",
"ignore_list.mode.exclude": "Exclure",
"ignore_list.mode.include": "Inclure",
"ignore_list.mode.label": "Mode 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...",
"json_migration.description": "<br>Démarrez et prévisualisez les résultats du processus de migration de la bibliothèque. La bibliothèque convertie <i>ne</i> sera utilisée que si vous cliquez sur \"Terminer la migration\". <br><br>Les données de la bibliothèque doivent soit avoir des valeurs correspondantes, soit comporter un label \"Matched\". Les valeurs qui ne correspondent pas seront affichées en rouge et comporteront un symbole \"<b>(!)</b>\" à côté d'elles.<br><center><i>Ce processus peut prendre jusqu'à plusieurs minutes pour les bibliothèques plus volumineuses.</i></center>",
"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",
@@ -124,67 +142,150 @@
"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.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.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 fields, 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!",
"json_migration.migration_complete_with_discrepancies": "Migration Terminée, Divergences Trouvées",
"json_migration.start_and_preview": "Commencer et Prévisualiser",
"json_migration.title": "Migration du format d'enregistrement: \"{path}\"",
"json_migration.title.new_lib": "<h2>Bibliothèque v9.5+</h2>",
"json_migration.title.old_lib": "<h2>Bibliothèque v9.4</h2>",
"landing.open_create_library": "Ouvrir/Créer une Bibliothèque {shortcut}",
"library.field.add": "Ajouter un Champ",
"library.field.confirm_remove": "Êtes-vous sûr de vouloir supprimer le champ \"%{self.lib.get_field_attr(field, \"name\")}\"?",
"library.field.confirm_remove": "Êtes-vous sûr de vouloir supprimer le champ \"{name}\"?",
"library.field.mixed_data": "Données Mélangées",
"library.field.remove": "Supprimer un Champ",
"library.missing": "Emplacement Manquant",
"library.name": "Bibliothèque",
"library.refresh.scanning.plural": "Analyse du Répertoire pour de Nouveaux Fichiers...\n{searched_count} Fichiers Trouvées, {found_count} Nouveaux Fichiers",
"library.refresh.scanning.singular": "Analyse du Répertoire pour de Nouveaux Fichiers...\n{searched_count} Fichier Trouvé, {found_count} Nouveaux Fichiers",
"library.refresh.scanning_preparing": "Recherche de Nouveaux Fichiers dans les Dossiers...\nPréparation...",
"library.refresh.title": "Rafraîchissement des Dossiers",
"library.scan_library.title": "Balayage de la Bibliothèque",
"macros.running.dialog.new_entries": "Éxectution des Macros Configurées sur %{x + 1}/%{len(new_ids)} Nouvelles Entrées",
"library_object.name": "Nom",
"library_object.name_required": "Nom (Requis)",
"library_object.slug": "Identifiant unique",
"library_object.slug_required": "ID Slug (Requis)",
"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",
"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}",
"menu.edit": "Édition",
"menu.edit.ignore_list": "Ignorer les Fichiers et Dossiers",
"menu.file": "Fichier",
"menu.edit.manage_file_extensions": "Gérer les extensions de fichier",
"menu.edit.manage_tags": "Gérer les Tags",
"menu.edit.new_tag": "Nouveaux &Tag",
"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.open_create_library": "Ouvrir/Créer une 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",
"menu.file.refresh_directories": "&Rafraichir les Répertoires",
"menu.file.save_backup": "&Sauvegarde de la Bibliothèque",
"menu.file.save_library": "Enregistrer la Bibliothèque",
"menu.help": "Aide",
"menu.macros": "Macros",
"menu.help": "&Aide",
"menu.help.about": "À propos",
"menu.macros": "&Macros",
"menu.macros.folders_to_tags": "Répertoires à Tags",
"menu.select": "Sélectionner",
"menu.tools": "Outils",
"menu.view": "Vues",
"menu.settings": "Paramètres...",
"menu.tools": "&Outils",
"menu.tools.fix_duplicate_files": "Réparer les entrées de fichiers en double",
"menu.tools.fix_unlinked_entries": "Réparer les entrées de fichier non liée",
"menu.view": "&Vues",
"menu.window": "Fenêtre",
"namespace.create.description": "Les namespaces sont utilisés par TagStudio pour séparer les groupes d'éléments tels que les Tags et les couleurs de manière à faciliter leur exportation et leur partage. Les namespace avec des noms commençant par « tagstudio » sont réservés par TagStudio pour un usage interne.",
"namespace.create.description_color": "Les tags utilise les namespace pour regrouper plusieurs couleurs. Toutes les couleurs personnalisées doivent être ajoutées à un namespace.",
"namespace.create.title": "Créer une Namespace",
"namespace.new.button": "Nouvelle Namespace",
"namespace.new.prompt": "Commencer par créer une nouvelle namespace pour pouvoir créer des couleurs personnalisées!",
"preview.multiple_selection": "<b>{count}</b> Éléments Sélectionner",
"preview.no_selection": "Pas d'Objet Selectionné",
"select.add_tag_to_selected": "Ajouter un Tag à la sélection",
"select.all": "Tout Sélectionner",
"select.clear": "Effacer la Sélection",
"settings.clear_thumb_cache.title": "Effacer le cache des vignettes",
"settings.language": "Langage",
"settings.open_library_on_start": "Ouvrir la Bibliothèque au Démarrage",
"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.title": "Paramètres",
"sorting.direction.ascending": "Croissant",
"sorting.direction.descending": "Décroissant",
"splash.opening_library": "Ouverture de la Bibliothèque",
"status.library_backup_success": "Bibliothèque sauvegardée au chemin  :",
"status.library_save_success": "Bibliothèque Sauvegardée et Fermée!",
"status.library_search_query": "Recherche dans la Bibliothèque pour",
"splash.opening_library": "Ouverture de la Bibliothèque \"{library_path}\"...",
"status.deleted_file_plural": "Suppression de {count} fichiers!",
"status.deleted_file_singular": "Suppression de 1 fichier!",
"status.deleted_none": "Aucun fichiers supprimer.",
"status.deleted_partial_warning": "Seulement {count} fichier(s) on été supprimé! Vérifier si les fichiers restant ne serais pas manquant ou en cours d'utilisation.",
"status.deleting_file": "Suppression de fichier(s) [{i}/{count}]: \"{path}\"...",
"status.library_backup_in_progress": "Création d'une Sauvegarde de la Bibliothèque...",
"status.library_backup_success": "Bibliothèque sauvegardée au chemin: \"{path}\" ({time_span})",
"status.library_closed": "Bibliothèque fermée ({time_span})",
"status.library_closing": "Fermeture de la Bibliothèque...",
"status.library_save_success": "Bibliothèque Sauvegardée et Fermée!",
"status.library_search_query": "Rechercher dans la Bibliothèque...",
"status.library_version_expected": "Exceptée:",
"status.library_version_found": "Trouvée:",
"status.library_version_mismatch": "La version de la library ne correspond pas!",
"status.results": "Résultats",
"status.results_found": "{results.total_count} Résultats Trouvés",
"tag.add": "Ajouter un Label",
"status.results.invalid_syntax": "Syntaxe de recherche invalide:",
"status.results_found": "{count} Résultats Trouvés({time_span})",
"tag.add": "Ajouter un Tag",
"tag.add.plural": "Ajouter des Tags",
"tag.add_to_search": "Ajouter à la Recherche",
"tag.aliases": "Alias",
"tag.all_tags": "Tout les Tags",
"tag.choose_color": "Choisir une couleur de Tag",
"tag.color": "Couleur",
"tag.create_add": "Créer && Adjouter \"{query}\"",
"tag.confirm_delete": "Voulez vous vraiment supprimer le tag \"{tag_name}\"?",
"tag.create": "Créer un Tag",
"tag.create_add": "Créer && Ajouter \"{query}\"",
"tag.disambiguation.tooltip": "Utilisez ce Tag pour définir une ambiguïté",
"tag.edit": "Modifier un Tag",
"tag.is_category": "Est une Catégorie",
"tag.name": "Nom",
"tag.new": "Nouveau Label",
"tag.parent_tags": "Labels Parent",
"tag.parent_tags.add": "Ajouter des Labels Parents",
"tag.new": "Nouveau Tag",
"tag.parent_tags": "Tags Parent",
"tag.parent_tags.add": "Ajouter des Tags Parents",
"tag.parent_tags.description": "Ce Tag peut être utilisé en replacement de tous ces Tags Parents dans les recherches.",
"tag.remove": "Supprimer un Tag",
"tag.search_for_tag": "Recherche de Label",
"tag.shorthand": "Abrégé",
"tag_manager.title": "Labels de la Bibliothèque",
"tag.tag_name_required": "Nom du Tag (Requis)",
"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}",
"trash.context.singular": "Déplacer le fichier vers {trash_term}",
"trash.dialog.disambiguation_warning.plural": "Cela les retirera de TagStudio <i>ET</i> de votre système de fichier!",
"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.title.plural": "Supprimer les Fichiers",
"trash.dialog.title.singular": "Supprimer le Fichier",
"trash.name.generic": "Poubelle",
"trash.name.windows": "Corbeille",
"view.size.0": "Mini",
"view.size.1": "Petit",
"view.size.2": "Moyen",
"view.size.3": "Grand",
"view.size.4": "Très Grand"
"view.size.4": "Très Grand",
"window.message.error_opening_library": "Une erreur est survenue lors de l'ouverture de la bibliothèque.",
"window.title.error": "Erreur",
"window.title.open_create_library": "Ouvrir/Créer une Bibliothèque"
}

View File

@@ -1,10 +1,23 @@
{
"about.content": "<h2>TagStudio Alfa {version} ({branch})</h2><p>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.</p>Licenc: GPLv3<br>Konfigurációs fájl: {config_path}<br>FFmpeg: {ffmpeg}<br>FFprobe: {ffprobe}<p><a href=\"https://github.com/TagStudioDev/TagStudio\">GitHub-adattár</a> | <a href=\"https://docs.tagstud.io\">Dokumentáció</a> | <a href=\"https://discord.com/invite/hRNnVKhF2G\">Discord-szerver</a></p>",
"about.content": "<p>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.</p>Licenc: GPLv3<br>Konfigurációs fájl: {config_path}<br>FFmpeg: {ffmpeg}<br>FFprobe: {ffprobe}<p><a href=\"https://github.com/TagStudioDev/TagStudio\">GitHub-adattár</a> | <a href=\"https://docs.tagstud.io\">Dokumentáció</a> | <a href=\"https://discord.com/invite/hRNnVKhF2G\">Discord-szerver</a></p>",
"about.title": "Névjegy",
"app.git": "Git-véglegesítés",
"app.pre_release": "Kísérleti verzió",
"app.title": "{base_title} Könyvtár: „{library_dir}”",
"color.color_border": "Másodlagos szín használata keretszínként",
"color.confirm_delete": "Biztosan törölni akarja a(z) „{color_name}”-színt?",
"color.delete": "Címke törlése",
"color.import_pack": "Színcsomag importálása",
"color.name": "Megnevezés",
"color.namespace.delete.prompt": "Biztosan törölni akarja ezt a színnévteret? Ezzel a névtér ÖSSZES színét törölni fogja!",
"color.namespace.delete.title": "Színnévtér törlése",
"color.new": "Új szín",
"color.placeholder": "Szín",
"color.primary": "Elsődleges szín",
"color.primary_required": "Elsődleges szín (kötelező)",
"color.secondary": "Másodlagos szín",
"color.title.no_color": "Színtelen",
"color_manager.title": "Színek kezelése",
"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.",
@@ -13,6 +26,9 @@
"drop_import.progress.label.singular": "Új fájlok importálása folyamatban…\n1 fájl importálva.{suffix}",
"drop_import.progress.window_title": "Fájlok importálása",
"drop_import.title": "Fájlütközés",
"edit.color_manager": "Színek kezelése",
"edit.copy_fields": "Mezők másolása",
"edit.paste_fields": "Mezők beillesztése",
"edit.tag_manager": "Címkék kezelése",
"entries.duplicate.merge": "Egyező elemek &egyesítése",
"entries.duplicate.merge.label": "Egyező elemek egyesítése folyamatban…",
@@ -96,6 +112,7 @@
"generic.recent_libraries": "Legutóbbi könyvtárak",
"generic.rename": "Átnevezés",
"generic.rename_alt": "&Átnevezés",
"generic.reset": "Alaphelyzet",
"generic.save": "Mentés",
"generic.skip": "Kihagyás",
"generic.skip_alt": "&Kihagyás",
@@ -154,9 +171,16 @@
"library.refresh.scanning_preparing": "Új fájlok keresése a mappákban…\nElőkészítés…",
"library.refresh.title": "Mappák frissítése",
"library.scan_library.title": "Könyvtár vizsgálata",
"library_object.name": "Megnevezés",
"library_object.name_required": "Megnevezés (kötelező)",
"library_object.slug": "Azonosító-helyőrző",
"library_object.slug_required": "Azonosító-helyőrző (kötelező)",
"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",
"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",
"menu.edit": "S&zerkesztés",
"menu.edit.ignore_list": "Fájlok és mappák figyelmen kívül hagyása",
"menu.edit.manage_file_extensions": "&Fájlkiterjesztések kezelése",
@@ -177,22 +201,37 @@
"menu.macros": "&Makrók",
"menu.macros.folders_to_tags": "Mappák &címkékké alakítása",
"menu.select": "Kijelölés",
"menu.settings": "Beállítások…",
"menu.tools": "&Eszközök",
"menu.tools.fix_duplicate_files": "&Egyező fájlok egyesítése",
"menu.tools.fix_unlinked_entries": "Kapcsolat &nélküli elemek javítása",
"menu.view": "&Nézet",
"menu.window": "&Ablak",
"namespace.create.description": "A TagStudio névterekkel különíti el az adatcsoportokat, mint a címkék és a színek, így azok könnyen exportálhatóak és megoszthatóak. A „tagstudio”-val kezdődő névterek belső használatra vannak lefoglalva.",
"namespace.create.description_color": "Minden szín névterekbe van foglalva, amelyek színpalettaként viselkednek. Minden egyéni színt névtérbe kell foglalni.",
"namespace.create.title": "Névtér létrehozása",
"namespace.new.button": "Új névtér",
"namespace.new.prompt": "Az egyéni színek használatához először hozzon létre egy névteret!",
"preview.multiple_selection": "<b>{count}</b> kijelölt elem",
"preview.no_selection": "Nincs kijelölt elem",
"select.add_tag_to_selected": "Címke hozzáadása a kijelölt elemekhez",
"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.language": "Nyelv",
"settings.open_library_on_start": "Könyvtár megnyitása a program indulásakor",
"settings.restart_required": "A módosítások érvénybeléptetéséhez ú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.title": "Beállítások",
"sorting.direction.ascending": "Növekvő sorrend",
"sorting.direction.descending": "Csökkenő sorrend",
"splash.opening_library": "Könyvtár megnyitása folyamatban: „{library_path}”…",
"status.deleted_file_plural": "{count} fájl törlése befejeződött.",
"status.deleted_file_singular": "1 fájl törlése befejeződött.",
"status.deleted_none": "Egy fájl sem került törlésre.",
"status.deleted_partial_warning": "Csak {count} fájl került törlésre. Lehetséges, hogy a többi fájl hiányzik vagy használatban van.",
"status.deleting_file": "{count}/{i}. fájl törlése folyamatban: „{path}”…",
"status.library_backup_in_progress": "Könyvtár biztonsági mentése folyamatban…",
"status.library_backup_success": "A biztonsági mentés létrehozása megtörtént az alábbi elérési úton: „{path}” ({time_span})",
"status.library_closed": "Könyvtár bezárása ({time_span}) sikeresen megtörtént.",
@@ -203,11 +242,13 @@
"status.library_version_found": "Tényleges érték:",
"status.library_version_mismatch": "A könyvtár és a program verziója nem egyezik.",
"status.results": "találat",
"status.results.invalid_syntax": "Szintaktikai hiba:",
"status.results_found": "{count} találat ({time_span})",
"tag.add": "Címke hozzáadása",
"tag.add.plural": "Címkék hozzáadása",
"tag.add_to_search": "Keresési kifejezés kiegészítése",
"tag.aliases": "Áljelek",
"tag.all_tags": "Minden címke",
"tag.choose_color": "Címkeszín",
"tag.color": "Szín",
"tag.confirm_delete": "Biztosan törölni akarja a(z) „{tag_name}” címkét?",
@@ -215,6 +256,7 @@
"tag.create_add": "„{query}”-címke létrehozása és alkalmazása",
"tag.disambiguation.tooltip": "Címke használata egyértelműsítéshez",
"tag.edit": "Címke szerkesztése",
"tag.is_category": "Kategória",
"tag.name": "Név",
"tag.new": "Új címke",
"tag.parent_tags": "Szülőcímkék",
@@ -223,8 +265,21 @@
"tag.remove": "Címke eltávolítása",
"tag.search_for_tag": "Címke keresése",
"tag.shorthand": "Rövidítés",
"tag.tag_name_required": "Címkenév (Kötelező)",
"tag.tag_name_required": "Címkenév (kötelező)",
"tag.view_limit": "Megtekintési korlát:",
"tag_manager.title": "Könyvtárcímkék",
"trash.context.ambiguous": "Fájl(ok) {trash_term} helyezése",
"trash.context.plural": "Fájlok {trash_term} helyezése",
"trash.context.singular": "Fájl {trash_term} helyezése",
"trash.dialog.disambiguation_warning.plural": "Ezzel a fájlok nem csak a TagStudióból, hanem a fájlrendszerből <i>is</i> el lesznek távolítva!",
"trash.dialog.disambiguation_warning.singular": "Ezzel a fájl nem csak a TagStudióból, hanem a fájlrendszerből <i>is</i> el lesz távolítva!",
"trash.dialog.move.confirmation.plural": "Biztosan a {trash_term} akarod helyezni ezt a(z) {count} elemet?",
"trash.dialog.move.confirmation.singular": "Biztosan a {trash_term} akarod helyezni ezt az elemet?",
"trash.dialog.permanent_delete_warning": "<b>FIGYELMEZTETÉS:</b> Ha a fájlt nem lehet a {trash_term} helyezni, akkor <b>véglegesen <b>törlésre kerül!</b>",
"trash.dialog.title.plural": "Fájlok törlése",
"trash.dialog.title.singular": "Fájl törlése",
"trash.name.generic": "Kukába",
"trash.name.windows": "Lomtárba",
"view.size.0": "Apró",
"view.size.1": "Kicsi",
"view.size.2": "Közepes",

View File

@@ -2,9 +2,9 @@
"entries.duplicate.merge.label": "Fletter duplikatoppføringer …",
"entries.mirror": "Speil",
"entries.tags": "Etiketter",
"entries.unlinked.delete.confirm": "Slett følgende %{len(self.lib.missing_files)} oppføringer?",
"entries.unlinked.delete.confirm": "Slett følgende {count} oppføringer?",
"entries.unlinked.delete.deleting": "Sletting av oppføringer",
"entries.unlinked.delete.deleting_count": "Sletter %{x[0]+1}/{len(self.lib.missing_files)} ulenkede oppføringer",
"entries.unlinked.delete.deleting_count": "Sletter {idx}/{count} ulenkede oppføringer",
"entries.unlinked.refresh_all": "Gjenoppfrisk alle",
"entries.unlinked.scanning": "Skanner bibliotek for ulenkede oppføringer …",
"entries.unlinked.title": "Fiks ulenkede oppføringer",
@@ -40,7 +40,7 @@
"ignore_list.mode.label": "Listemodus:",
"ignore_list.title": "Filutvidelse",
"library.field.add": "Legg til felt",
"library.field.confirm_remove": "Fjern dette «%{self.lib.get_field_attr(field, \"name\")}»-feltet?",
"library.field.confirm_remove": "Fjern dette «\"{name}\"»-feltet?",
"library.field.mixed_data": "Blandet data",
"library.field.remove": "Fjern felt",
"library.missing": "Posisjon mangler",
@@ -53,7 +53,7 @@
"menu.tools": "Verktøy",
"menu.window": "Vindu",
"preview.no_selection": "Ingen elementer valgt",
"status.library_backup_success": "Kopi av bibliotek lagret i:",
"status.library_backup_success": "Kopi av bibliotek lagret i: \"{path}\" ({time_span})",
"status.library_save_success": "Bibliotek lagret og lukket.",
"status.library_search_query": "Søker i biblioteket etter",
"status.results": "Resultat",

View File

@@ -0,0 +1,106 @@
{
"color.title.no_color": "Geen Kleur",
"drop_import.progress.label.initial": "Nieuwe bestanden importeren…",
"drop_import.progress.label.plural": "Nieuwe bestanden importeren…\n{count} bestanden geïmporteerd.{suffix}",
"drop_import.progress.label.singular": "Nieuwe bestanden importeren…\n1 bestand geïmporteerd.{suffix}",
"drop_import.progress.window_title": "Importeer bestanden",
"edit.copy_fields": "Velden Kopiëren",
"edit.paste_fields": "Velden Plakken",
"edit.tag_manager": "Beheer Labels",
"entries.tags": "Labels",
"field.copy": "Veld Kopiëren",
"field.edit": "Veld Aanpassen",
"field.paste": "Veld Plakken",
"file.date_added": "Datum Toegevoegd",
"file.date_created": "Datum Aangemaakt",
"file.date_modified": "Datum Aangepast",
"file.dimensions": "Dimensies",
"file.duration": "Speelduur",
"file.not_found": "Bestand Niet Gevonden",
"file.open_file": "Bestand openen",
"file.open_file_with": "Bestand openen met",
"folders_to_tags.close_all": "Alles sluiten",
"folders_to_tags.open_all": "Alles openen",
"generic.add": "Toevoegen",
"generic.apply": "Toepassen",
"generic.apply_alt": "&Toepassen",
"generic.cancel": "Annuleren",
"generic.cancel_alt": "&Annuleren",
"generic.close": "Sluiten",
"generic.continue": "Doorgaan",
"generic.copy": "Kopiëren",
"generic.cut": "Knippen",
"generic.delete": "Verwijderen",
"generic.delete_alt": "&Verwijderen",
"generic.done": "Klaar",
"generic.done_alt": "&klaar",
"generic.edit": "Aanpassen",
"generic.edit_alt": "&Aanpassen",
"generic.filename": "Bestandsnaam",
"generic.navigation.back": "Terug",
"generic.navigation.next": "Volgende",
"generic.none": "Niks",
"generic.overwrite": "Overschrijven",
"generic.overwrite_alt": "&Overschrijven",
"generic.paste": "Plakken",
"generic.rename": "Hernoemen",
"generic.rename_alt": "&Hernoemen",
"generic.save": "Opslaan",
"generic.skip": "Overslaan",
"generic.skip_alt": "&Overslaan",
"home.search": "Zoeken",
"home.search_tags": "Labels Zoeken",
"home.thumbnail_size": "Miniatuur Grootte",
"home.thumbnail_size.extra_large": "Extra Grote Miniaturen",
"home.thumbnail_size.large": "Grote Miniaturen",
"home.thumbnail_size.medium": "Gemiddelde Minituren",
"home.thumbnail_size.mini": "Mini Miniaturen",
"home.thumbnail_size.small": "Kleine Miniaturen",
"ignore_list.mode.exclude": "Uitsluiten",
"json_migration.finish_migration": "Migratie Afronden",
"json_migration.heading.aliases": "Aliassen:",
"json_migration.heading.colors": "Kleuren:",
"json_migration.heading.fields": "Velden:",
"json_migration.heading.names": "Namen:",
"json_migration.heading.paths": "Paden:",
"json_migration.heading.shorthands": "Afkortingen:",
"json_migration.heading.tags": "Labels:",
"json_migration.migration_complete": "Migratie Afgerond!",
"json_migration.title": "Migratie Formaat Opslaan: \"{path}\"",
"library.field.add": "Veld Toevoegen",
"library.field.mixed_data": "Gemixte Data",
"library.field.remove": "Veld Verwijderen",
"menu.delete_selected_files_ambiguous": "Bestand(en) verplaatsen naar {trash_term}",
"menu.delete_selected_files_plural": "Bestanden verplaatsen naar {trash_term}",
"menu.delete_selected_files_singular": "Bestand verplaatsen naar {trash_term}",
"menu.edit": "Aanpassen",
"menu.edit.ignore_list": "Bestanden en Mappen Negeren",
"menu.edit.manage_tags": "Labels Beheren",
"menu.edit.new_tag": "Nieuw &Label",
"menu.file": "&Bestand",
"menu.help": "&Help",
"menu.macros.folders_to_tags": "Mappen naar Labels",
"menu.select": "Selecteren",
"menu.window": "Venster",
"select.all": "Alles Selecteren",
"sorting.direction.ascending": "Oplopend",
"sorting.direction.descending": "Aflopend",
"status.deleted_file_plural": "{count} Bestanden Verwijderd!",
"status.deleted_file_singular": "1 Bestand Verwijderd!",
"status.deleted_none": "Geen bestanden verwijderd.",
"status.deleting_file": "Bestand [{i}/{count}] verwijderen: \"{path}\"…",
"status.library_version_found": "Gevonden:",
"status.results": "Resultaten",
"tag.add": "Label toevoegen",
"tag.add.plural": "Labels toevoegen",
"tag.aliases": "Aliassen",
"tag.all_tags": "Alle Labels",
"tag.choose_color": "Kies Label Kleur",
"tag.color": "Kleur",
"tag.edit": "Label Aanpassen",
"tag.name": "Naam",
"tag.new": "Nieuw Label",
"tag.remove": "Verwijder Label",
"trash.dialog.title.plural": "Bestanden Verwijderen",
"trash.dialog.title.singular": "Bestand Verwijden"
}

View File

@@ -1,14 +1,33 @@
{
"about.title": "O programie",
"app.git": "Migawki Git",
"app.git": "Migawka Git",
"app.pre_release": "Przedpremiera",
"app.title": "{base_title} - Biblioteka '{library_dir}'",
"color.color_border": "Użyj koloru pochodnego na ramkę",
"color.confirm_delete": "Czy na pewno chcesz usunąć kolor \"{color_name}\"?",
"color.delete": "Usuń tag",
"color.import_pack": "Importuj paczkę kolorów",
"color.name": "Nazwa",
"color.namespace.delete.prompt": "Czy na pewno chcesz usunąć tę przestrzeń kolorów? Zostaną usunięte WSZYSTKIE kolory w przestrzeni!",
"color.namespace.delete.title": "Usuń przestrzeń kolorów",
"color.new": "Nowy kolor",
"color.placeholder": "Kolor",
"color.primary": "Kolor podstawowy",
"color.primary_required": "Kolor podstawowy (wymagany)",
"color.secondary": "Kolor pochodny",
"color.title.no_color": "Brak koloru",
"color_manager.title": "Zarządzaj kolorami tagów",
"drop_import.description": "Następujące pliki pasują ścieżkami do już istniejących w bibliotece",
"drop_import.duplicates_choice.plural": "Następujące {count} pliki pasują ścieżkami do już istniejących w bibliotece.",
"drop_import.duplicates_choice.singular": "Następujący plik pasuje ścieżką do już istniejącego w bibliotece.",
"drop_import.progress.label.initial": "Importowanie nowych plików...",
"drop_import.progress.label.plural": "Importowanie nowych plików...\nZaimportowano {count} plików.{suffix}",
"drop_import.progress.label.singular": "Importowanie nowych plików...\nZaimportowano 1 plik.{suffix}",
"drop_import.progress.window_title": "Importuj pliki",
"drop_import.title": "Konfliktujące pliki",
"edit.color_manager": "Zarządzaj kolorami tagów",
"edit.copy_fields": "Skopiuj pola",
"edit.paste_fields": "Wklej pola",
"edit.tag_manager": "Zarządzaj tagami",
"entries.duplicate.merge": "Złącz zduplikowane wpisy",
"entries.duplicate.merge.label": "Łączenie zduplikowanych wpisów...",
@@ -92,6 +111,7 @@
"generic.recent_libraries": "Ostatnie biblioteki",
"generic.rename": "Zmień nazwę",
"generic.rename_alt": "&Zmień nazwę",
"generic.reset": "Resetuj",
"generic.save": "Zapisz",
"generic.skip": "Pomiń",
"generic.skip_alt": "&Pomiń",
@@ -116,14 +136,18 @@
"json_migration.discrepancies_found": "Znaleziono niezgodności biblioteki",
"json_migration.discrepancies_found.description": "Znaleziono niezgodności pomiędzy oryginalną a skonwertowaną biblioteką. Proszę sprawdzić i wybrać czy chcesz kontynuować z migracją czy anulować.",
"json_migration.finish_migration": "Ukończ migrację",
"json_migration.heading.aliases": "Zastępcze nazwy:",
"json_migration.heading.colors": "Kolory:",
"json_migration.heading.differ": "Niezgodność",
"json_migration.heading.entires": "Wpisy:",
"json_migration.heading.extension_list_type": "Typ listy rozszerzeń:",
"json_migration.heading.fields": "Pola:",
"json_migration.heading.file_extension_list": "Lista rozszerzeń plików:",
"json_migration.heading.match": "Dopasowane",
"json_migration.heading.names": "Nazwy:",
"json_migration.heading.parent_tags": "Tagi nadrzędne:",
"json_migration.heading.paths": "Ścieżki:",
"json_migration.heading.shorthands": "Skróty:",
"json_migration.heading.tags": "Tagi:",
"json_migration.migrating_files_entries": "Migrowanie {entries:,d} wpisów plików...",
"json_migration.migration_complete": "Migrowanie skończone!",
@@ -139,12 +163,18 @@
"library.field.remove": "Usuń pole",
"library.missing": "Brak lokalizacji",
"library.name": "Biblioteka",
"library.refresh.scanning.plural": "Skanowanie folderów w poszukiwaniu nowych plików...\nPrzeszukano {searched_count} plików, Znaleziono {found_count} nowych plików",
"library.refresh.scanning_preparing": "Skanowanie katalogów w poszukiwaniu nowych plików\nPrzygotowywanie...",
"library.refresh.title": "Odświeżanie katalogów",
"library.scan_library.title": "Skanowanie biblioteki",
"library_object.name": "Nazwa",
"library_object.name_required": "Nazwa (wymagana)",
"macros.running.dialog.new_entries": "Stosowanie skonfigurowanych makr na {count}/{total} nowych wpisach plików...",
"macros.running.dialog.title": "Stosowanie makr na nowych wpisach",
"media_player.autoplay": "Automatyczne odtwarzanie",
"menu.delete_selected_files_ambiguous": "Przenieś plik(i) do {trash_term}",
"menu.delete_selected_files_plural": "Przenieś pliki do {trash_term}",
"menu.delete_selected_files_singular": "Przenieś plik do {trash_term}",
"menu.edit": "Edytuj",
"menu.edit.ignore_list": "Ignoruj pliki i foldery",
"menu.edit.manage_file_extensions": "Zarządzaj rozszerzeniami plików",
@@ -165,22 +195,36 @@
"menu.macros": "&Makra",
"menu.macros.folders_to_tags": "Foldery na Tagi",
"menu.select": "Zaznacz",
"menu.settings": "Ustawienia...",
"menu.tools": "&Narzędzia",
"menu.tools.fix_duplicate_files": "Napraw zduplikowane &pliki",
"menu.tools.fix_unlinked_entries": "Napraw &odłączone wpisy",
"menu.view": "&Widok",
"menu.window": "Okno",
"namespace.create.description_color": "Kolory tagów używają przestrzeni jako grup palet. Wszystkie niestandardowe kolory muszą najpierw znajdować się w grupie przestrzeni.",
"namespace.create.title": "Stwórz przestrzeń",
"namespace.new.button": "Nowa przestrzeń",
"namespace.new.prompt": "Stwórz nową przestrzeń żeby zacząć dodawać niestandardowe kolory!",
"preview.multiple_selection": "<b>{count}</b> pozycji zaznaczonych",
"preview.no_selection": "Nie wybrano żadnych pozycji",
"select.add_tag_to_selected": "Dodaj tag do zaznaczonych",
"select.all": "Zaznacz wszystko",
"select.clear": "Odznacz zaznaczenie",
"settings.clear_thumb_cache.title": "Wyczyść pamięć podręczną miniaturek",
"settings.language": "Język",
"settings.open_library_on_start": "Otwieraj bibliotekę podczas startu",
"settings.restart_required": "Zrestartuj TagStudio żeby zmiany zaczęły obowiązywać.",
"settings.show_filenames_in_grid": "Pokazuj nazwy plików w siatce",
"settings.show_recent_libraries": "Pokazuj ostatnie biblioteki",
"settings.title": "Ustawienia",
"sorting.direction.ascending": "Rosnąco",
"sorting.direction.descending": "Malejąco",
"splash.opening_library": "Otwieranie biblioteki \"{library_path}\"...",
"status.deleted_file_plural": "Usunięto {count} plików!",
"status.deleted_file_singular": "Usunięto 1 plik!",
"status.deleted_none": "Nie usunięto żadnych plików.",
"status.deleted_partial_warning": "Usunięto tylko {count} plik(i/ów)! Sprawdź czy nie brakuje jakichś plików lub czy nie są obecnie w użyciu.",
"status.deleting_file": "Usuwanie pliku [{i}/{count}]: \"{path}\"...",
"status.library_backup_in_progress": "Zapisywanie kopii zapasowej biblioteki...",
"status.library_backup_success": "Kopia zapasowa biblioteki zapisana w: \"{path}\" ({time_span})",
"status.library_closed": "Biblioteka zamknięta ({time_span})",
@@ -191,17 +235,21 @@
"status.library_version_found": "Znaleziono:",
"status.library_version_mismatch": "Niezgodność wersji biblioteki!",
"status.results": "Wyniki",
"status.results.invalid_syntax": "Niepoprawna składnia zapytania:",
"status.results_found": "Znaleziono {count} wyników ({time_span})",
"tag.add": "Dodaj tag",
"tag.add.plural": "Dodaj tagi",
"tag.add_to_search": "Dodaj do wyszukiwania",
"tag.aliases": "Aliasy",
"tag.all_tags": "Wszystkie tagi",
"tag.choose_color": "Wybierz kolor tagu",
"tag.color": "Kolor",
"tag.confirm_delete": "Jesteś pewien że chcesz usunąć tag \"{tag_name}\"?",
"tag.create": "Stwórz tag",
"tag.create_add": "Stwórz && dodaj \"zapytanie\"",
"tag.create_add": "Stwórz && dodaj \"{query}\"",
"tag.disambiguation.tooltip": "Uzyj tego tagu dla uściślenia",
"tag.edit": "Edytuj tag",
"tag.is_category": "Jest kategorią",
"tag.name": "Nazwa",
"tag.new": "Nowy tag",
"tag.parent_tags": "Tagi nadrzędne",
@@ -211,7 +259,20 @@
"tag.search_for_tag": "Szukaj dla tagu",
"tag.shorthand": "Skrót",
"tag.tag_name_required": "Nazwa tagu (wymagana)",
"tag.view_limit": "Limit wyświetlania:",
"tag_manager.title": "Biblioteka tagów",
"trash.context.ambiguous": "Przenieś plik(i) do {trash_term}",
"trash.context.plural": "Przenieś pliki do {trash_term}",
"trash.context.singular": "Przenieś plik do {trash_term}",
"trash.dialog.disambiguation_warning.plural": "To usunie je z TagStudio <i>ORAZ</i> z twojego systemu plików!",
"trash.dialog.disambiguation_warning.singular": "To usunie go z TagStudio <i>ORAZ</i> z twojego systemu plików!",
"trash.dialog.move.confirmation.plural": "Czy na pewno chcesz przenieść te {count} plików do {trash_term}?",
"trash.dialog.move.confirmation.singular": "Czy na pewno chcesz przenieść ten plik do {trash_term}?",
"trash.dialog.permanent_delete_warning": "<b>WARNING!</b> Jeśli ten plik nie może być przeniesiony do {trash_term}, <b>Zostanie <b>usunięty na stałe!</b>",
"trash.dialog.title.plural": "Usuń pliki",
"trash.dialog.title.singular": "Usuń plik",
"trash.name.generic": "Kosz",
"trash.name.windows": "Kosz",
"view.size.0": "Mini",
"view.size.1": "Mały",
"view.size.2": "Średni",

View File

@@ -1,91 +1,259 @@
{
"entries.duplicate.merge.label": "Mesclando Entradas Duplicadas",
"entries.duplicate.refresh": "Atualizar Entradas Duplicadas",
"entries.duplicates.description": "Entradas duplicadas são definidas como multiplas entradas que levam ao mesmo arquivo no disco. Mergir essas entradas irá combinar as tags e metadados de todas as duplicatas em uma única entrada consolidada. Não confundir com \"Arquivos Duplicados\" que são duplicatas dos seus arquivos fora do TagStudio.",
"entries.mirror": "Espelho",
"entries.mirror.confirmation": "Tem certeza que você deseja espelhar os seguintes %{len(self.lib.dupe_files)} entradas?",
"entries.mirror.label": "Espelhando 1/%{count} Entradas...",
"entries.mirror.title": "Espelhando Entradas",
"entries.tags": "Rótulos",
"entries.unlinked.delete": "Deletar Entradas Não Linkada",
"entries.unlinked.delete.confirm": "Tem certeza que deseja deletar as seguintes %{len(self.lib.missing_files)} entradas?",
"entries.unlinked.delete.deleting": "Deletando Entradas",
"entries.unlinked.delete.deleting_count": "Deletando %{x[0]+1}/{len(self.lib.missing_files)} Entradas Não Linkadas",
"entries.unlinked.description": "Cada entrada na biblioteca está linkada a um arquivo em um dos seus diretórios. Se um arquivo linkado a uma entrada for movido ou deletado fora do TagStudio, ele é então considerado não linkado. Entradas não linkadas podem ser automaticamente re-linkadas por buscas nos seus diretórios, manualmente re-linkadas pelo usuário, ou deletada se for desejada.",
"entries.unlinked.refresh_all": "Atualizar_Tudo",
"entries.unlinked.relink.attempting": "Tentando Relinkar %{x[0]+1}/%{len(self.lib.missing_files)} Entradas, %{self.fixed} Relinkadas com Sucesso",
"entries.unlinked.relink.manual": "Relink Manual",
"entries.unlinked.relink.title": "Relinkando Entradas",
"entries.unlinked.scanning": "Escaneando Bibliotecada para Entradas Não Linkadas...",
"entries.unlinked.search_and_relink": "Buscar && Relinkar",
"entries.unlinked.title": "Corrigir Entradas Não Linkadas",
"about.content": "<p>TagStudio é uma aplicação de organização de fotos e arquivos com um sistema de tags que tem como foco conceder liberdade e flexibilidade ao usuário. Sem programas ou formatos proprietários, sem imensidão de arquivos Sidecar, e sem total transtorno de sua estrutura de sistema de arquivos.</p>Licença: GPLv3<br>Diretório de Configuração: {config_path}<br>FFmpeg: {ffmpeg}<br>FFprobe: {ffprobe}<p><a href=\"https://github.com/TagStudioDev/TagStudio\">GitHub</a> | <a href=\"https://docs.tagstud.io\">Documentação</a> | <a href=\"https://discord.com/invite/hRNnVKhF2G\">Discord</a></p>",
"about.title": "Sobre",
"app.git": "Confirmação do Git",
"app.pre_release": "Pré-Lançamento",
"app.title": "{base_title} - Biblioteca '{library_dir}'",
"color.color_border": "Usar Cores Secundárias nas Bordas",
"color.confirm_delete": "Tem certeza que você quer deletar a cor \"{color_name}\"?",
"color.delete": "Deletar Tag",
"color.import_pack": "Importar Pacote de Cores",
"color.name": "Nome",
"color.namespace.delete.prompt": "Tem certeza que você quer deletar o espaço de nome dessa cor? Isso irá deletar TODAS as cores do espaço de nome ao mesmo tempo!",
"color.namespace.delete.title": "Deletar Espaço de Cor",
"color.new": "Nova Cor",
"color.placeholder": "Cor",
"color.primary": "Cor Primária",
"color.primary_required": "Cor Primária (Obrigatório)",
"color.secondary": "Cor Secundária",
"color.title.no_color": "Nenhuma Cor",
"color_manager.title": "Gerenciar Cores das Tags",
"drop_import.description": "Os seguintes arquivos correspondem a caminhos de arquivos que já existem na biblioteca",
"drop_import.duplicates_choice.plural": "Os seguintes arquivos {count} correspondem a caminhos de arquivo que já existem na biblioteca.",
"drop_import.duplicates_choice.singular": "O arquivo a seguir corresponde a um caminho de arquivo que já existe na biblioteca.",
"drop_import.progress.label.initial": "Importando Novos Arquivos...",
"drop_import.progress.label.plural": "Importando Novos Arquivos...\n{count} Arquivos Importados.{suffix}",
"drop_import.progress.label.singular": "Importando Novos Arquivos...\n1 Arquivo Importado.{suffix}",
"drop_import.progress.window_title": "Importar Arquivos",
"drop_import.title": "Arquivo(s) em Conflito",
"edit.color_manager": "Gerenciar Cores das Tags",
"edit.copy_fields": "Copiar Campos",
"edit.paste_fields": "Colar Campos",
"edit.tag_manager": "Gerenciar Tags",
"entries.duplicate.merge": "Mesclar Registros Duplicados",
"entries.duplicate.merge.label": "Mesclando Itens Duplicados...",
"entries.duplicate.refresh": "Atualizar Registros Duplicados",
"entries.duplicates.description": "Registros duplicados são definidas como multiplos registros que levam ao mesmo arquivo no disco. Mesclar esses registros irá combinar as tags e metadados de todas as duplicatas em um único registro consolidado. Não confundir com \"Arquivos Duplicados\" que são duplicatas dos seus arquivos fora do TagStudio.",
"entries.mirror": "&Espelho",
"entries.mirror.confirmation": "Tem certeza que você deseja espelhar os seguintes {count} registros?",
"entries.mirror.label": "Espelhando {idx}/{total} Registros...",
"entries.mirror.title": "Espelhando Registros",
"entries.mirror.window_title": "Espelhar Registros",
"entries.running.dialog.new_entries": "Adicionando {total} Novos Registros de Arquivos...",
"entries.running.dialog.title": "Adicionando Novos Registros de Arquivos",
"entries.tags": "Tags",
"entries.unlinked.delete": "Deletar Registros não Referenciados",
"entries.unlinked.delete.confirm": "Tem certeza que deseja deletar os seguintes {count} Registros ?",
"entries.unlinked.delete.deleting": "Deletando Registros",
"entries.unlinked.delete.deleting_count": "Deletando {idx}/{count} Registros Não Linkadas",
"entries.unlinked.delete_alt": "De&letar Registros Não Linkados",
"entries.unlinked.description": "Cada registro na biblioteca faz referência à um arquivo em uma de suas pastas. Se um arquivo referenciado à uma entrada for movido ou deletado fora do TagStudio, ele é então considerado não-referenciado.<br><br>registros não-referenciados podem ser automaticamente referenciados por buscas nos seus diretórios, manualmente pelo usuário, ou deletado se for desejado.",
"entries.unlinked.missing_count.none": "Registros Não Referenciados: N/A",
"entries.unlinked.missing_count.some": "Registros não referenciados: {count}",
"entries.unlinked.refresh_all": "&Atualizar Tudo",
"entries.unlinked.relink.attempting": "Tentando referenciar {idx}/{missing_count} Registros, {fixed_count} Referenciados com Sucesso",
"entries.unlinked.relink.manual": "&Referência Manual",
"entries.unlinked.relink.title": "Referenciando Registros",
"entries.unlinked.scanning": "Escaneando Bibliotecada em busca de Registros Não Referenciados...",
"entries.unlinked.search_and_relink": "&Buscar && Referenciar",
"entries.unlinked.title": "Corrigir Registros Não Referenciados",
"field.copy": "Copiar Campo",
"field.edit": "Editar Campo",
"field.paste": "Colar Campo",
"file.date_added": "Datta de Adição",
"file.date_created": "Data de Criação",
"file.date_modified": "Dada de Modificação",
"file.date_modified": "Data de Modificação",
"file.dimensions": "Dimensões",
"file.duplicates.dupeguru.advice": "Após espelhagem, você estará livre para usar DupeGuru para deletar arquivos indesejados. Após, use a função \"Consertar Entradas Não Linkadas\" do TagStudio no menu de Ferramentas para deletar entradas não linkadas.",
"file.duplicates.description": "TagStudio aceita resultados do DupeGuru pra gerenciar arquivos duplicados.",
"file.duplicates.dupeguru.advice": "Após espelhagem, você estará livre para usar DupeGuru para deletar arquivos indesejados. Após, use a função \"Corrigir Registros Não Referenciados\" do TagStudio no menu de Ferramentas para deletar esses Registros.",
"file.duplicates.dupeguru.file_extension": "Arquivos DupeGuru (*.dupeguru)",
"file.duplicates.dupeguru.load_file": "Carregar Aquivo DupeGuru",
"file.duplicates.dupeguru.load_file": "&Carregar Aquivo DupeGuru",
"file.duplicates.dupeguru.no_file": "Nenhum Arquivo DupeGuru Selecionado",
"file.duplicates.dupeguru.open_file": "Abrir Arquivo de Resultados do DupeGuru",
"file.duplicates.fix": "Corrigir Arquivos Duplicados",
"file.duplicates.matches": "Correspondências de Arquivos Duplicados: %{count}",
"file.duplicates.matches": "Correspondências de Arquivos Duplicados: {count}",
"file.duplicates.matches_uninitialized": "Correspondências de Arquivos Duplicados: N/A",
"file.duplicates.mirror.description": "Espelhe os ados de entrada em cada conjunto de correspondência duplicado, combinando todos os dados sem remover ou duplicar campos. Esta operação não excluirá nenhum arquivo ou dado.",
"file.duplicates.mirror_entries": "Entradas Espelhadas",
"file.not_found": "Arquivo não encontrado:",
"file.duplicates.mirror_entries": "&Registros Espelhados",
"file.duration": "Duração",
"file.not_found": "Arquivo não encontrado",
"file.open_file": "Abrir arquivo",
"file.open_file_with": "Abrir arquivo com",
"file.open_location.generic": "Abrir no explorador de arquivos",
"folders_to_tags.converting": "Convertendo pastas para Rótulos",
"folders_to_tags.description": "Cria rótulos baseado na sua estrutura de arquivos e aplica elas nas suas entradas\nA estrutura abaixo mostra todos os rótulos que irão ser criados e a quais entradas eles serão aplicados.",
"file.open_location.mac": "Mostrar no Finder",
"file.open_location.windows": "Mostrar no Explorador de Arquivos",
"folders_to_tags.close_all": "Fechar Tudo",
"folders_to_tags.converting": "Convertendo pastas para Tags",
"folders_to_tags.description": "Cria tags com base na sua estrutura de arquivos e aplica elas nos seus registros\nA estrutura abaixo mostra todas as tags que serão criadas e em quais itens elas serão aplicadas.",
"folders_to_tags.open_all": "Abrir Tudo",
"folders_to_tags.title": "Criar rótulos a partir das pastas",
"folders_to_tags.title": "Criar tags a partir de pastas",
"generic.add": "Adicionar",
"generic.apply": "Aplicar",
"generic.apply_alt": "&Aplicar",
"generic.cancel": "Cancelar",
"generic.cancel_alt": "&Cancelar",
"generic.close": "Fechar",
"generic.continue": "Continuar",
"generic.copy": "Copiar",
"generic.cut": "Recortar",
"generic.delete": "Deletar",
"generic.done": "Completo",
"generic.delete_alt": "&Excluir",
"generic.done": "Pronto",
"generic.done_alt": "&Pronto",
"generic.edit": "Editar",
"generic.edit_alt": "&Editar",
"generic.filename": "Nome do Arquivo",
"generic.navigation.back": "Anterior",
"generic.navigation.next": "Próximo",
"generic.none": "Nenhum",
"generic.overwrite": "Sobrescrever",
"generic.overwrite_alt": "&Sobrescrever",
"generic.paste": "Colar",
"generic.recent_libraries": "Bibliotecas recentes",
"generic.rename": "Renomear",
"generic.rename_alt": "&Renomear",
"generic.reset": "Redefinir",
"generic.save": "Salvar",
"generic.skip": "Pular",
"generic.skip_alt": "&Pular",
"help.visit_github": "Visite o Repositório no GitHub",
"home.search": "Buscar",
"home.search_entries": "Buscar Entradas",
"home.search_tags": "Buscar Rótulos",
"home.search_entries": "Buscar Registros",
"home.search_library": "Buscar na Biblioteca",
"home.search_tags": "Buscar Tags",
"home.thumbnail_size": "Tamanho de miniatura",
"ignore_list.add_extension": "Adicionar Extensão",
"home.thumbnail_size.extra_large": "Miniaturas Extra Grandes",
"home.thumbnail_size.large": "Miniaturas Grandes",
"home.thumbnail_size.medium": "Miniaturas Médias",
"home.thumbnail_size.mini": "Miniaturas Mini",
"home.thumbnail_size.small": "Miniaturas Pequenas",
"ignore_list.add_extension": "&Adicionar Extensão",
"ignore_list.mode.exclude": "Excluir",
"ignore_list.mode.include": "Incluir",
"ignore_list.mode.label": "Modo de Lista:",
"ignore_list.mode.label": "Modo:",
"ignore_list.title": "Extensões de Arquivo",
"json_migration.creating_database_tables": "Criando Tabelas de Banco de Dados SQL...",
"json_migration.discrepancies_found": "Encontradas Discrepâncias na biblioteca",
"json_migration.discrepancies_found.description": "Discrepâncias foram encontradas entre os arquivos de Biblioteca originais e os convertidos. Por favor, revise e escolha continuar com a migração ou cancelar.",
"json_migration.finish_migration": "Finalizar Migração",
"json_migration.heading.aliases": "Pseudônimos:",
"json_migration.heading.colors": "Cores:",
"json_migration.heading.differ": "Discrepância",
"json_migration.heading.entires": "Registros:",
"json_migration.heading.fields": "Campos:",
"json_migration.heading.file_extension_list": "Lista de Extensão de Arquivo:",
"json_migration.heading.names": "Nomes:",
"json_migration.heading.parent_tags": "Tags Pai:",
"json_migration.heading.tags": "Tags:",
"json_migration.migrating_files_entries": "Migrando {entries:,d} Registros de Arquivos...",
"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",
"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 \"%{self.lib.get_field_attr(field, \"name\")}\"?",
"library.field.confirm_remove": "Você tem certeza de que quer remover o campo \"{name}\"?",
"library.field.mixed_data": "Dados Mistos",
"library.field.remove": "Remover Campo",
"library.missing": "Localização Ausente",
"library.name": "Biblioteca",
"library.refresh.scanning.plural": "Buscando Pastas por Novos Arquivos ...\n{searched_count} Arquivos Encontrados, {found_count} Novos Arquivos",
"library.refresh.scanning.singular": "Buscando Pastas por Novos Arquivos ...\n{searched_count} Arquivos Encontrados, {found_count} Novos Arquivos",
"library.refresh.scanning_preparing": "Escaneando Diretórios por Novos Arquivos...\nPreparando...",
"library.refresh.title": "Atualizando Diretórios",
"library.refresh.title": "Atualizando Pastas",
"library.scan_library.title": "Escaneando Biblioteca",
"macros.running.dialog.new_entries": "Executando Macros Configurados em %{x + 1}/%{len(new_ids)} Novas Entradas",
"macros.running.dialog.title": "Executando Macros nas Novas Entradas",
"library_object.name": "Nome",
"library_object.name_required": "Nome (Obrigatório)",
"macros.running.dialog.new_entries": "Executando Macros Configurados nos {count}/{total} Novos Registros de Arquivos...",
"macros.running.dialog.title": "Executando Macros nos Novos Registros",
"media_player.autoplay": "Tocar Automaticamente",
"menu.delete_selected_files_ambiguous": "Mover Arquivo(s) para {trash_term}",
"menu.delete_selected_files_plural": "Mover Arquivos para {trash_term}",
"menu.delete_selected_files_singular": "Mover Arquivo para {trash_term}",
"menu.edit": "Editar",
"menu.file": "Arquivo",
"menu.help": "Ajuda",
"menu.macros": "Macros",
"menu.tools": "Ferramentas",
"menu.edit.ignore_list": "Ignorar Arquivos e Pastas",
"menu.edit.manage_file_extensions": "Gerenciar Extensões de Arquivo",
"menu.edit.manage_tags": "Gerenciar Tags",
"menu.edit.new_tag": "Nova &Tag",
"menu.file": "&Arquivo",
"menu.file.clear_recent_libraries": "Limpar Recentes",
"menu.file.close_library": "&Fechar Biblioteca",
"menu.file.new_library": "Nova Biblioteca",
"menu.file.open_create_library": "&Abrir/Criar Biblioteca",
"menu.file.open_library": "Abrir Biblioteca",
"menu.file.open_recent_library": "Abrir Recente",
"menu.file.refresh_directories": "&Atualizar Pastas",
"menu.file.save_backup": "&Salvar Backup da Biblioteca",
"menu.file.save_library": "Salvar Biblioteca",
"menu.help": "&Ajuda",
"menu.help.about": "Sobre",
"menu.macros": "&Macros",
"menu.macros.folders_to_tags": "Pastas para Tags",
"menu.settings": "Configurações...",
"menu.tools": "&Ferramentas",
"menu.tools.fix_duplicate_files": "Corrigir &Arquivos Duplicados",
"menu.tools.fix_unlinked_entries": "Corrigir &Registros Não Referenciados",
"menu.view": "&Exibir",
"menu.window": "Janela",
"preview.multiple_selection": "<b>{count}</b> Itens Selecionados",
"preview.no_selection": "Nenhum Item Selecionado",
"status.library_backup_success": "Backup da Biblioteca Salvo em:",
"select.add_tag_to_selected": "Adicionar Tag às Seleções",
"select.all": "Selecionar Tudo",
"select.clear": "Limpar Seleção",
"settings.clear_thumb_cache.title": "Limpar cache de miniaturas",
"settings.language": "Idioma",
"settings.open_library_on_start": "Abrir Biblioteca ao Iniciar",
"settings.restart_required": "Por favor reinicie o TagStudio para que as mudanças façam efeito.",
"settings.show_filenames_in_grid": "Exibir nome dos arquivos",
"settings.show_recent_libraries": "Mostrar Bibliotecas Recentes",
"settings.title": "Configurações",
"sorting.direction.ascending": "Ordem Ascendente",
"sorting.direction.descending": "Ordem Descendente",
"splash.opening_library": "Abrindo Biblioteca \"{library_path}\"...",
"status.deleted_file_plural": "{count} Arquivos Apagados!",
"status.deleted_file_singular": "1 Arquivo Apagado!",
"status.deleted_none": "Nenhum Arquivo Apagado.",
"status.deleting_file": "Apagando arquivo [{i}/{count}]: \"{path}\"...",
"status.library_backup_in_progress": "Salvando Backup da Biblioteca...",
"status.library_backup_success": "Backup da Biblioteca Salvo em: \"{path}\" ({time_span})",
"status.library_closed": "Biblioteca Fechada ({time_span})",
"status.library_closing": "Fechando Biblioteca...",
"status.library_save_success": "Biblioteca Salva e Fechada!",
"status.library_search_query": "Procurando na Biblioteca por",
"status.library_search_query": "Procurando na Biblioteca...",
"status.library_version_expected": "Esperado:",
"status.library_version_found": "Encontrado:",
"status.library_version_mismatch": "Incompatibilidade de versão da biblioteca!",
"status.results": "Resultados",
"tag.add": "Adicionar Rótulo",
"tag.add_to_search": "Adicionar a Pesquisa",
"status.results.invalid_syntax": "Sintaxe de Pesquisa Inválida:",
"status.results_found": "{count} Resultados Encontrados ({time_span})",
"tag.add": "Adicionar Tag",
"tag.add.plural": "Adicionar Tags",
"tag.add_to_search": "Adicionar à Pesquisa",
"tag.aliases": "Alias",
"tag.all_tags": "Todas Tags",
"tag.choose_color": "Escolha a cor da Tag",
"tag.color": "Cor",
"tag.confirm_delete": "Tem certeza que quer deletar a tag \"{tag_name}\"?",
"tag.create": "Criar Tag",
"tag.create_add": "Criar && Adicionar \"{query}\"",
"tag.edit": "Editar Tag",
"tag.is_category": "É Categoria",
"tag.name": "Nome",
"tag.new": "Novo Rótulo",
"tag.parent_tags": "Rótulos Pai",
"tag.parent_tags.add": "Adicionar Rótulo Pai",
"tag.search_for_tag": "Procurar por Rótulo",
"tag.shorthand": "Taquigrafia",
"tag_manager.title": "Rótulos da biblioteca"
"tag.new": "Nova Tag",
"tag.parent_tags": "Tags Pai",
"tag.parent_tags.add": "Adicionar Tag Pai",
"tag.search_for_tag": "Procurar por Tag",
"tag.shorthand": "Abreviação",
"tag_manager.title": "Tags da biblioteca",
"trash.context.ambiguous": "Mover arquivo(s) para {trash_term}",
"trash.context.plural": "Mover arquivos para {trash_term}",
"trash.context.singular": "Mover arquivo para {trash_term}",
"trash.dialog.move.confirmation.plural": "Tem certeza que quer remover esses {count} arquivos para o {trash_term}?",
"trash.dialog.move.confirmation.singular": "Tem certeza que quer mover esse arquivo para o {trash_term}?",
"trash.dialog.permanent_delete_warning": "<b>AVISO!</b> Se esse arquivo não puder ser movido para o {trash_term}, <b> ele será <b>apagado permanentemente!</b>",
"trash.dialog.title.plural": "Apagar Arquivos",
"trash.dialog.title.singular": "Apagar Arquivo",
"trash.name.windows": "Lixeira",
"window.message.error_opening_library": "Erro ao abrir biblioteca.",
"window.title.error": "Erro",
"window.title.open_create_library": "Abrir/Criar Bilbioteca"
}

View File

@@ -1,91 +1,221 @@
{
"entries.duplicate.merge.label": "Объединить повторяющиеся записи",
"entries.duplicate.refresh": "Обновить записи дубликаты",
"entries.duplicates.description": "Записи-дубликаты это несколько записей, которые одновременно привязаны к одному файлу. Объединение таких дубликатов соединит все теги и мета данные из этих записей в одну. Записи-дубликаты не стоит путать с несколькими копиями самого файла, которые могут существовать вне TagStudio.",
"about.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": "Записи-дубликаты — это несколько записей, которые одновременно привязаны к одному файлу. Объединение таких дубликатов соединит все теги и мета данные из этих записей в одну. Записи-дубликаты не стоит путать с несколькими копиями самого файла, которые могут существовать вне TagStudio.",
"entries.mirror": "Отзеркалить",
"entries.mirror.confirmation": "Вы уверенны, что хотите отзеркалить следующие %{len(self.lib.dupe_files)} записи?",
"entries.mirror.label": "Отзеркаливание 1/%{count} записей...",
"entries.mirror.confirmation": "Вы уверенны, что хотите отзеркалить следующие {count} записей?",
"entries.mirror.label": "Отзеркаливание {idx}/{total} записей...",
"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": "Вы уверены в том, что желаете удалить %{len(self.lib.missing_files)} записи?",
"entries.unlinked.delete.confirm": "Вы уверены, что желаете удалить {count} записей?",
"entries.unlinked.delete.deleting": "Удаление записей",
"entries.unlinked.delete.deleting_count": "Удалено %{x[0]+1}/{len(self.lib.missing_files)} откреплённых записей",
"entries.unlinked.description": "Каждая запись в библиотеке привязана к файлу, находящегося внутри той или иной папки. Если файл, к которому была привязана запись, был удалён или перемещён без использования TagStudio, то запись становиться \"откреплённой\". Откреплённые записи могут быть прикреплены обратно через автоматический рескан, вручную прикреплены обратно пользователем, либо же удалены если в них нет надобности.",
"entries.unlinked.delete.deleting_count": "Удаление {idx}/{count} откреплённых записей",
"entries.unlinked.delete_alt": "Удалить откреплённые записи",
"entries.unlinked.description": "Каждая запись в библиотеке привязана к файлу, находящегося внутри той или иной папки. Если файл, к которому была привязана запись, был удалён или перемещён без использования TagStudio, то запись становиться \"откреплённой\".<br><br>Откреплённые записи могут быть прикреплены обратно автоматически, либо же удалены если в них нет надобности.",
"entries.unlinked.missing_count.none": "Откреплённых записей: нет",
"entries.unlinked.missing_count.some": "Откреплённых записей: {count}",
"entries.unlinked.refresh_all": "Обновить Всё",
"entries.unlinked.relink.attempting": ытаемся вновь привязать %{x[0]+1}/%{len(self.lib.missing_files)} записей, %{self.fixed} Успешно привязано",
"entries.unlinked.relink.attempting": опытка перепривязать {idx}/{missing_count} записей, {fixed_count} привязано успешно",
"entries.unlinked.relink.manual": "Ручная привязка",
"entries.unlinked.relink.title": "Привязка записей",
"entries.unlinked.scanning": "Сканирование Библиотеки на наличие откреплённых записей...",
"entries.unlinked.search_and_relink": "Поиск и Привязка",
"entries.unlinked.scanning": "Сканирование библиотеки на наличие откреплённых записей...",
"entries.unlinked.search_and_relink": "Поиск и привязка",
"entries.unlinked.title": "Исправить откреплённые записи",
"field.copy": "Копировать поле",
"field.edit": "Редактировать поле",
"field.paste": "Вставить поле",
"file.date_added": "Дата добавления",
"file.date_created": "Дата создания",
"file.date_modified": "Дата изменения",
"file.dimensions": "Соотношения сторон",
"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",
"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.fix": "Исправить дубликаты файлов",
"file.duplicates.matches": "Совпадений дубликатов файлов: {count}",
"file.duplicates.matches_uninitialized": "Совпадений дубликатов файлов: нет",
"file.duplicates.mirror.description": "Отзеркалить данные записи внутри каждого указанного набора дубликатов, объединяя все данные внутри без удаления или дублирования категорий. Эта операция не удалит какие-либо файлы или данные.",
"file.duplicates.mirror_entries": "Отзеркалить записи",
"file.not_found": "Файл не найден:",
"file.duration": "Длительность",
"file.not_found": "Файл не найден",
"file.open_file": "Открыть файл",
"file.open_file_with": "Открыть файл с помощью",
"file.open_location.generic": "Открыть файл в проводнике",
"file.open_location.mac": "Показать в поисковике",
"file.open_location.windows": "Открыть в проводнике",
"folders_to_tags.close_all": "Закрыть всё",
"folders_to_tags.converting": "Конвертировать папки в теги",
"folders_to_tags.description": "Создаёт теги для записей согласно имеющейся иерархии папок.\nВнизу указаны все теги, которые будут созданы, а также записи к которым они будут применены.",
"folders_to_tags.open_all": "Открыть Всё",
"folders_to_tags.open_all": "Открыть всё",
"folders_to_tags.title": "Создать теги из папок",
"generic.add": "Добавить",
"generic.apply": "Применить",
"generic.apply_alt": "Применить",
"generic.cancel": "Отмена",
"generic.cancel_alt": "&Отмена",
"generic.close": "Закрыть",
"generic.continue": "Продолжить",
"generic.copy": "Копировать",
"generic.cut": "Вырезать",
"generic.delete": "Удалить",
"generic.delete_alt": "&Удалить",
"generic.done": "Завершено",
"generic.done_alt": "&Завершено",
"generic.edit": "Редактировать",
"generic.edit_alt": "&Редактировать",
"generic.filename": "Имя файла",
"generic.navigation.back": "Назад",
"generic.navigation.next": "Далее",
"generic.none": "Ничего",
"generic.overwrite": "Перезаписать",
"generic.overwrite_alt": "&Перезаписать",
"generic.paste": "Вставить",
"generic.recent_libraries": "Недавние библиотеки",
"generic.rename": "Переименовать",
"generic.rename_alt": "&Переименовать",
"generic.reset": "Cбросить",
"generic.save": "Сохранить",
"generic.skip": "Пропустить",
"generic.skip_alt": "&Пропустить",
"help.visit_github": "Посетить GitHub-репозиторий",
"home.search": "Поиск",
"home.search_entries": "Поисковые запросы",
"home.search_entries": "Поиск записей",
"home.search_library": "Поиск по библиотеке",
"home.search_tags": "Поиск тегов",
"home.thumbnail_size": "Размер иконок",
"ignore_list.add_extension": "Добавить расширение",
"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": "&Добавить расширение",
"ignore_list.mode.exclude": "Исключить",
"ignore_list.mode.include": "Включить",
"ignore_list.mode.label": "Список режимов:",
"ignore_list.title": "Расширения Файлов",
"library.field.add": "Добавить Категорию",
"library.field.confirm_remove": "Вы уверенны, что хотите удалить эту категорию \"%{self.lib.get_field_attr(field, \"name\")}\"?",
"ignore_list.title": "Расширения файлов",
"landing.open_create_library": "Открыть/создать библиотеку {shortcut}",
"library.field.add": "Добавить поле",
"library.field.confirm_remove": "Вы уверены, что хотите удалить поле \"{name}\"?",
"library.field.mixed_data": "Смешанные данные",
"library.field.remove": "Удалить Категорию",
"library.missing": "Путь не найден",
"library.field.remove": "Удалить поле",
"library.missing": "Отсутствует путь к библиотеке",
"library.name": "Библиотека",
"library.refresh.scanning_preparing": "Сканирование каталога на наличие новых файлов...\nПодготовка...",
"library.refresh.title": "Обновление Каталога",
"library.scan_library.title": "Сканирование Библиотеки",
"macros.running.dialog.new_entries": "Запуск сконфигурированных макросов в %{x + 1}/%{len(new_ids)} Новые файлы",
"macros.running.dialog.title": "Использование макросов на новых файлах",
"library.refresh.scanning.plural": "Сканирование папок на наличие новых файлов...\nПросканировано {searched_count} файлов, найдено {found_count} новых",
"library.refresh.scanning.singular": "Сканирование папок на наличие новых файлов...\nПросканирован {searched_count} файл, найдено {found_count} новых",
"library.refresh.scanning_preparing": "Сканирование папок на наличие новых файлов...\nПодготовка...",
"library.refresh.title": "Обновление папок",
"library.scan_library.title": "Сканирование библиотеки",
"library_object.name": "Название",
"library_object.name_required": "Название (Обязательно)",
"library_object.slug": "ID Slug",
"library_object.slug_required": "ID Slug (Обязательно)",
"macros.running.dialog.new_entries": "Выполнение макросов на {count}/{total} новых файлах...",
"macros.running.dialog.title": "Выполнение макросов на новых файлах",
"media_player.autoplay": "Автовоспроизведение",
"menu.delete_selected_files_ambiguous": еремеcтить файл(ы) в {trash_term}",
"menu.delete_selected_files_plural": еремеcтить файлы в {trash_term}",
"menu.delete_selected_files_singular": еремеcтить файл в {trash_term}",
"menu.edit": "Редактировать",
"menu.file": "Файл",
"menu.help": "Помощь",
"menu.macros": "Макросы",
"menu.tools": "Инструменты",
"menu.edit.ignore_list": "Игнорировать файлы и папки",
"menu.edit.manage_file_extensions": "Управлять разширениями файлов",
"menu.edit.manage_tags": "Управлять тегами",
"menu.edit.new_tag": "Новый &тег",
"menu.file": "&Файл",
"menu.file.clear_recent_libraries": "Очистить последние файлы",
"menu.file.close_library": "&Закрыть библиотеку",
"menu.file.new_library": "Новая библиотека",
"menu.file.open_create_library": "Открыть/создать библиотеку",
"menu.file.open_library": "Открыть библиотеку",
"menu.file.open_recent_library": "Открыть последнюю",
"menu.file.refresh_directories": "&Обновить папки",
"menu.file.save_backup": "&Сохранить резервную копию библиотеки",
"menu.file.save_library": "Сохранить библиотеку",
"menu.help": "&Помощь",
"menu.help.about": "О программе",
"menu.macros": "&Макросы",
"menu.macros.folders_to_tags": "Теги из папок",
"menu.select": "Выбрать",
"menu.settings": "Настройки...",
"menu.tools": "&Инструменты",
"menu.tools.fix_duplicate_files": "Исправить дубликаты &файлов",
"menu.tools.fix_unlinked_entries": "Исправить &откреплённые записи",
"menu.view": "&Вид",
"menu.window": "Окно",
"preview.multiple_selection": "Выбрано <b>{count}</b>",
"preview.no_selection": "Ничего не выбрано",
"status.library_backup_success": "Резервная копия библиотеки сохранена по адресу:",
"select.add_tag_to_selected": "Добавить тег к выбранному",
"select.all": "Выбрать всё",
"select.clear": "Отменить выбор",
"settings.clear_thumb_cache.title": "Очистить кэш иконок",
"settings.language": "Язык",
"settings.open_library_on_start": "Открывать библиотеку при запуске",
"settings.restart_required": "Пожалуйста, перезапустите TagStudio для применения изменений.",
"settings.show_filenames_in_grid": "Показывать имена файлов",
"settings.show_recent_libraries": "Показывать недавние библиотеки",
"settings.title": "Настройки",
"sorting.direction.ascending": "По возрастанию",
"sorting.direction.descending": "По убыванию",
"splash.opening_library": "Открывание библиотеки \"{library_path}\"...",
"status.deleted_file_plural": "{count} файлов удалено!",
"status.deleted_file_singular": "Файл удалён!",
"status.deleted_none": "Ничего не удалено.",
"status.deleted_partial_warning": "Удалено только {count} файлов! Убедитесь, что файлы не пропали, и не используются в данный момент.",
"status.deleting_file": "Удаление файла [{i}/{count}]: \"{path}\"...",
"status.library_backup_in_progress": "Сохранение резервной копии библиотеки...",
"status.library_backup_success": "Резервная копия библиотеки сохранена по адресу: \"{path}\" ({time_span})",
"status.library_closed": "Библиотека закрыта ({time_span})",
"status.library_closing": "Закрытие библиотеки...",
"status.library_save_success": "Библиотека сохранена и закрыта!",
"status.library_search_query": "Поиск по библиотеке",
"status.library_search_query": "Поиск по библиотеке...",
"status.library_version_expected": "Ожидалось:",
"status.library_version_found": "Найдено:",
"status.library_version_mismatch": "Несоответствие версии библиотеки!",
"status.results": "Результаты",
"status.results.invalid_syntax": "Некорректный синтаксис поиска:",
"status.results_found": "Найдено {count} результатов ({time_span})",
"tag.add": "Добавить тег",
"tag.add.plural": "Добавить теги",
"tag.add_to_search": "Добавить к поисковому запросу",
"tag.aliases": "Псевдонимы",
"tag.all_tags": "Все теги",
"tag.choose_color": "Выберите цвет тега",
"tag.color": "Цвет",
"tag.confirm_delete": "Вы уверены, что хотите удалить тег \"{tag_name}\"?",
"tag.create": "Создать тег",
"tag.create_add": "Создать и добавить \"{query}\"",
"tag.name": "Имя",
"tag.new": "Новый тег",
"tag.parent_tags": "Теги-родители",
"tag.parent_tags.add": "Добавить Теги-родители",
"tag.search_for_tag": "Поиск тега",
"tag.shorthand": "Сокращённое название",
"tag_manager.title": "Библиотека тегов"
"tag_manager.title": "Теги этой библиотеки"
}

View File

@@ -73,7 +73,7 @@
"menu.tools": "Verktyg",
"menu.window": "Fönster",
"preview.no_selection": "Inga valda objekt",
"status.library_backup_success": "Bibliotekets säkerhetskopia sparad i:",
"status.library_backup_success": "Bibliotekets säkerhetskopia sparad i: \"{path}\" ({time_span})",
"status.library_save_success": "Bibliotek sparat och stängt!",
"status.library_search_query": "Söker i biblioteket efter",
"status.results": "Resultat",

View File

@@ -3,17 +3,17 @@
"entries.duplicate.refresh": "நகல் உள்ளீடுகளைப் புதுப்பி",
"entries.duplicates.description": "மறுநுழைவுகள் என்பது, ஒரே கோப்பை குறிக்கும் பல நுழைவுகளை குறிக்கும். இவற்றை இணைப்பதால், அனைத்து மறுநுழைவுகளின் குறிச்சொற்களும் மெட்டாடேட்டாவும் ஒரே ஒட்டுமொத்த நுழைவாகச் சேர்க்கப்படும். இவற்றை 'மறுகோப்புகள்' என்பதுடன் குழப்பக் கூடாது, ஏனெனில் அவை டாக் ஸ்டுடியோவுக்கு வெளியேயுள்ள கோப்புகளின் நகல்களாகும்.",
"entries.mirror": "படிமம்",
"entries.mirror.confirmation": "பின்வரும் உள்ளீடுகளைப் பிரதிபலிக்க விரும்புகிறீர்களா %{len(self.lib.dupe_files)}?",
"entries.mirror.label": "1/%{count} உள்ளீடுகளைப் பிரதிபலிக்கப்படுகின்றது...",
"entries.mirror.confirmation": "பின்வரும் உள்ளீடுகளைப் பிரதிபலிக்க விரும்புகிறீர்களா {count}?",
"entries.mirror.label": "{idx}/{total} உள்ளீடுகளைப் பிரதிபலிக்கப்படுகின்றது...",
"entries.mirror.title": "உள்ளீடுகள் பிரதிபழிக்கப்படுகின்றது",
"entries.tags": "குறிச்சொற்கள்",
"entries.unlinked.delete": "இணைக்கப்படாத உள்ளீடுகளை நீக்கு",
"entries.unlinked.delete.confirm": "பின்வரும் உள்ளீடுகளை நிச்சயமாக நீக்க விரும்புகிறீர்களா %{len(self.lib.missing_files)}?",
"entries.unlinked.delete.confirm": "பின்வரும் உள்ளீடுகளை நிச்சயமாக நீக்க விரும்புகிறீர்களா {count}?",
"entries.unlinked.delete.deleting": "உள்ளீடுகள் நீக்கப்படுகிறது",
"entries.unlinked.delete.deleting_count": "%{x[0]+1}/{len(self.lib.missing_files)} இணைக்கப்படாத உள்ளீடுகள் நீக்கப்படுகிறது",
"entries.unlinked.delete.deleting_count": "{idx}/{count} இணைக்கப்படாத உள்ளீடுகள் நீக்கப்படுகிறது",
"entries.unlinked.description": "ஒவ்வொரு புத்தககல்லரி நுழைவும் உங்கள் அடைவுகளில் உள்ள ஒரு கோப்புடன் இணைக்கப்பட்டுள்ளது. டாக் ஸ்டுடியோ-வைத் தவிர கோப்புகள் நகர்த்தப்பட்டால் அல்லது நீக்கப்பட்டால், அவை இணைக்கப்படாதவையாகக் கருதப்படும். இணைக்கப்படாத நுழைவுகளை உங்கள் அடைவுகளில் தேடுவதன் மூலம் தானாகவே மீண்டும் இணைக்கலாம், பயனர் கைமுறையாக இணைக்கலாம் அல்லது விருப்பப்படி நீக்கலாம்.",
"entries.unlinked.refresh_all": "அனைத்தையும் புதுப்பி",
"entries.unlinked.relink.attempting": "%{x[0]+1}/%{len(self.lib.missing_files)} உள்ளீடுகளை மீண்டும் இணைக்க முயற்சிக்கிறது, %{self.fixed} மீண்டும் இணைக்கப்பட்டது",
"entries.unlinked.relink.attempting": "{idx}/{missing_count} உள்ளீடுகளை மீண்டும் இணைக்க முயற்சிக்கிறது, {fixed_count} மீண்டும் இணைக்கப்பட்டது",
"entries.unlinked.relink.manual": "கைமுறை மறு இணைப்பு",
"entries.unlinked.relink.title": "உள்ளீடுகள் மீண்டும் இணைக்கப்படுகின்றது",
"entries.unlinked.scanning": "இணைக்கப்படாத நுழைவுகளை புத்தககல்லரியில் சோதனை செய்யப்படுகிறது...",
@@ -56,7 +56,7 @@
"ignore_list.mode.label": "பட்டியல் முறை:",
"ignore_list.title": "கோப்பு நீட்டிப்புகள்",
"library.field.add": "புலத்தைச் சேர்க்க",
"library.field.confirm_remove": "இந்த \"%{self.lib.get_field_attr(field, \"name\")}\" புலத்தை நிச்சயமாக அகற்ற விரும்புகிறீர்களா?",
"library.field.confirm_remove": "இந்த \"{name}\" புலத்தை நிச்சயமாக அகற்ற விரும்புகிறீர்களா?",
"library.field.mixed_data": "கலப்பு தரவு",
"library.field.remove": "புலத்தை அகற்று",
"library.missing": "இடம் காணவில்லை",
@@ -64,7 +64,7 @@
"library.refresh.scanning_preparing": "புதிய கோப்புகளுக்கான அடைவுகள் சோதனை செய்யப்படுகின்றது...\nதயாராகிறது...",
"library.refresh.title": "கோப்பகங்கள் புதுப்பிக்கப்படுகின்றன",
"library.scan_library.title": "புத்தககல்லரி சோதனை செய்யப்படுகிறது",
"macros.running.dialog.new_entries": "%{x + 1} இல் கட்டமைக்கப்பட்ட செயல்முறைகளை இயக்கப்படுகிறது / %{len(new_ids)} புதிய பதிவுகள்",
"macros.running.dialog.new_entries": "{count} இல் கட்டமைக்கப்பட்ட செயல்முறைகளை இயக்கப்படுகிறது / {total} புதிய பதிவுகள்",
"macros.running.dialog.title": "புதிய நுழைவுகளில் செயல்முறைகளை இயக்கப்படுகின்றது",
"menu.edit": "திருத்து",
"menu.file": "கோப்பு",
@@ -73,7 +73,7 @@
"menu.tools": "கருவிகள்",
"menu.window": "சாளரம்",
"preview.no_selection": "உருப்படிகள் எதுவும் தேர்ந்தெடுக்கப்படவில்லை",
"status.library_backup_success": "நூலக காப்புப் பிரதி சேமிக்கப்பட்டது:",
"status.library_backup_success": "நூலக காப்புப் பிரதி சேமிக்கப்பட்டது: \"{path}\" ({time_span})",
"status.library_save_success": "நூலகம் சேமிக்கப்பட்டு மூடப்பட்டது!",
"status.library_search_query": "நூலகத்தைத் தேடுகிறது",
"status.results": "முடிவுகள்",

View File

@@ -7,17 +7,17 @@
"entries.duplicate.refresh": "o kama jo e sona tan ijo sama",
"entries.duplicates.description": "ken la, ijo mute li jo e ijo lon sama. ni li \"ijo sama\". sina wan e ona la, ijo sama li kama wan li jo e sona ale tan ijo sama ale.",
"entries.mirror": "jasima",
"entries.mirror.confirmation": "mi jasima e ijo %{len(self.lib.dupe_files)}. ni li pona anu seme?",
"entries.mirror.label": "mi jasima e ijo 1/%{count}...",
"entries.mirror.confirmation": "mi jasima e ijo {count}. ni li pona anu seme?",
"entries.mirror.label": "mi jasima e ijo {idx}/{total}...",
"entries.mirror.title": "mi jasima e ijo",
"entries.tags": "poki",
"entries.unlinked.delete": "o weka e ijo pi ijo lon ala",
"entries.unlinked.delete.confirm": "mi weka e ijo %{len(self.lib.missing_files)}. ni li pona anu seme?",
"entries.unlinked.delete.confirm": "mi weka e ijo {count}. ni li pona anu seme?",
"entries.unlinked.delete.deleting": "mi weka e ijo",
"entries.unlinked.delete.deleting_count": "mi weka e ijo %{x[0]+1}/{len(self.lib.missing_files)} pi ijo lon ala",
"entries.unlinked.delete.deleting_count": "mi weka e ijo {idx}/{count} pi ijo lon ala",
"entries.unlinked.description": "ijo ale li jo e ijo lon. ona li tawa anu weka, ona li jo ala e ijo lon. ijo pi ijo lon li ken alasa e tomo li ken kama jo e ijo lon. ante la sina ken pana ijo lon tawa ijo. ante la sina ken weka e ijo.",
"entries.unlinked.refresh_all": "o kama jo sin tan ale",
"entries.unlinked.relink.attempting": "mi o pana e ijo lon tawa ijo %{x[0]+1}/%{len(self.lib.missing_files)}. mi pana e ijo lon tawa ijo %{self.fixed}",
"entries.unlinked.relink.attempting": "mi o pana e ijo lon tawa ijo {idx}/{missing_count}. mi pana e ijo lon tawa ijo {fixed_count}",
"entries.unlinked.relink.manual": "sina o pana e ijo lon tawa ijo",
"entries.unlinked.relink.title": "mi pana e ijo lon tawa ijo",
"entries.unlinked.scanning": "mi o alasa e ijo pi ijo lon ala...",
@@ -76,7 +76,7 @@
"ignore_list.mode.label": "nasin kulupu",
"ignore_list.title": "nimi lon nimi ijo anpa",
"library.field.add": "pana e sona",
"library.field.confirm_remove": "sina weka e sona poki \"%{self.lib.get_field_attr(field, \"name\")}\". ni li pona anu seme?",
"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",
@@ -84,7 +84,7 @@
"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",
"macros.running.dialog.new_entries": "mi pali lon ijo sin %{x + 1}/%{len(new_ids)}",
"macros.running.dialog.new_entries": "mi pali lon ijo sin {count}/{total}",
"macros.running.dialog.title": "mi pali lon ijo sin",
"menu.edit": "ante",
"menu.edit.ignore_list": "o sona ala e ijo ni",
@@ -102,12 +102,12 @@
"settings.open_library_on_start": "ilo Tagstudio li open la o open e tomo ni",
"settings.show_filenames_in_grid": "o sitelen e nimi ijo lon leko sitelen",
"settings.show_recent_libraries": "o sitelen e tomo pi tenpo poka",
"splash.opening_library": "mi open e tomo",
"status.library_backup_success": "tomo sama li lon:",
"splash.opening_library": "mi open e tomo \"{library_path}\"...",
"status.library_backup_success": "tomo sama li lon: \"{path}\" ({time_span})",
"status.library_save_success": "tomo li awen li weka!",
"status.library_search_query": "mi alasa e",
"status.results": "jo",
"status.results_found": "mi sona e ijo {results.total_count}",
"status.results_found": "mi sona e ijo {count} ({time_span})",
"tag.add": "o pana e poki",
"tag.add_to_search": "pana tawa alasa",
"tag.aliases": "nimi ante",

View File

@@ -3,17 +3,17 @@
"entries.duplicate.refresh": "Yinelenen Kayıtları Yenile",
"entries.duplicates.description": "Yinelenen kayıtlar, diskinizde aynı dosyaya işaret eden birden fazla kayıt olarak tanımlanmaktadır. Bu kayıtları birleştirdiğinizde, yinelenen tüm kayıtların içerisindeki etiketler ve metadata bilgisi tek bir tane kayıt üzerinde birleştirilecektir. Bu, \"yinelenen dosyalar\" ile karıştırılmamalıdır. Yinelenen dosyalar, TagStudio'nun dışında birden fazla kere bulunan dosyalarınızdır.",
"entries.mirror": "Yansıt",
"entries.mirror.confirmation": "%{len(self.lib.dupe_files)} kaydı yansıtmak istediğinden emin misin?",
"entries.mirror.label": "1/%{count} Kayıt Yansıtılıyor...",
"entries.mirror.confirmation": "{count} kaydı yansıtmak istediğinden emin misin?",
"entries.mirror.label": "{idx}/{total} Kayıt Yansıtılıyor...",
"entries.mirror.title": "Kayıtlar Yansıtılıyor",
"entries.tags": "Etiketler",
"entries.unlinked.delete": "Kopuk Kayıtları Sil",
"entries.unlinked.delete.confirm": "%{len(self.lib.missing_files) tane kayıtları silmek istediğinden emin misin?",
"entries.unlinked.delete.confirm": "{count} tane kayıtları silmek istediğinden emin misin?",
"entries.unlinked.delete.deleting": "Kayıtlar Siliniyor",
"entries.unlinked.delete.deleting_count": "%{x[0]+1}/{len(self.lib.missing_files)} Kopuk Kayıt Siliniyor",
"entries.unlinked.delete.deleting_count": "{idx}/{count} Kopuk Kayıt Siliniyor",
"entries.unlinked.description": "Kütüphanenizdeki her bir kayıt, dizinlerinizden bir tane dosya ile eşleştirilmektedir. Eğer bir kayıta bağlı dosya TagStudio dışında taşınır veya silinirse, o dosya artık kopuk olarak sayılır. Kopuk kayıtlar dizinlerinizde arama yapılırken otomatik olarak tekrar eşleştirilebilir, manuel olarak sizin tarafınızdan eşleştirilebilir veya isteğiniz üzere silinebilir.",
"entries.unlinked.refresh_all": "Tümünü Yenile",
"entries.unlinked.relink.attempting": "%{x[0]+1}/%{len(self.lib.missing_files)} Kayıt Yeniden Eşleştirilmeye Çalışılıyor, %{self.fixed} Başarıyla Yeniden Eşleştirildi",
"entries.unlinked.relink.attempting": "{idx}/{missing_count} Kayıt Yeniden Eşleştirilmeye Çalışılıyor, {fixed_count} Başarıyla Yeniden Eşleştirildi",
"entries.unlinked.relink.manual": "Manuel Yeniden Eşleştirme",
"entries.unlinked.relink.title": "Kayıtlar Yeniden Eşleştiriliyor",
"entries.unlinked.scanning": "Kütüphane Kopuk Kayıtlar için Taranıyor...",
@@ -56,7 +56,7 @@
"ignore_list.mode.label": "Listeleme Modu:",
"ignore_list.title": "Dosya Uzantıları",
"library.field.add": "Alan Ekle",
"library.field.confirm_remove": "Bu \"%{self.lib.get_field_attr(field, \"name\")}\" alanını silmek istediğinden emin misin?",
"library.field.confirm_remove": "Bu \"{name}\" alanını silmek istediğinden emin misin?",
"library.field.mixed_data": "Karışık Veri",
"library.field.remove": "Alan Kaldır",
"library.missing": "Lokasyon bulunamadı",
@@ -64,7 +64,7 @@
"library.refresh.scanning_preparing": "Yeni Dosyalar için Dizinler Taranıyor...\nHazırlanıyor...",
"library.refresh.title": "Dizinler Yenileniyor",
"library.scan_library.title": "Kütüphane Taranıyor",
"macros.running.dialog.new_entries": "%{x + 1}/%{len(new_ids)} Tane Yeni Kayıt Üzerinde Yapılandırılmış Makrolar Çalıştırılıyor",
"macros.running.dialog.new_entries": "{count}/{total} Tane Yeni Kayıt Üzerinde Yapılandırılmış Makrolar Çalıştırılıyor",
"macros.running.dialog.title": "Yeni Kayıtlar Üzerinde Makrolar Çalıştırılıyor",
"menu.edit": "Düzenle",
"menu.file": "Dosya",
@@ -73,7 +73,7 @@
"menu.tools": "Araçlar",
"menu.window": "Pencere",
"preview.no_selection": "Hiçbir Öğe Seçilmedi",
"status.library_backup_success": "Kütüphane Yedeklemesi Şuraya Kaydedildi:",
"status.library_backup_success": "Kütüphane Yedeklemesi Şuraya Kaydedildi: \"{path}\" ({time_span})",
"status.library_save_success": "Kütüphane Kaydedildi ve Çıkış Yapıldı!",
"status.library_search_query": "Kütüphane Aranıyor",
"status.results": "Sonuçlar",

View File

@@ -29,7 +29,7 @@
"entries.unlinked.missing_count.none": "未連結項目:無",
"entries.unlinked.missing_count.some": "未連結項目:{count}",
"entries.unlinked.refresh_all": "&重新整理",
"entries.unlinked.relink.attempting": "正在重新連結{idx}/{missing_files)count}個項目, 成功重新連結{fixed_count}",
"entries.unlinked.relink.attempting": "正在重新連結{idx}/{missing_count}個項目, 成功重新連結{fixed_count}",
"entries.unlinked.relink.manual": "&手動重新連結",
"entries.unlinked.relink.title": "正在重新連結項目",
"entries.unlinked.scanning": "正在掃描資料庫以尋找未連結的項目...",

View File

@@ -2,8 +2,8 @@
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
VERSION: str = "9.5.0" # Major.Minor.Patch
VERSION_BRANCH: str = "Pre-Release 1" # Usually "" or "Pre-Release"
VERSION: str = "9.5.1" # Major.Minor.Patch
VERSION_BRANCH: str = "" # Usually "" or "Pre-Release"
# The folder & file names where TagStudio keeps its data relative to a library.
TS_FOLDER_NAME: str = ".TagStudio"
@@ -25,3 +25,5 @@ TAG_FAVORITE = 1
TAG_META = 2
RESERVED_TAG_START = 0
RESERVED_TAG_END = 999
RESERVED_NAMESPACE_PREFIX = "tagstudio"

View File

@@ -17,6 +17,7 @@ class SettingItems(str, enum.Enum):
SHOW_FILENAMES = "show_filenames"
AUTOPLAY = "autoplay_videos"
THUMB_CACHE_SIZE_LIMIT = "thumb_cache_size_limit"
LANGUAGE = "language"
class Theme(str, enum.Enum):
@@ -71,4 +72,4 @@ class LibraryPrefs(DefaultEnum):
IS_EXCLUDE_LIST = True
EXTENSION_LIST: list[str] = [".json", ".xmp", ".aae"]
PAGE_SIZE: int = 500
DB_VERSION: int = 6
DB_VERSION: int = 8

View File

@@ -307,6 +307,12 @@ def pastels() -> list[TagColorGroup]:
def shades() -> list[TagColorGroup]:
burgundy = TagColorGroup(
slug="burgundy",
namespace="tagstudio-shades",
name="Burgundy",
primary="#6E1C24",
)
auburn = TagColorGroup(
slug="auburn",
namespace="tagstudio-shades",
@@ -319,19 +325,31 @@ def shades() -> list[TagColorGroup]:
name="Olive",
primary="#4C652E",
)
dark_teal = TagColorGroup(
slug="dark-teal",
namespace="tagstudio-shades",
name="Dark Teal",
primary="#1F5E47",
)
navy = TagColorGroup(
slug="navy",
namespace="tagstudio-shades",
name="Navy",
primary="#104B98",
)
dark_lavender = TagColorGroup(
slug="dark_lavender",
namespace="tagstudio-shades",
name="Dark Lavender",
primary="#3D3B6C",
)
berry = TagColorGroup(
slug="berry",
namespace="tagstudio-shades",
name="Berry",
primary="#9F2AA7",
)
return [auburn, olive, navy, berry]
return [burgundy, auburn, olive, dark_teal, navy, dark_lavender, berry]
def earth_tones() -> list[TagColorGroup]:
@@ -421,6 +439,7 @@ def neon() -> list[TagColorGroup]:
name="Neon Red",
primary="#180607",
secondary="#E22C3C",
color_border=True,
)
neon_red_orange = TagColorGroup(
slug="neon-red-orange",
@@ -428,6 +447,7 @@ def neon() -> list[TagColorGroup]:
name="Neon Red Orange",
primary="#220905",
secondary="#E83726",
color_border=True,
)
neon_orange = TagColorGroup(
slug="neon-orange",
@@ -435,6 +455,7 @@ def neon() -> list[TagColorGroup]:
name="Neon Orange",
primary="#1F0D05",
secondary="#ED6022",
color_border=True,
)
neon_amber = TagColorGroup(
slug="neon-amber",
@@ -442,6 +463,7 @@ def neon() -> list[TagColorGroup]:
name="Neon Amber",
primary="#251507",
secondary="#FA9A2C",
color_border=True,
)
neon_yellow = TagColorGroup(
slug="neon-yellow",
@@ -449,6 +471,7 @@ def neon() -> list[TagColorGroup]:
name="Neon Yellow",
primary="#2B1C0B",
secondary="#FFD63D",
color_border=True,
)
neon_lime = TagColorGroup(
slug="neon-lime",
@@ -456,6 +479,7 @@ def neon() -> list[TagColorGroup]:
name="Neon Lime",
primary="#1B220C",
secondary="#92E649",
color_border=True,
)
neon_green = TagColorGroup(
slug="neon-green",
@@ -463,6 +487,7 @@ def neon() -> list[TagColorGroup]:
name="Neon Green",
primary="#091610",
secondary="#45D649",
color_border=True,
)
neon_teal = TagColorGroup(
slug="neon-teal",
@@ -470,6 +495,7 @@ def neon() -> list[TagColorGroup]:
name="Neon Teal",
primary="#09191D",
secondary="#22D589",
color_border=True,
)
neon_cyan = TagColorGroup(
slug="neon-cyan",
@@ -477,6 +503,7 @@ def neon() -> list[TagColorGroup]:
name="Neon Cyan",
primary="#0B191C",
secondary="#3DDBDB",
color_border=True,
)
neon_blue = TagColorGroup(
slug="neon-blue",
@@ -484,6 +511,7 @@ def neon() -> list[TagColorGroup]:
name="Neon Blue",
primary="#09101C",
secondary="#3B87F0",
color_border=True,
)
neon_indigo = TagColorGroup(
slug="neon-indigo",
@@ -491,6 +519,7 @@ def neon() -> list[TagColorGroup]:
name="Neon Indigo",
primary="#150B24",
secondary="#874FF5",
color_border=True,
)
neon_purple = TagColorGroup(
slug="neon-purple",
@@ -498,6 +527,7 @@ def neon() -> list[TagColorGroup]:
name="Neon Purple",
primary="#1E0B26",
secondary="#BB4FF0",
color_border=True,
)
neon_magenta = TagColorGroup(
slug="neon-magenta",
@@ -505,6 +535,7 @@ def neon() -> list[TagColorGroup]:
name="Neon Magenta",
primary="#220A13",
secondary="#F64680",
color_border=True,
)
neon_pink = TagColorGroup(
slug="neon-pink",
@@ -512,6 +543,7 @@ def neon() -> list[TagColorGroup]:
name="Neon Pink",
primary="#210E15",
secondary="#FF62AF",
color_border=True,
)
neon_white = TagColorGroup(
slug="neon-white",
@@ -519,6 +551,7 @@ def neon() -> list[TagColorGroup]:
name="Neon White",
primary="#131315",
secondary="#F2F1F8",
color_border=True,
)
return [
neon_red,

View File

@@ -2,11 +2,14 @@ import enum
from dataclasses import dataclass, replace
from pathlib import Path
import structlog
from src.core.query_lang import AST as Query # noqa: N811
from src.core.query_lang import Constraint, ConstraintType, Parser
MAX_SQL_VARIABLES = 32766 # 32766 is the max sql bind parameter count as defined here: https://github.com/sqlite/sqlite/blob/master/src/sqliteLimit.h#L140
logger = structlog.get_logger(__name__)
class TagColorEnum(enum.IntEnum):
DEFAULT = 1

View File

@@ -113,7 +113,7 @@ class _FieldID(Enum):
AUTHOR = DefaultField(id=1, name="Author", type=FieldTypeEnum.TEXT_LINE)
ARTIST = DefaultField(id=2, name="Artist", type=FieldTypeEnum.TEXT_LINE)
URL = DefaultField(id=3, name="URL", type=FieldTypeEnum.TEXT_LINE)
DESCRIPTION = DefaultField(id=4, name="Description", type=FieldTypeEnum.TEXT_LINE)
DESCRIPTION = DefaultField(id=4, name="Description", type=FieldTypeEnum.TEXT_BOX)
NOTES = DefaultField(id=5, name="Notes", type=FieldTypeEnum.TEXT_BOX)
COLLATION = DefaultField(id=9, name="Collation", type=FieldTypeEnum.TEXT_LINE)
DATE = DefaultField(id=10, name="Date", type=FieldTypeEnum.DATETIME)

View File

@@ -11,6 +11,7 @@ from dataclasses import dataclass
from datetime import UTC, datetime
from os import makedirs
from pathlib import Path
from typing import TYPE_CHECKING
from uuid import uuid4
from warnings import catch_warnings
@@ -31,6 +32,7 @@ from sqlalchemy import (
func,
or_,
select,
text,
update,
)
from sqlalchemy.exc import IntegrityError
@@ -47,6 +49,7 @@ from src.qt.translations import Translations
from ...constants import (
BACKUP_FOLDER_NAME,
LEGACY_TAG_FIELD_IDS,
RESERVED_NAMESPACE_PREFIX,
RESERVED_TAG_END,
RESERVED_TAG_START,
TAG_ARCHIVED,
@@ -68,10 +71,35 @@ from .joins import TagEntry, TagParent
from .models import Entry, Folder, Namespace, Preferences, Tag, TagAlias, TagColorGroup, ValueType
from .visitors import SQLBoolExpressionBuilder
if TYPE_CHECKING:
from sqlalchemy import Select
logger = structlog.get_logger(__name__)
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
SELECT tp.parent_id AS child_id
FROM tag_parents tp
INNER JOIN ChildTags c ON tp.child_id = c.child_id
)
SELECT * FROM ChildTags;
""") # noqa: E501
def slugify(input_string: str) -> str:
class ReservedNamespaceError(Exception):
"""Raise during an unauthorized attempt to create or modify a reserved namespace value.
Reserved namespace prefix: "tagstudio".
"""
pass
def slugify(input_string: str, allow_reserved: bool = False) -> str:
# Convert to lowercase and normalize unicode characters
slug = unicodedata.normalize("NFKD", input_string.lower())
@@ -81,6 +109,9 @@ def slugify(input_string: str) -> str:
# Replace spaces with hyphens
slug = re.sub(r"[-\s]+", "-", slug)
if not allow_reserved and slug.startswith(RESERVED_NAMESPACE_PREFIX):
raise ReservedNamespaceError
return slug
@@ -153,6 +184,7 @@ class LibraryStatus:
success: bool
library_path: Path | None = None
message: str | None = None
msg_description: str | None = None
json_migration_req: bool = False
@@ -161,7 +193,7 @@ class Library:
library_dir: Path | None = None
storage_path: Path | str | None
engine: Engine | None
engine: Engine | None = None
folder: Folder | None
included_files: set[Path] = set()
@@ -246,7 +278,7 @@ class Library:
for k, v in field.items():
# Old tag fields get added as tags
if k in LEGACY_TAG_FIELD_IDS:
self.add_tags_to_entry(entry_id=entry.id + 1, tag_ids=v)
self.add_tags_to_entries(entry_ids=entry.id + 1, tag_ids=v)
else:
self.add_field_to_entry(
entry_id=(entry.id + 1), # JSON IDs start at 0 instead of 1
@@ -318,6 +350,7 @@ class Library:
# https://docs.sqlalchemy.org/en/20/changelog/migration_07.html
# Under -> sqlite-the-sqlite-dialect-now-uses-nullpool-for-file-based-databases
poolclass = None if self.storage_path == ":memory:" else NullPool
db_version: int = 0
logger.info(
"[Library] Opening SQLite Library",
@@ -328,31 +361,29 @@ class Library:
with Session(self.engine) as session:
# dont check db version when creating new library
if not is_new:
db_version = session.scalar(
db_result = session.scalar(
select(Preferences).where(Preferences.key == LibraryPrefs.DB_VERSION.name)
)
if db_result:
db_version = db_result.value # type: ignore
if not db_version or db_version.value != LibraryPrefs.DB_VERSION.default:
mismatch_text = Translations.translate_formatted(
"status.library_version_mismatch"
)
found_text = Translations.translate_formatted("status.library_version_found")
expected_text = Translations.translate_formatted(
"status.library_version_expected"
)
# NOTE: DB_VERSION 6 is the first supported SQL DB version.
if db_version < 6 or db_version > LibraryPrefs.DB_VERSION.default:
mismatch_text = Translations["status.library_version_mismatch"]
found_text = Translations["status.library_version_found"]
expected_text = Translations["status.library_version_expected"]
return LibraryStatus(
success=False,
message=(
f"{mismatch_text}\n"
f"{found_text} v{0 if not db_version else db_version.value}, "
f"{found_text} v{db_version}, "
f"{expected_text} v{LibraryPrefs.DB_VERSION.default}"
),
)
logger.info(f"[Library] DB_VERSION: {db_version}")
make_tables(self.engine)
# TODO: Determine a good way of updating built-in data after updates.
# Add default tag color namespaces.
if is_new:
namespaces = default_color_groups.namespaces()
@@ -371,12 +402,13 @@ class Library:
tag_colors += default_color_groups.grayscale()
tag_colors += default_color_groups.earth_tones()
tag_colors += default_color_groups.neon()
try:
session.add_all(tag_colors)
session.commit()
except IntegrityError as e:
logger.error("[Library] Couldn't add default tag colors", error=e)
session.rollback()
if is_new:
try:
session.add_all(tag_colors)
session.commit()
except IntegrityError as e:
logger.error("[Library] Couldn't add default tag colors", error=e)
session.rollback()
# Add default tags.
if is_new:
@@ -421,14 +453,119 @@ class Library:
)
session.add(folder)
session.expunge(folder)
session.commit()
self.folder = folder
# Apply any post-SQL migration patches.
if not is_new:
if db_version < 8:
self.apply_db8_schema_changes(session)
if db_version == 6:
self.apply_repairs_for_db6(session)
if db_version >= 6 and db_version < 8:
self.apply_db8_default_data(session)
# Update DB_VERSION
if LibraryPrefs.DB_VERSION.default > db_version:
self.set_prefs(LibraryPrefs.DB_VERSION, LibraryPrefs.DB_VERSION.default)
# everything is fine, set the library path
self.library_dir = library_dir
return LibraryStatus(success=True, library_path=library_dir)
def apply_repairs_for_db6(self, session: Session):
"""Apply database repairs introduced in DB_VERSION 7."""
logger.info("[Library][Migration] Applying patches to DB_VERSION: 6 library...")
with session:
# Repair "Description" fields with a TEXT_LINE key instead of a TEXT_BOX key.
desc_stmd = (
update(ValueType)
.where(ValueType.key == _FieldID.DESCRIPTION.name)
.values(type=FieldTypeEnum.TEXT_BOX.name)
)
session.execute(desc_stmd)
session.flush()
# Repair tags that may have a disambiguation_id pointing towards a deleted tag.
all_tag_ids: set[int] = {tag.id for tag in self.tags}
disam_stmt = (
update(Tag)
.where(Tag.disambiguation_id.not_in(all_tag_ids))
.values(disambiguation_id=None)
)
session.execute(disam_stmt)
session.flush()
session.commit()
def apply_db8_schema_changes(self, session: Session):
"""Apply database schema changes introduced in DB_VERSION 8."""
# TODO: Use Alembic for this part instead
# Add the missing color_border column to the TagColorGroups table.
color_border_stmt = text(
"ALTER TABLE tag_colors ADD COLUMN color_border BOOLEAN DEFAULT FALSE NOT NULL"
)
try:
session.execute(color_border_stmt)
session.commit()
logger.info("[Library][Migration] Added color_border column to tag_colors table")
except Exception as e:
logger.error(
"[Library][Migration] Could not create color_border column in tag_colors table!",
error=e,
)
session.rollback()
def apply_db8_default_data(self, session: Session):
"""Apply default data changes introduced in DB_VERSION 8."""
tag_colors: list[TagColorGroup] = default_color_groups.standard()
tag_colors += default_color_groups.pastels()
tag_colors += default_color_groups.shades()
tag_colors += default_color_groups.grayscale()
tag_colors += default_color_groups.earth_tones()
# tag_colors += default_color_groups.neon() # NOTE: Neon is handled separately
# Add any new default colors introduced in DB_VERSION 8
for color in tag_colors:
try:
session.add(color)
logger.info(
"[Library][Migration] Migrated tag color to DB_VERSION 8+",
color_name=color.name,
)
session.commit()
except IntegrityError:
session.rollback()
# Update Neon colors to use the the color_border property
for color in default_color_groups.neon():
try:
neon_stmt = (
update(TagColorGroup)
.where(
and_(
TagColorGroup.namespace == color.namespace,
TagColorGroup.slug == color.slug,
)
)
.values(
slug=color.slug,
namespace=color.namespace,
name=color.name,
primary=color.primary,
secondary=color.secondary,
color_border=color.color_border,
)
)
session.execute(neon_stmt)
session.commit()
except IntegrityError as e:
logger.error(
"[Library] Could not migrate Neon colors to DB_VERSION 8+!",
error=e,
)
session.rollback()
@property
def default_fields(self) -> list[BaseField]:
with Session(self.engine) as session:
@@ -460,30 +597,49 @@ class Library:
self, entry_id: int, with_fields: bool = True, with_tags: bool = True
) -> Entry | None:
"""Load entry and join with all joins and all tags."""
# NOTE: TODO: Currently this method makes multiple separate queries to the db and combines
# those into a final Entry object (if using "with" args). This was done due to it being
# much more efficient than the existing join query, however there likely exists a single
# query that can accomplish the same task without exhibiting the same slowdown.
with Session(self.engine) as session:
statement = select(Entry).where(Entry.id == entry_id)
tags: set[Tag] | None = None
tag_stmt: Select[tuple[Tag]]
entry_stmt = select(Entry).where(Entry.id == entry_id).limit(1)
if with_fields:
statement = (
statement.outerjoin(Entry.text_fields)
entry_stmt = (
entry_stmt.outerjoin(Entry.text_fields)
.outerjoin(Entry.datetime_fields)
.options(selectinload(Entry.text_fields), selectinload(Entry.datetime_fields))
)
# if with_tags:
# entry_stmt = entry_stmt.outerjoin(Entry.tags).options(selectinload(Entry.tags))
if with_tags:
statement = (
statement.outerjoin(Entry.tags)
.outerjoin(TagAlias)
.options(
selectinload(Entry.tags).options(
joinedload(Tag.aliases),
joinedload(Tag.parent_tags),
)
tag_stmt = select(Tag).where(
and_(
TagEntry.tag_id == Tag.id,
TagEntry.entry_id == entry_id,
)
)
entry = session.scalar(statement)
start_time = time.time()
entry = session.scalar(entry_stmt)
if with_tags:
tags = set(session.scalars(tag_stmt)) # pyright: ignore [reportPossiblyUnboundVariable]
end_time = time.time()
logger.info(
f"[Library] Time it took to get entry: "
f"{format_timespan(end_time-start_time, max_units=5)}",
with_fields=with_fields,
with_tags=with_tags,
)
if not entry:
return None
session.expunge(entry)
make_transient(entry)
# Recombine the separately queried tags with the base entry object.
if with_tags and tags:
entry.tags = tags
return entry
def get_entries_full(self, entry_ids: list[int] | set[int]) -> Iterator[Entry]:
@@ -639,12 +795,15 @@ class Library:
with Session(self.engine) as session:
return session.query(exists().where(Entry.path == path)).scalar()
def get_paths(self, glob: str | None = None) -> list[str]:
def get_paths(self, glob: str | None = None, limit: int = -1) -> list[str]:
path_strings: list[str] = []
with Session(self.engine) as session:
paths = session.scalars(select(Entry.path)).unique()
path_strings: list[str] = list(map(lambda x: x.as_posix(), paths))
return path_strings
if limit > 0:
paths = session.scalars(select(Entry.path).limit(limit)).unique()
else:
paths = session.scalars(select(Entry.path)).unique()
path_strings = list(map(lambda x: x.as_posix(), paths))
return path_strings
def search_library(
self,
@@ -711,19 +870,16 @@ class Library:
return res
def search_tags(
self,
name: str | None,
) -> list[Tag]:
def search_tags(self, name: str | None, limit: int = 100) -> list[set[Tag]]:
"""Return a list of Tag records matching the query."""
tag_limit = 100
with Session(self.engine) as session:
query = select(Tag).outerjoin(TagAlias)
query = select(Tag).outerjoin(TagAlias).order_by(func.lower(Tag.name))
query = query.options(
selectinload(Tag.parent_tags),
selectinload(Tag.aliases),
).limit(tag_limit)
)
if limit > 0:
query = query.limit(limit)
if name:
query = query.where(
@@ -734,12 +890,28 @@ class Library:
)
)
tags = session.scalars(query)
res = list(set(tags))
direct_tags = set(session.scalars(query))
ancestor_tag_ids: list[Tag] = []
for tag in direct_tags:
ancestor_tag_ids.extend(
list(session.scalars(TAG_CHILDREN_QUERY, {"tag_id": tag.id}))
)
ancestor_tags = session.scalars(
select(Tag)
.where(Tag.id.in_(ancestor_tag_ids))
.options(selectinload(Tag.parent_tags), selectinload(Tag.aliases))
)
res = [
direct_tags,
{at for at in ancestor_tags if at not in direct_tags},
]
logger.info(
"searching tags",
search=name,
limit=limit,
statement=str(query),
results=len(res),
)
@@ -792,6 +964,14 @@ class Library:
session.delete(child_tag)
session.expunge(child_tag)
disam_stmt = (
update(Tag)
.where(Tag.disambiguation_id == tag.id)
.values(disambiguation_id=None)
)
session.execute(disam_stmt)
session.flush()
session.delete(tag)
session.commit()
session.expunge(tag)
@@ -987,6 +1167,79 @@ class Library:
session.commit()
return tags
def add_namespace(self, namespace: Namespace) -> bool:
"""Add a namespace value to the library.
Args:
namespace(str): The namespace slug. No special characters
"""
with Session(self.engine) as session:
if not namespace.namespace:
logger.warning("[LIBRARY][add_namespace] Namespace slug must not be empty")
return False
slug = namespace.namespace
try:
slug = slugify(namespace.namespace)
except ReservedNamespaceError:
logger.error(
f"[LIBRARY][add_namespace] Will not add a namespace with the reserved prefix:"
f"{RESERVED_NAMESPACE_PREFIX}",
namespace=namespace,
)
namespace_obj = Namespace(
namespace=slug,
name=namespace.name,
)
try:
session.add(namespace_obj)
session.commit()
return True
except IntegrityError:
session.rollback()
logger.error("IntegrityError")
return False
def delete_namespace(self, namespace: Namespace | str):
"""Delete a namespace and any connected data from the library."""
if isinstance(namespace, str):
if namespace.startswith(RESERVED_NAMESPACE_PREFIX):
raise ReservedNamespaceError
else:
if namespace.namespace.startswith(RESERVED_NAMESPACE_PREFIX):
raise ReservedNamespaceError
with Session(self.engine, expire_on_commit=False) as session:
try:
namespace_: Namespace | None = None
if isinstance(namespace, str):
namespace_ = session.scalar(
select(Namespace).where(Namespace.namespace == namespace)
)
else:
namespace_ = namespace
if not namespace_:
raise Exception
session.delete(namespace_)
session.flush()
colors = session.scalars(
select(TagColorGroup).where(TagColorGroup.namespace == namespace_.namespace)
)
for color in colors:
session.delete(color)
session.flush()
session.commit()
except IntegrityError as e:
logger.error(e)
session.rollback()
return None
def add_tag(
self,
tag: Tag,
@@ -1014,41 +1267,49 @@ class Library:
session.rollback()
return None
def add_tags_to_entry(self, entry_id: int, tag_ids: int | list[int] | set[int]) -> bool:
"""Add one or more tags to an entry."""
tag_ids = [tag_ids] if isinstance(tag_ids, int) else tag_ids
def add_tags_to_entries(
self, entry_ids: int | list[int], tag_ids: int | list[int] | set[int]
) -> bool:
"""Add one or more tags to one or more entries."""
entry_ids_ = [entry_ids] if isinstance(entry_ids, int) else entry_ids
tag_ids_ = [tag_ids] if isinstance(tag_ids, int) else tag_ids
with Session(self.engine, expire_on_commit=False) as session:
for tag_id in tag_ids:
try:
session.add(TagEntry(tag_id=tag_id, entry_id=entry_id))
session.flush()
except IntegrityError:
session.rollback()
for tag_id in tag_ids_:
for entry_id in entry_ids_:
try:
session.add(TagEntry(tag_id=tag_id, entry_id=entry_id))
session.flush()
except IntegrityError:
session.rollback()
try:
session.commit()
except IntegrityError as e:
logger.warning("[add_tags_to_entry]", warning=e)
logger.warning("[Library][add_tags_to_entries]", warning=e)
session.rollback()
return False
return True
def remove_tags_from_entry(self, entry_id: int, tag_ids: int | list[int] | set[int]) -> bool:
"""Remove one or more tags from an entry."""
def remove_tags_from_entries(
self, entry_ids: int | list[int], tag_ids: int | list[int] | set[int]
) -> bool:
"""Remove one or more tags from one or more entries."""
entry_ids_ = [entry_ids] if isinstance(entry_ids, int) else entry_ids
tag_ids_ = [tag_ids] if isinstance(tag_ids, int) else tag_ids
with Session(self.engine, expire_on_commit=False) as session:
try:
for tag_id in tag_ids_:
tag_entry = session.scalars(
select(TagEntry).where(
and_(
TagEntry.tag_id == tag_id,
TagEntry.entry_id == entry_id,
for entry_id in entry_ids_:
tag_entry = session.scalars(
select(TagEntry).where(
and_(
TagEntry.tag_id == tag_id,
TagEntry.entry_id == entry_id,
)
)
)
).first()
if tag_entry:
session.delete(tag_entry)
session.commit()
).first()
if tag_entry:
session.delete(tag_entry)
session.flush()
session.commit()
return True
except IntegrityError as e:
@@ -1056,6 +1317,33 @@ class Library:
session.rollback()
return False
def add_color(self, color_group: TagColorGroup) -> TagColorGroup | None:
with Session(self.engine, expire_on_commit=False) as session:
try:
session.add(color_group)
session.commit()
session.expunge(color_group)
return color_group
except IntegrityError as e:
logger.error(
"[Library] Could not add color, trying to update existing value instead.",
error=e,
)
session.rollback()
return None
def delete_color(self, color: TagColorGroup):
with Session(self.engine, expire_on_commit=False) as session:
try:
session.delete(color)
session.commit()
except IntegrityError as e:
logger.error(e)
session.rollback()
return None
def save_library_backup_to_disk(self) -> Path:
assert isinstance(self.library_dir, Path)
makedirs(str(self.library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME), exist_ok=True)
@@ -1171,6 +1459,55 @@ class Library:
"""Edit a Tag in the Library."""
self.add_tag(tag, parent_ids, alias_names, alias_ids)
def update_color(self, old_color_group: TagColorGroup, new_color_group: TagColorGroup) -> None:
"""Update a TagColorGroup in the Library. If it doesn't already exist, create it."""
with Session(self.engine) as session:
existing_color = session.scalar(
select(TagColorGroup).where(
and_(
TagColorGroup.namespace == old_color_group.namespace,
TagColorGroup.slug == old_color_group.slug,
)
)
)
if existing_color:
update_color_stmt = (
update(TagColorGroup)
.where(
and_(
TagColorGroup.namespace == old_color_group.namespace,
TagColorGroup.slug == old_color_group.slug,
)
)
.values(
slug=new_color_group.slug,
namespace=new_color_group.namespace,
name=new_color_group.name,
primary=new_color_group.primary,
secondary=new_color_group.secondary,
color_border=new_color_group.color_border,
)
)
session.execute(update_color_stmt)
session.flush()
update_tags_stmt = (
update(Tag)
.where(
and_(
Tag.color_namespace == old_color_group.namespace,
Tag.color_slug == old_color_group.slug,
)
)
.values(
color_namespace=new_color_group.namespace,
color_slug=new_color_group.slug,
)
)
session.execute(update_tags_stmt)
session.commit()
else:
self.add_color(new_color_group)
def update_aliases(self, tag, alias_ids, alias_names, session):
prev_aliases = session.scalars(select(TagAlias).where(TagAlias.tag_id == tag.id)).all()
@@ -1256,7 +1593,7 @@ class Library:
value=field.value,
)
tag_ids = [tag.id for tag in from_entry.tags]
self.add_tags_to_entry(into_entry.id, tag_ids)
self.add_tags_to_entries(into_entry.id, tag_ids)
self.remove_entries([from_entry.id])
@property
@@ -1270,7 +1607,28 @@ class Library:
color_groups[color.namespace] = []
color_groups[color.namespace].append(color)
session.expunge(color)
return color_groups
# Add empty namespaces that are available for use.
empty_namespaces = session.scalars(
select(Namespace)
.where(Namespace.namespace.not_in(color_groups.keys()))
.order_by(asc(Namespace.namespace))
)
for en in empty_namespaces:
if not color_groups.get(en.namespace):
color_groups[en.namespace] = []
session.expunge(en)
return dict(
sorted(color_groups.items(), key=lambda kv: self.get_namespace_name(kv[0]).lower())
)
@property
def namespaces(self) -> list[Namespace]:
"""Return every Namespace in the library."""
with Session(self.engine) as session:
namespaces = session.scalars(select(Namespace).order_by(asc(Namespace.name)))
return list(namespaces)
def get_namespace_name(self, namespace: str) -> str:
with Session(self.engine) as session:

View File

@@ -2,7 +2,7 @@
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import datetime as dt
from datetime import datetime as dt
from pathlib import Path
from sqlalchemy import JSON, ForeignKey, ForeignKeyConstraint, Integer, event
@@ -63,6 +63,7 @@ class TagColorGroup(Base):
name: Mapped[str] = mapped_column()
primary: Mapped[str] = mapped_column(nullable=False)
secondary: Mapped[str | None]
color_border: Mapped[bool] = mapped_column(nullable=False, default=False)
# TODO: Determine if slug and namespace can be optional and generated/added here if needed.
def __init__(
@@ -72,6 +73,7 @@ class TagColorGroup(Base):
name: str,
primary: str,
secondary: str | None = None,
color_border: bool = False,
):
self.slug = slug
self.namespace = namespace
@@ -79,6 +81,7 @@ class TagColorGroup(Base):
self.primary = primary
if secondary:
self.secondary = secondary
self.color_border = color_border
super().__init__()
@@ -185,9 +188,9 @@ class Entry(Base):
path: Mapped[Path] = mapped_column(PathType, unique=True)
suffix: Mapped[str] = mapped_column()
date_created: Mapped[dt.datetime | None]
date_modified: Mapped[dt.datetime | None]
date_added: Mapped[dt.datetime | None]
date_created: Mapped[dt | None]
date_modified: Mapped[dt | None]
date_added: Mapped[dt | None]
tags: Mapped[set[Tag]] = relationship(secondary="tag_entries")
@@ -222,9 +225,9 @@ class Entry(Base):
folder: Folder,
fields: list[BaseField],
id: int | None = None,
date_created: dt.datetime | None = None,
date_modified: dt.datetime | None = None,
date_added: dt.datetime | None = None,
date_created: dt | None = None,
date_modified: dt | None = None,
date_added: dt | None = None,
) -> None:
self.path = path
self.folder = folder

View File

@@ -2,11 +2,13 @@
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import re
from typing import TYPE_CHECKING
import structlog
from sqlalchemy import ColumnElement, and_, distinct, func, or_, select, text
from sqlalchemy.orm import Session
from sqlalchemy.sql.operators import ilike_op
from src.core.media_types import FILETYPE_EQUIVALENTS, MediaCategories
from src.core.query_lang import BaseVisitor
from src.core.query_lang.ast import ANDList, Constraint, ConstraintType, Not, ORList, Property
@@ -14,7 +16,7 @@ from src.core.query_lang.ast import ANDList, Constraint, ConstraintType, Not, OR
from .joins import TagEntry
from .models import Entry, Tag, TagAlias
# workaround to have autocompletion in the Editor
# Only import for type checking/autocompletion, will not be imported at runtime.
if TYPE_CHECKING:
from .library import Library
else:
@@ -23,7 +25,7 @@ else:
logger = structlog.get_logger(__name__)
# TODO: Reevaluate after subtags -> parent tags name change
CHILDREN_QUERY = text("""
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
@@ -97,7 +99,29 @@ class SQLBoolExpressionBuilder(BaseVisitor[ColumnElement[bool]]):
elif node.type == ConstraintType.TagID:
return self.__entry_matches_tag_ids([int(node.value)])
elif node.type == ConstraintType.Path:
return Entry.path.op("GLOB")(node.value)
ilike = False
glob = False
# Smartcase check
if node.value == node.value.lower():
ilike = True
if node.value.startswith("*") or node.value.endswith("*"):
glob = True
if ilike and glob:
logger.info("ConstraintType.Path", ilike=True, glob=True)
return func.lower(Entry.path).op("GLOB")(f"{node.value.lower()}")
elif ilike:
logger.info("ConstraintType.Path", ilike=True, glob=False)
return ilike_op(Entry.path, f"%{node.value}%")
elif glob:
logger.info("ConstraintType.Path", ilike=False, glob=True)
return Entry.path.op("GLOB")(node.value)
else:
logger.info(
"ConstraintType.Path", ilike=False, glob=False, re=re.escape(node.value)
)
return Entry.path.regexp_match(re.escape(node.value))
elif node.type == ConstraintType.MediaType:
extensions: set[str] = set[str]()
for media_cat in MediaCategories.ALL_CATEGORIES:
@@ -151,7 +175,7 @@ class SQLBoolExpressionBuilder(BaseVisitor[ColumnElement[bool]]):
return tag_ids
outp = []
for tag_id in tag_ids:
outp.extend(list(session.scalars(CHILDREN_QUERY, {"tag_id": tag_id})))
outp.extend(list(session.scalars(TAG_CHILDREN_ID_QUERY, {"tag_id": tag_id})))
return outp
def __entry_has_all_tags(self, tag_ids: list[int]) -> ColumnElement[bool]:

View File

@@ -93,22 +93,23 @@ class Tokenizer:
start = self.pos
while self.current_char not in self.NOT_IN_ULITERAL and self.current_char is not None:
while self.current_char is not None:
if self.current_char in self.NOT_IN_ULITERAL:
if self.current_char == ":":
if len(out) == 0:
raise ParsingError(self.pos, self.pos)
constraint_type = ConstraintType.from_string(out)
if constraint_type is not None:
self.__advance()
return Token(TokenType.CONSTRAINTTYPE, constraint_type, start, self.pos)
else:
break
out += self.current_char
self.__advance()
end = self.pos - 1
if self.current_char == ":":
if len(out) == 0:
raise ParsingError(self.pos, self.pos)
self.__advance()
constraint_type = ConstraintType.from_string(out)
if constraint_type is None:
raise ParsingError(start, end, f'Invalid ContraintType "{out}"')
return Token(TokenType.CONSTRAINTTYPE, constraint_type, start, end)
else:
return Token(TokenType.ULITERAL, out, start, end)
return Token(TokenType.ULITERAL, out, start, end)
def __quoted_string(self) -> Token:
start = self.pos

View File

@@ -1,6 +1,6 @@
import datetime as dt
from collections.abc import Iterator
from dataclasses import dataclass, field
from datetime import datetime as dt
from pathlib import Path
from time import time
@@ -42,7 +42,7 @@ class RefreshDirTracker:
path=entry_path,
folder=self.library.folder,
fields=[],
date_added=dt.datetime.now(),
date_added=dt.now(),
)
for entry_path in self.files_not_in_library
]

View File

@@ -0,0 +1,8 @@
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
def escape_text(text: str):
"""Escapes characters that are problematic in Qt widgets."""
return text.replace("&", "&&")

View File

@@ -0,0 +1,30 @@
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import logging
from pathlib import Path
from send2trash import send2trash
logging.basicConfig(format="%(message)s", level=logging.INFO)
def delete_file(path: str | Path) -> bool:
"""Send a file to the system trash.
Args:
path (str | Path): The path of the file to delete.
"""
_path = Path(path)
try:
logging.info(f"[delete_file] Sending to Trash: {_path}")
send2trash(_path)
return True
except PermissionError as e:
logging.error(f"[delete_file][ERROR] PermissionError: {e}")
except FileNotFoundError:
logging.error(f"[delete_file][ERROR] File Not Found: {_path}")
except Exception as e:
logging.error(e)
return False

View File

@@ -74,7 +74,7 @@ class Ui_MainWindow(QMainWindow):
# Thumbnail Size placeholder
self.thumb_size_combobox = QComboBox(self.centralwidget)
self.thumb_size_combobox.setObjectName(u"thumbSizeComboBox")
Translations.translate_with_setter(self.thumb_size_combobox.setPlaceholderText, "home.thumbnail_size")
self.thumb_size_combobox.setPlaceholderText(Translations["home.thumbnail_size"])
self.thumb_size_combobox.setCurrentText("")
sizePolicy = QSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed)
sizePolicy.setHorizontalStretch(0)
@@ -142,7 +142,7 @@ class Ui_MainWindow(QMainWindow):
self.horizontalLayout_2.addWidget(self.forwardButton)
self.searchField = QLineEdit(self.centralwidget)
Translations.translate_with_setter(self.searchField.setPlaceholderText, "home.search_entries")
self.searchField.setPlaceholderText(Translations["home.search_entries"])
self.searchField.setObjectName(u"searchField")
self.searchField.setMinimumSize(QSize(0, 32))
@@ -152,8 +152,7 @@ class Ui_MainWindow(QMainWindow):
self.searchField.setCompleter(self.searchFieldCompleter)
self.horizontalLayout_2.addWidget(self.searchField)
self.searchButton = QPushButton(self.centralwidget)
Translations.translate_qobject(self.searchButton, "home.search")
self.searchButton = QPushButton(Translations["home.search"], self.centralwidget)
self.searchButton.setObjectName(u"searchButton")
self.searchButton.setMinimumSize(QSize(0, 32))

View File

@@ -6,14 +6,9 @@
from PIL import ImageQt
from PySide6.QtCore import Qt
from PySide6.QtGui import QPixmap
from PySide6.QtWidgets import (
QHBoxLayout,
QLabel,
QPushButton,
QVBoxLayout,
QWidget,
)
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
from src.core.constants import VERSION, VERSION_BRANCH
from src.core.palette import ColorType, UiColor, get_ui_color
from src.qt.modals.ffmpeg_checker import FfmpegChecker
from src.qt.resource_manager import ResourceManager
from src.qt.translations import Translations
@@ -22,15 +17,17 @@ from src.qt.translations import Translations
class AboutModal(QWidget):
def __init__(self, config_path):
super().__init__()
Translations.translate_with_setter(self.setWindowTitle, "about.title")
self.setWindowTitle(Translations["about.title"])
self.fc: FfmpegChecker = FfmpegChecker()
self.rm: ResourceManager = ResourceManager()
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setMinimumSize(400, 500)
self.setMinimumSize(360, 480)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(20, 20, 20, 6)
self.root_layout.setContentsMargins(24, 24, 24, 6)
self.root_layout.setSpacing(12)
self.root_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.logo_widget = QLabel()
self.logo_widget.setObjectName("logo")
@@ -40,40 +37,45 @@ class AboutModal(QWidget):
)
self.logo_widget.setPixmap(self.logo_pixmap)
self.logo_widget.setAlignment(Qt.AlignmentFlag.AlignHCenter)
self.logo_widget.setContentsMargins(0, 0, 0, 20)
self.logo_widget.setContentsMargins(0, 0, 0, 24)
self.content_widget = QLabel()
self.content_widget.setObjectName("contentLabel")
self.content_widget.setWordWrap(True)
ff_version = self.fc.version()
ffmpeg = '<span style="color:red">Missing</span>'
red = get_ui_color(ColorType.PRIMARY, UiColor.RED)
green = get_ui_color(ColorType.PRIMARY, UiColor.GREEN)
ffmpeg = f'<span style="color:{red}">Missing</span>'
if ff_version["ffmpeg"] is not None:
ffmpeg = '<span style="color:green">Found</span> (' + ff_version["ffmpeg"] + ")"
ffprobe = '<span style="color:red">Missing</span>'
ffmpeg = f'<span style="color:{green}">Found</span> (' + ff_version["ffmpeg"] + ")"
ffprobe = f'<span style="color:{red}">Missing</span>'
if ff_version["ffprobe"] is not None:
ffprobe = '<span style="color:green">Found</span> (' + ff_version["ffprobe"] + ")"
Translations.translate_qobject(
self.content_widget,
"about.content",
version=VERSION,
branch=VERSION_BRANCH,
config_path=config_path,
ffmpeg=ffmpeg,
ffprobe=ffprobe,
ffprobe = f'<span style="color:{green}">Found</span> (' + ff_version["ffprobe"] + ")"
branch: str = (" (" + VERSION_BRANCH + ")") if VERSION_BRANCH else ""
self.title_label = QLabel(f"<h2>TagStudio Alpha {VERSION}{branch}</h2>")
self.title_label.setAlignment(Qt.AlignmentFlag.AlignHCenter)
self.content_label = QLabel(
Translations.format(
"about.content", config_path=config_path, ffmpeg=ffmpeg, ffprobe=ffprobe
)
)
self.content_widget.setAlignment(Qt.AlignmentFlag.AlignHCenter)
self.content_label.setObjectName("contentLabel")
self.content_label.setWordWrap(True)
self.content_label.setOpenExternalLinks(True)
self.content_label.setAlignment(Qt.AlignmentFlag.AlignHCenter)
self.button_widget = QWidget()
self.button_layout = QHBoxLayout(self.button_widget)
self.button_layout.addStretch(1)
self.close_button = QPushButton()
Translations.translate_qobject(self.close_button, "generic.close")
self.close_button = QPushButton(Translations["generic.close"])
self.close_button.clicked.connect(lambda: self.close())
self.close_button.setMaximumWidth(80)
self.button_layout.addWidget(self.close_button)
self.root_layout.addWidget(self.logo_widget)
self.root_layout.addWidget(self.content_widget, Qt.AlignmentFlag.AlignTop)
self.root_layout.addWidget(self.title_label)
self.root_layout.addWidget(self.content_label)
self.root_layout.addStretch(1)
self.root_layout.addWidget(self.button_widget)

View File

@@ -1,8 +1,10 @@
# 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 structlog
from PySide6 import QtCore, QtGui
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QHBoxLayout,
@@ -16,7 +18,10 @@ from PySide6.QtWidgets import (
from src.core.library import Library
from src.qt.translations import Translations
logger = structlog.get_logger(__name__)
# NOTE: This class doesn't inherit from PanelWidget? Seems like it predates that system?
class AddFieldModal(QWidget):
done = Signal(list)
@@ -26,21 +31,16 @@ class AddFieldModal(QWidget):
# [Cancel] [Save]
super().__init__()
self.lib = library
Translations.translate_with_setter(self.setWindowTitle, "library.field.add")
self.setWindowTitle(Translations["library.field.add"])
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setMinimumSize(400, 300)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 6, 6, 6)
self.title_widget = QLabel()
self.title_widget = QLabel(Translations["library.field.add"])
self.title_widget.setObjectName("fieldTitle")
self.title_widget.setWordWrap(True)
self.title_widget.setStyleSheet(
# 'background:blue;'
# 'text-align:center;'
"font-weight:bold;" "font-size:14px;" "padding-top: 6px" ""
)
Translations.translate_qobject(self.title_widget, "library.field.add")
self.title_widget.setStyleSheet("font-weight:bold;" "font-size:14px;" "padding-top: 6px;")
self.title_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.list_widget = QListWidget()
@@ -50,18 +50,11 @@ class AddFieldModal(QWidget):
self.button_layout.setContentsMargins(6, 6, 6, 6)
self.button_layout.addStretch(1)
# self.cancel_button = QPushButton()
# self.cancel_button.setText('Cancel')
self.cancel_button = QPushButton()
Translations.translate_qobject(self.cancel_button, "generic.cancel")
self.cancel_button = QPushButton(Translations["generic.cancel"])
self.cancel_button.clicked.connect(self.hide)
# self.cancel_button.clicked.connect(widget.reset)
self.button_layout.addWidget(self.cancel_button)
self.save_button = QPushButton()
Translations.translate_qobject(self.save_button, "generic.add")
# self.save_button.setAutoDefault(True)
self.save_button = QPushButton(Translations["generic.add"])
self.save_button.setDefault(True)
self.save_button.clicked.connect(self.hide)
self.save_button.clicked.connect(
@@ -74,8 +67,6 @@ class AddFieldModal(QWidget):
self.root_layout.addWidget(self.title_widget)
self.root_layout.addWidget(self.list_widget)
# self.root_layout.setStretch(1,2)
self.root_layout.addStretch(1)
self.root_layout.addWidget(self.button_container)
@@ -85,5 +76,13 @@ class AddFieldModal(QWidget):
item = QListWidgetItem(f"{df.name} ({df.type.value})")
item.setData(Qt.ItemDataRole.UserRole, df.key)
self.list_widget.addItem(item)
self.list_widget.setFocus()
super().show()
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
if event.key() == QtCore.Qt.Key.Key_Escape:
self.cancel_button.click()
else: # Other key presses
pass
return super().keyPressEvent(event)

View File

@@ -0,0 +1,406 @@
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import contextlib
import structlog
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QColor
from PySide6.QtWidgets import (
QCheckBox,
QColorDialog,
QFormLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QVBoxLayout,
QWidget,
)
from src.core import palette
from src.core.library import Library
from src.core.library.alchemy.enums import TagColorEnum
from src.core.library.alchemy.library import slugify
from src.core.library.alchemy.models import TagColorGroup
from src.core.palette import ColorType, UiColor, get_tag_color, get_ui_color
from src.qt.translations import Translations
from src.qt.widgets.panel import PanelWidget
from src.qt.widgets.tag import (
get_border_color,
get_highlight_color,
get_text_color,
)
from src.qt.widgets.tag_color_preview import TagColorPreview
logger = structlog.get_logger(__name__)
class BuildColorPanel(PanelWidget):
on_edit = Signal(TagColorGroup)
def __init__(self, library: Library, color_group: TagColorGroup):
super().__init__()
self.lib = library
self.color_group: TagColorGroup = color_group
self.tag_color_namespace: str | None
self.tag_color_slug: str | None
self.disambiguation_id: int | None
self.known_colors: set[str]
self.update_known_colors()
self.setMinimumSize(340, 240)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 0, 6, 0)
self.root_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.form_container = QWidget()
self.form_layout = QFormLayout(self.form_container)
self.form_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
self.form_layout.setFormAlignment(Qt.AlignmentFlag.AlignLeft)
# Preview Tag ----------------------------------------------------------
self.preview_widget = QWidget()
self.preview_layout = QVBoxLayout(self.preview_widget)
self.preview_layout.setStretch(1, 1)
self.preview_layout.setContentsMargins(0, 0, 0, 6)
self.preview_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.preview_button = TagColorPreview(self.lib, None)
self.preview_button.setEnabled(False)
self.preview_layout.addWidget(self.preview_button)
# Name -----------------------------------------------------------------
self.name_title = QLabel(Translations["library_object.name"])
self.name_field = QLineEdit()
self.name_field.setFixedHeight(24)
self.name_field.textChanged.connect(self.on_text_changed)
self.name_field.setPlaceholderText(Translations["library_object.name_required"])
self.form_layout.addRow(self.name_title, self.name_field)
# Slug -----------------------------------------------------------------
self.slug_title = QLabel(Translations["library_object.slug"])
self.slug_field = QLineEdit()
self.slug_field.setEnabled(False)
self.slug_field.setFixedHeight(24)
self.slug_field.setPlaceholderText(Translations["library_object.slug_required"])
self.form_layout.addRow(self.slug_title, self.slug_field)
# Primary --------------------------------------------------------------
self.primary_title = QLabel(Translations["color.primary"])
self.primary_button = QPushButton()
self.primary_button.setMinimumSize(44, 22)
self.primary_button.setMaximumHeight(22)
self.edit_primary_modal = QColorDialog()
self.primary_button.clicked.connect(self.primary_color_callback)
self.form_layout.addRow(self.primary_title, self.primary_button)
# Secondary ------------------------------------------------------------
self.secondary_widget = QWidget()
self.secondary_layout = QHBoxLayout(self.secondary_widget)
self.secondary_layout.setContentsMargins(0, 0, 0, 0)
self.secondary_layout.setSpacing(6)
self.secondary_title = QLabel(Translations["color.secondary"])
self.secondary_button = QPushButton()
self.secondary_button.setMinimumSize(44, 22)
self.secondary_button.setMaximumHeight(22)
self.edit_secondary_modal = QColorDialog()
self.secondary_button.clicked.connect(self.secondary_color_callback)
self.secondary_layout.addWidget(self.secondary_button)
self.secondary_reset_button = QPushButton(Translations["generic.reset"])
self.secondary_reset_button.clicked.connect(self.update_secondary)
self.secondary_layout.addWidget(self.secondary_reset_button)
self.secondary_layout.setStretch(0, 3)
self.secondary_layout.setStretch(1, 1)
self.form_layout.addRow(self.secondary_title, self.secondary_widget)
# Color Border ---------------------------------------------------------
self.border_widget = QWidget()
self.border_layout = QHBoxLayout(self.border_widget)
self.border_layout.setStretch(1, 1)
self.border_layout.setContentsMargins(0, 0, 0, 0)
self.border_layout.setSpacing(6)
self.border_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.border_checkbox = QCheckBox()
self.border_checkbox.setFixedSize(22, 22)
self.border_checkbox.clicked.connect(
lambda checked: self.update_secondary(
color=QColor(self.preview_button.tag_color_group.secondary)
if self.preview_button.tag_color_group.secondary
else None,
color_border=checked,
)
)
self.border_layout.addWidget(self.border_checkbox)
self.border_label = QLabel(Translations["color.color_border"])
self.border_layout.addWidget(self.border_label)
primary_color = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT))
border_color = get_border_color(primary_color)
highlight_color = get_highlight_color(primary_color)
text_color: QColor = get_text_color(primary_color, highlight_color)
self.border_checkbox.setStyleSheet(
f"QCheckBox{{"
f"background: rgba{primary_color.toTuple()};"
f"color: rgba{text_color.toTuple()};"
f"border-color: rgba{border_color.toTuple()};"
f"border-radius: 6px;"
f"border-style:solid;"
f"border-width: 2px;"
f"}}"
f"QCheckBox::indicator{{"
f"width: 10px;"
f"height: 10px;"
f"border-radius: 2px;"
f"margin: 4px;"
f"}}"
f"QCheckBox::indicator:checked{{"
f"background: rgba{text_color.toTuple()};"
f"}}"
f"QCheckBox::hover{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"}}"
f"QCheckBox::focus{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"outline:none;"
f"}}"
)
# Add Widgets to Layout ================================================
self.root_layout.addWidget(self.preview_widget)
self.root_layout.addWidget(self.form_container)
self.root_layout.addWidget(self.border_widget)
self.set_color(color_group or TagColorGroup("", "", Translations["color.new"], ""))
self.update_primary(QColor(color_group.primary))
self.update_secondary(None if not color_group.secondary else QColor(color_group.secondary))
self.on_text_changed()
def set_color(self, color_group: TagColorGroup):
logger.info("[BuildColorPanel] Setting Color", color=color_group)
self.color_group = color_group
self.preview_button.set_tag_color_group(color_group)
self.name_field.setText(color_group.name)
self.primary_button.setText(color_group.primary)
self.edit_primary_modal.setCurrentColor(color_group.primary)
self.secondary_button.setText(
Translations["color.title.no_color"]
if not color_group.secondary
else str(color_group.secondary)
)
self.edit_secondary_modal.setCurrentColor(color_group.secondary or QColor(0, 0, 0, 255))
self.border_checkbox.setChecked(color_group.color_border)
def primary_color_callback(self) -> None:
initial = (
self.primary_button.text()
if self.primary_button.text().startswith("#")
else self.color_group.primary
)
color = self.edit_primary_modal.getColor(initial=initial)
if color.isValid():
self.update_primary(color)
self.preview_button.set_tag_color_group(self.build_color()[1])
else:
logger.info("[BuildColorPanel] Primary color selection was cancelled!")
def secondary_color_callback(self) -> None:
initial = (
self.secondary_button.text()
if self.secondary_button.text().startswith("#")
else (self.color_group.secondary or QColor())
)
color = self.edit_secondary_modal.getColor(initial=initial)
if color.isValid():
self.update_secondary(color)
self.preview_button.set_tag_color_group(self.build_color()[1])
else:
logger.info("[BuildColorPanel] Secondary color selection was cancelled!")
def update_primary(self, color: QColor):
logger.info("[BuildColorPanel] Updating Primary", primary_color=color)
highlight_color = get_highlight_color(color)
text_color = get_text_color(color, highlight_color)
border_color = get_border_color(color)
hex_code = color.name().upper()
self.primary_button.setText(hex_code)
self.primary_button.setStyleSheet(
f"QPushButton{{"
f"background: rgba{color.toTuple()};"
f"color: rgba{text_color.toTuple()};"
f"font-weight: 600;"
f"border-color: rgba{border_color.toTuple()};"
f"border-radius: 6px;"
f"border-style:solid;"
f"border-width: 2px;"
f"padding-right: 4px;"
f"padding-bottom: 1px;"
f"padding-left: 4px;"
f"font-size: 13px"
f"}}"
f"QPushButton::hover{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"}}"
f"QPushButton::pressed{{"
f"background: rgba{highlight_color.toTuple()};"
f"color: rgba{color.toTuple()};"
f"border-color: rgba{color.toTuple()};"
f"}}"
f"QPushButton::focus{{"
f"padding-right: 0px;"
f"padding-left: 0px;"
f"outline-style: solid;"
f"outline-width: 1px;"
f"outline-radius: 4px;"
f"outline-color: rgba{text_color.toTuple()};"
f"}}"
)
self.preview_button.set_tag_color_group(self.build_color()[1])
def update_secondary(self, color: QColor | None = None, color_border: bool = False):
logger.info("[BuildColorPanel] Updating Secondary", color=color)
color_ = color or QColor(palette.get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT))
highlight_color = get_highlight_color(color_)
text_color = get_text_color(color_, highlight_color)
border_color = get_border_color(color_)
hex_code = "" if not color else color.name().upper()
self.secondary_button.setText(
Translations["color.title.no_color"] if not color else hex_code
)
self.secondary_button.setStyleSheet(
f"QPushButton{{"
f"background: rgba{color_.toTuple()};"
f"color: rgba{text_color.toTuple()};"
f"font-weight: 600;"
f"border-color: rgba{border_color.toTuple()};"
f"border-radius: 6px;"
f"border-style:solid;"
f"border-width: 2px;"
f"padding-right: 4px;"
f"padding-bottom: 1px;"
f"padding-left: 4px;"
f"font-size: 13px"
f"}}"
f"QPushButton::hover{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"}}"
f"QPushButton::pressed{{"
f"background: rgba{highlight_color.toTuple()};"
f"color: rgba{color_.toTuple()};"
f"border-color: rgba{color_.toTuple()};"
f"}}"
f"QPushButton::focus{{"
f"padding-right: 0px;"
f"padding-left: 0px;"
f"outline-style: solid;"
f"outline-width: 1px;"
f"outline-radius: 4px;"
f"outline-color: rgba{text_color.toTuple()};"
f"}}"
)
self.preview_button.set_tag_color_group(self.build_color()[1])
def update_known_colors(self):
groups = self.lib.tag_color_groups
colors = groups.get(self.color_group.namespace, [])
self.known_colors = {c.slug for c in colors}
with contextlib.suppress(KeyError):
self.known_colors.remove(self.color_group.slug)
def update_preview_text(self):
self.preview_button.button.setText(
f"{self.name_field.text().strip() or Translations["color.placeholder"]} "
f"({self.lib.get_namespace_name(self.color_group.namespace)})"
)
self.preview_button.button.setMaximumWidth(self.preview_button.button.sizeHint().width())
def no_collide(self, slug: str) -> str:
"""Return a slug name that's verified not to collide with other known color slugs."""
if slug and slug in self.known_colors:
split_slug: list[str] = slug.rsplit("-", 1)
suffix: str = ""
if len(split_slug) > 1:
suffix = split_slug[1]
if suffix:
try:
suffix_num: int = int(suffix)
return self.no_collide(f"{split_slug[0]}-{suffix_num+1}")
except ValueError:
return self.no_collide(f"{slug}-2")
else:
return self.no_collide(f"{slug}-2")
return slug
def on_text_changed(self):
slug = self.no_collide(slugify(self.name_field.text().strip(), allow_reserved=True))
is_name_empty = not self.name_field.text().strip()
is_slug_empty = not slug
is_invalid = False
self.name_field.setStyleSheet(
f"border: 1px solid {get_ui_color(ColorType.PRIMARY, UiColor.RED)}; border-radius: 2px"
if is_name_empty
else ""
)
self.slug_field.setStyleSheet(
f"border: 1px solid {get_ui_color(ColorType.PRIMARY, UiColor.RED)}; border-radius: 2px"
if is_slug_empty or is_invalid
else ""
)
self.slug_field.setText(slug)
self.update_preview_text()
if self.panel_save_button is not None:
self.panel_save_button.setDisabled(is_name_empty)
def build_color(self) -> tuple[TagColorGroup, TagColorGroup]:
name = self.name_field.text()
slug = self.slug_field.text()
primary: str = self.primary_button.text()
secondary: str | None = (
self.secondary_button.text() if self.secondary_button.text().startswith("#") else None
)
color_border: bool = self.border_checkbox.isChecked()
new_color = TagColorGroup(
slug=slug,
namespace=self.color_group.namespace,
name=name,
primary=primary,
secondary=secondary,
color_border=color_border,
)
logger.info(
"[BuildColorPanel] Built Color",
slug=new_color.slug,
namespace=new_color.namespace,
name=new_color.name,
primary=new_color.primary,
secondary=new_color.secondary,
color_border=new_color.color_border,
)
return (self.color_group, new_color)
def parent_post_init(self):
# self.setTabOrder(self.name_field, self.shorthand_field)
# self.setTabOrder(self.shorthand_field, self.aliases_add_button)
# self.setTabOrder(self.aliases_add_button, self.parent_tags_add_button)
# self.setTabOrder(self.parent_tags_add_button, self.color_button)
# self.setTabOrder(self.color_button, self.panel_cancel_button)
# self.setTabOrder(self.panel_cancel_button, self.panel_save_button)
# self.setTabOrder(self.panel_save_button, self.aliases_table.cellWidget(0, 1))
self.name_field.selectAll()
self.name_field.setFocus()

View File

@@ -0,0 +1,168 @@
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import contextlib
from uuid import uuid4
import structlog
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QLabel,
QLineEdit,
QVBoxLayout,
QWidget,
)
from src.core.constants import RESERVED_NAMESPACE_PREFIX
from src.core.library import Library
from src.core.library.alchemy.library import ReservedNamespaceError, slugify
from src.core.library.alchemy.models import Namespace
from src.core.palette import ColorType, UiColor, get_ui_color
from src.qt.translations import Translations
from src.qt.widgets.panel import PanelWidget
logger = structlog.get_logger(__name__)
class BuildNamespacePanel(PanelWidget):
on_edit = Signal(Namespace)
def __init__(self, library: Library, namespace: Namespace | None = None):
super().__init__()
self.lib = library
self.namespace: Namespace | None = namespace
self.known_namespaces: set[str]
self.update_known_namespaces()
self.setMinimumSize(360, 260)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 0, 6, 0)
self.root_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
# Name -----------------------------------------------------------------
self.name_widget = QWidget()
self.name_layout = QVBoxLayout(self.name_widget)
self.name_layout.setStretch(1, 1)
self.name_layout.setContentsMargins(0, 0, 0, 0)
self.name_layout.setSpacing(0)
self.name_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.name_title = QLabel(Translations["library_object.name"])
self.name_layout.addWidget(self.name_title)
self.name_field = QLineEdit()
self.name_field.setFixedHeight(24)
self.name_field.textChanged.connect(self.on_text_changed)
self.name_field.setPlaceholderText(Translations["library_object.name_required"])
self.name_layout.addWidget(self.name_field)
# Slug -----------------------------------------------------------------
self.slug_widget = QWidget()
self.slug_layout = QVBoxLayout(self.slug_widget)
self.slug_layout.setStretch(1, 1)
self.slug_layout.setContentsMargins(0, 0, 0, 0)
self.slug_layout.setSpacing(0)
self.slug_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.slug_title = QLabel(Translations["library_object.slug"])
self.slug_layout.addWidget(self.slug_title)
self.slug_field = QLineEdit()
self.slug_field.setFixedHeight(24)
self.slug_field.setEnabled(False)
self.slug_field.setPlaceholderText(Translations["library_object.slug_required"])
self.slug_layout.addWidget(self.slug_field)
# Description ----------------------------------------------------------
self.desc_label = QLabel(Translations["namespace.create.description"])
self.desc_label.setWordWrap(True)
self.desc_color_label = QLabel(Translations["namespace.create.description_color"])
self.desc_color_label.setWordWrap(True)
# Add Widgets to Layout ================================================
self.root_layout.addWidget(self.name_widget)
self.root_layout.addWidget(self.slug_widget)
self.root_layout.addSpacing(12)
self.root_layout.addWidget(self.desc_label)
self.root_layout.addSpacing(6)
self.root_layout.addWidget(self.desc_color_label)
self.set_namespace(namespace)
def set_namespace(self, namespace: Namespace | None):
logger.info("[BuildNamespacePanel] Setting Namespace", namespace=namespace)
self.namespace = namespace
if namespace:
self.name_field.setText(namespace.name)
self.slug_field.setText(namespace.namespace)
else:
self.name_field.setText("User Colors")
def update_known_namespaces(self):
namespaces = self.lib.namespaces
self.known_namespaces = {n.namespace for n in namespaces}
if self.namespace:
with contextlib.suppress(KeyError):
self.known_namespaces.remove(self.namespace.namespace)
def on_text_changed(self):
slug = ""
try:
slug = self.no_collide(slugify(self.name_field.text().strip()))
except ReservedNamespaceError:
raw_name = self.name_field.text().strip().lower()
raw_name = raw_name.replace(RESERVED_NAMESPACE_PREFIX, str(uuid4()).split("-", 1)[0])
slug = self.no_collide(slugify(raw_name))
is_name_empty = not self.name_field.text().strip()
is_slug_empty = not slug
is_invalid = False
self.name_field.setStyleSheet(
f"border: 1px solid {get_ui_color(ColorType.PRIMARY, UiColor.RED)}; border-radius: 2px"
if is_name_empty
else ""
)
self.slug_field.setStyleSheet(
f"border: 1px solid {get_ui_color(ColorType.PRIMARY, UiColor.RED)}; border-radius: 2px"
if is_slug_empty or is_invalid
else ""
)
self.slug_field.setText(slug)
if self.panel_save_button is not None:
self.panel_save_button.setDisabled(is_name_empty)
def no_collide(self, slug: str) -> str:
"""Return a slug name that's verified not to collide with other known namespace slugs."""
if slug and slug in self.known_namespaces:
split_slug: list[str] = slug.rsplit("-", 1)
suffix: str = ""
if len(split_slug) > 1:
suffix = split_slug[1]
if suffix:
try:
suffix_num: int = int(suffix)
return self.no_collide(f"{split_slug[0]}-{suffix_num+1}")
except ValueError:
return self.no_collide(f"{slug}-2")
else:
return self.no_collide(f"{slug}-2")
return slug
def build_namespace(self) -> Namespace:
name = self.name_field.text()
slug_raw = self.slug_field.text()
slug = slugify(slug_raw)
namespace = Namespace(namespace=slug, name=name)
logger.info("[BuildNamespacePanel] Built Namespace", slug=slug, name=name)
return namespace
def parent_post_init(self):
self.setTabOrder(self.name_field, self.slug_field)
self.name_field.selectAll()
self.name_field.setFocus()

View File

@@ -86,15 +86,12 @@ class BuildTagPanel(PanelWidget):
self.name_layout.setContentsMargins(0, 0, 0, 0)
self.name_layout.setSpacing(0)
self.name_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.name_title = QLabel()
Translations.translate_qobject(self.name_title, "tag.name")
self.name_title = QLabel(Translations["tag.name"])
self.name_layout.addWidget(self.name_title)
self.name_field = QLineEdit()
self.name_field.setFixedHeight(24)
self.name_field.textChanged.connect(self.on_name_changed)
Translations.translate_with_setter(
self.name_field.setPlaceholderText, "tag.tag_name_required"
)
self.name_field.setPlaceholderText(Translations["tag.tag_name_required"])
self.name_layout.addWidget(self.name_field)
# Shorthand ------------------------------------------------------------
@@ -104,8 +101,7 @@ class BuildTagPanel(PanelWidget):
self.shorthand_layout.setContentsMargins(0, 0, 0, 0)
self.shorthand_layout.setSpacing(0)
self.shorthand_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.shorthand_title = QLabel()
Translations.translate_qobject(self.shorthand_title, "tag.shorthand")
self.shorthand_title = QLabel(Translations["tag.shorthand"])
self.shorthand_layout.addWidget(self.shorthand_title)
self.shorthand_field = QLineEdit()
self.shorthand_layout.addWidget(self.shorthand_field)
@@ -117,8 +113,7 @@ class BuildTagPanel(PanelWidget):
self.aliases_layout.setContentsMargins(0, 0, 0, 0)
self.aliases_layout.setSpacing(0)
self.aliases_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.aliases_title = QLabel()
Translations.translate_qobject(self.aliases_title, "tag.aliases")
self.aliases_title = QLabel(Translations["tag.aliases"])
self.aliases_layout.addWidget(self.aliases_title)
self.aliases_table = QTableWidget(0, 2)
@@ -144,8 +139,7 @@ class BuildTagPanel(PanelWidget):
self.disam_button_group = QButtonGroup(self)
self.disam_button_group.setExclusive(False)
self.parent_tags_title = QLabel()
Translations.translate_qobject(self.parent_tags_title, "tag.parent_tags")
self.parent_tags_title = QLabel(Translations["tag.parent_tags"])
self.parent_tags_layout.addWidget(self.parent_tags_title)
self.scroll_contents = QWidget()
@@ -173,8 +167,8 @@ class BuildTagPanel(PanelWidget):
tsp = TagSearchPanel(self.lib, exclude_ids)
tsp.tag_chosen.connect(lambda x: self.add_parent_tag_callback(x))
self.add_tag_modal = PanelModal(tsp)
Translations.translate_with_setter(self.add_tag_modal.setTitle, "tag.parent_tags.add")
Translations.translate_with_setter(self.add_tag_modal.setWindowTitle, "tag.parent_tags.add")
self.add_tag_modal.setTitle(Translations["tag.parent_tags.add"])
self.add_tag_modal.setWindowTitle(Translations["tag.parent_tags.add"])
self.parent_tags_add_button.clicked.connect(self.add_tag_modal.show)
# Color ----------------------------------------------------------------
@@ -184,18 +178,17 @@ class BuildTagPanel(PanelWidget):
self.color_layout.setContentsMargins(0, 0, 0, 6)
self.color_layout.setSpacing(6)
self.color_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.color_title = QLabel()
Translations.translate_qobject(self.color_title, "tag.color")
self.color_title = QLabel(Translations["tag.color"])
self.color_layout.addWidget(self.color_title)
self.color_button: TagColorPreview
try:
self.color_button = TagColorPreview(tag.color)
self.color_button = TagColorPreview(self.lib, tag.color)
except Exception as e:
# TODO: Investigate why this happens during tests
logger.error("[BuildTag] Could not access Tag member attributes", error=e)
self.color_button = TagColorPreview(None)
self.color_button = TagColorPreview(self.lib, None)
self.tag_color_selection = TagColorSelection(self.lib)
chose_tag_color_title = Translations.translate_formatted("tag.choose_color")
chose_tag_color_title = Translations["tag.choose_color"]
self.choose_color_modal = PanelModal(
self.tag_color_selection,
chose_tag_color_title,
@@ -214,8 +207,7 @@ class BuildTagPanel(PanelWidget):
self.cat_layout.setContentsMargins(0, 0, 0, 0)
self.cat_layout.setSpacing(6)
self.cat_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.cat_title = QLabel()
self.cat_title.setText("Is Category")
self.cat_title = QLabel(Translations["tag.is_category"])
self.cat_checkbox = QCheckBox()
self.cat_checkbox.setFixedSize(22, 22)
@@ -245,6 +237,10 @@ class BuildTagPanel(PanelWidget):
f"QCheckBox::hover{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"}}"
f"QCheckBox::focus{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"outline:none;"
f"}}"
)
self.cat_layout.addWidget(self.cat_checkbox)
self.cat_layout.addWidget(self.cat_title)
@@ -372,7 +368,7 @@ class BuildTagPanel(PanelWidget):
primary_color = get_primary_color(tag)
border_color = (
get_border_color(primary_color)
if not (tag.color and tag.color.secondary)
if not (tag.color and tag.color.secondary and tag.color.color_border)
else (QColor(tag.color.secondary))
)
highlight_color = get_highlight_color(
@@ -386,11 +382,21 @@ class BuildTagPanel(PanelWidget):
else:
text_color = get_text_color(primary_color, highlight_color)
# Add Tag Widget
tag_widget = TagWidget(
tag,
library=self.lib,
has_edit=False,
has_remove=True,
)
tag_widget.on_remove.connect(lambda t=parent_id: self.remove_parent_tag_callback(t))
row.addWidget(tag_widget)
# Add Disambiguation Tag Button
disam_button = QRadioButton()
disam_button.setObjectName(f"disambiguationButton.{parent_id}")
disam_button.setFixedSize(22, 22)
disam_button.setToolTip(Translations.translate_formatted("tag.disambiguation.tooltip"))
disam_button.setToolTip(Translations["tag.disambiguation.tooltip"])
disam_button.setStyleSheet(
f"QRadioButton{{"
f"background: rgba{primary_color.toTuple()};"
@@ -412,6 +418,15 @@ class BuildTagPanel(PanelWidget):
f"QRadioButton::hover{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"}}"
f"QRadioButton::pressed{{"
f"background: rgba{border_color.toTuple()};"
f"color: rgba{primary_color.toTuple()};"
f"border-color: rgba{primary_color.toTuple()};"
f"}}"
f"QRadioButton::focus{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"outline:none;"
f"}}"
)
self.disam_button_group.addButton(disam_button)
@@ -421,18 +436,7 @@ class BuildTagPanel(PanelWidget):
disam_button.clicked.connect(lambda checked=False: self.toggle_disam_id(parent_id))
row.addWidget(disam_button)
# Add Tag Widget
tag_widget = TagWidget(
tag,
library=self.lib,
has_edit=False,
has_remove=True,
)
tag_widget.on_remove.connect(lambda t=parent_id: self.remove_parent_tag_callback(t))
row.addWidget(tag_widget)
return disam_button, tag_widget.bg_button, container
return tag_widget.bg_button, disam_button, container
def toggle_disam_id(self, disambiguation_id: int | None):
if self.disambiguation_id == disambiguation_id:

View File

@@ -1,26 +1,20 @@
# 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 typing
from typing import TYPE_CHECKING, override
from PySide6 import QtCore, QtGui
from PySide6.QtCore import Qt, QThreadPool, Signal
from PySide6.QtGui import QStandardItem, QStandardItemModel
from PySide6.QtWidgets import (
QHBoxLayout,
QLabel,
QListView,
QPushButton,
QVBoxLayout,
QWidget,
)
from PySide6.QtWidgets import QHBoxLayout, QLabel, QListView, QPushButton, QVBoxLayout, QWidget
from src.core.utils.missing_files import MissingRegistry
from src.qt.helpers.custom_runnable import CustomRunnable
from src.qt.translations import Translations
from src.qt.widgets.progress import ProgressWidget
# Only import for type checking/autocompletion, will not be imported at runtime.
if typing.TYPE_CHECKING:
if TYPE_CHECKING:
from src.qt.ts_qt import QtDriver
@@ -31,20 +25,20 @@ class DeleteUnlinkedEntriesModal(QWidget):
super().__init__()
self.driver = driver
self.tracker = tracker
Translations.translate_with_setter(self.setWindowTitle, "entries.unlinked.delete")
self.setWindowTitle(Translations["entries.unlinked.delete"])
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setMinimumSize(500, 400)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 6, 6, 6)
self.desc_widget = QLabel()
self.desc_widget = QLabel(
Translations.format(
"entries.unlinked.delete.confirm",
count=self.tracker.missing_file_entries_count,
)
)
self.desc_widget.setObjectName("descriptionLabel")
self.desc_widget.setWordWrap(True)
Translations.translate_qobject(
self.desc_widget,
"entries.unlinked.delete.confirm",
count=self.tracker.missing_file_entries_count,
)
self.desc_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.list_view = QListView()
@@ -56,14 +50,12 @@ class DeleteUnlinkedEntriesModal(QWidget):
self.button_layout.setContentsMargins(6, 6, 6, 6)
self.button_layout.addStretch(1)
self.cancel_button = QPushButton()
Translations.translate_qobject(self.cancel_button, "generic.cancel_alt")
self.cancel_button = QPushButton(Translations["generic.cancel_alt"])
self.cancel_button.setDefault(True)
self.cancel_button.clicked.connect(self.hide)
self.button_layout.addWidget(self.cancel_button)
self.delete_button = QPushButton()
Translations.translate_qobject(self.delete_button, "generic.delete_alt")
self.delete_button = QPushButton(Translations["generic.delete_alt"])
self.delete_button.clicked.connect(self.hide)
self.delete_button.clicked.connect(lambda: self.delete_entries())
self.button_layout.addWidget(self.delete_button)
@@ -74,7 +66,7 @@ class DeleteUnlinkedEntriesModal(QWidget):
def refresh_list(self):
self.desc_widget.setText(
Translations.translate_formatted(
Translations.format(
"entries.unlinked.delete.confirm", count=self.tracker.missing_file_entries_count
)
)
@@ -86,20 +78,13 @@ class DeleteUnlinkedEntriesModal(QWidget):
self.model.appendRow(item)
def delete_entries(self):
def displayed_text(x):
return Translations.translate_formatted(
"entries.unlinked.delete.deleting_count",
idx=x,
count=self.tracker.missing_file_entries_count,
)
pw = ProgressWidget(
cancel_button_text=None,
minimum=0,
maximum=0,
)
Translations.translate_with_setter(pw.setWindowTitle, "entries.unlinked.delete.deleting")
Translations.translate_with_setter(pw.update_label, "entries.unlinked.delete.deleting")
pw.setWindowTitle(Translations["entries.unlinked.delete.deleting"])
pw.update_label(Translations["entries.unlinked.delete.deleting"])
pw.show()
r = CustomRunnable(self.tracker.execute_deletion)
@@ -111,3 +96,11 @@ class DeleteUnlinkedEntriesModal(QWidget):
self.done.emit(),
)
)
@override
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
if event.key() == QtCore.Qt.Key.Key_Escape:
self.cancel_button.click()
else: # Other key presses
pass
return super().keyPressEvent(event)

View File

@@ -1,22 +1,17 @@
# Copyright (C) 2025
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import enum
import shutil
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, override
import structlog
from PySide6 import QtCore, QtGui
from PySide6.QtCore import Qt, QUrl
from PySide6.QtGui import QStandardItem, QStandardItemModel
from PySide6.QtWidgets import (
QHBoxLayout,
QLabel,
QListView,
QPushButton,
QVBoxLayout,
QWidget,
)
from PySide6.QtWidgets import QHBoxLayout, QLabel, QListView, QPushButton, QVBoxLayout, QWidget
from src.qt.translations import Translations
from src.qt.widgets.progress import ProgressWidget
@@ -42,7 +37,7 @@ class DropImportModal(QWidget):
self.driver: QtDriver = driver
# Widget ======================
Translations.translate_with_setter(self.setWindowTitle, "drop_import.title")
self.setWindowTitle(Translations["drop_import.title"])
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setMinimumSize(500, 400)
self.root_layout = QVBoxLayout(self)
@@ -65,26 +60,22 @@ class DropImportModal(QWidget):
self.button_layout.setContentsMargins(6, 6, 6, 6)
self.button_layout.addStretch(1)
self.skip_button = QPushButton()
Translations.translate_qobject(self.skip_button, "generic.skip_alt")
self.skip_button = QPushButton(Translations["generic.skip_alt"])
self.skip_button.setDefault(True)
self.skip_button.clicked.connect(lambda: self.begin_transfer(DuplicateChoice.SKIP))
self.button_layout.addWidget(self.skip_button)
self.overwrite_button = QPushButton()
Translations.translate_qobject(self.overwrite_button, "generic.overwrite_alt")
self.overwrite_button = QPushButton(Translations["generic.overwrite_alt"])
self.overwrite_button.clicked.connect(
lambda: self.begin_transfer(DuplicateChoice.OVERWRITE)
)
self.button_layout.addWidget(self.overwrite_button)
self.rename_button = QPushButton()
Translations.translate_qobject(self.rename_button, "generic.rename_alt")
self.rename_button = QPushButton(Translations["generic.rename_alt"])
self.rename_button.clicked.connect(lambda: self.begin_transfer(DuplicateChoice.RENAME))
self.button_layout.addWidget(self.rename_button)
self.cancel_button = QPushButton()
Translations.translate_qobject(self.cancel_button, "generic.cancel_alt")
self.cancel_button = QPushButton(Translations["generic.cancel_alt"])
self.cancel_button.clicked.connect(lambda: self.begin_transfer(DuplicateChoice.CANCEL))
self.button_layout.addWidget(self.cancel_button)
@@ -140,7 +131,7 @@ class DropImportModal(QWidget):
self.desc_widget.setText(
Translations["drop_import.duplicates_choice.singular"]
if len(self.duplicate_files) == 1
else Translations.translate_formatted(
else Translations.format(
"drop_import.duplicates_choice.plural", count=len(self.duplicate_files)
)
)
@@ -163,7 +154,7 @@ class DropImportModal(QWidget):
return
def displayed_text(x):
return Translations.translate_formatted(
return Translations.format(
"drop_import.progress.label.singular"
if x[0] + 1 == 1
else "drop_import.progress.label.plural",
@@ -176,8 +167,8 @@ class DropImportModal(QWidget):
minimum=0,
maximum=len(self.files),
)
Translations.translate_with_setter(pw.setWindowTitle, "drop_import.progress.window_title")
Translations.translate_with_setter(pw.update_label, "drop_import.progress.label.initial")
pw.setWindowTitle(Translations["drop_import.progress.window_title"])
pw.update_label(Translations["drop_import.progress.label.initial"])
pw.from_iterable_function(
self.copy_files,
@@ -232,3 +223,11 @@ class DropImportModal(QWidget):
)
index += 1
return filepath.name
@override
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
if event.key() == QtCore.Qt.Key.Key_Escape:
self.cancel_button.click()
else: # Other key presses
pass
return super().keyPressEvent(event)

View File

@@ -36,7 +36,7 @@ class FileExtensionModal(PanelWidget):
super().__init__()
# Initialize Modal =====================================================
self.lib = library
Translations.translate_with_setter(self.setWindowTitle, "ignore_list.title")
self.setWindowTitle(Translations["ignore_list.title"])
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setMinimumSize(240, 400)
self.root_layout = QVBoxLayout(self)
@@ -50,8 +50,7 @@ class FileExtensionModal(PanelWidget):
self.table.setItemDelegate(FileExtensionItemDelegate())
# Create "Add Button" Widget -------------------------------------------
self.add_button = QPushButton()
Translations.translate_qobject(self.add_button, "ignore_list.add_extension")
self.add_button = QPushButton(Translations["ignore_list.add_extension"])
self.add_button.clicked.connect(self.add_item)
self.add_button.setDefault(True)
self.add_button.setMinimumWidth(100)
@@ -61,18 +60,13 @@ class FileExtensionModal(PanelWidget):
self.mode_layout = QHBoxLayout(self.mode_widget)
self.mode_layout.setContentsMargins(0, 0, 0, 0)
self.mode_layout.setSpacing(12)
self.mode_label = QLabel()
Translations.translate_qobject(self.mode_label, "ignore_list.mode.label")
self.mode_label = QLabel(Translations["ignore_list.mode.label"])
self.mode_combobox = QComboBox()
self.mode_combobox.setEditable(False)
self.mode_combobox.addItem("")
self.mode_combobox.addItem("")
Translations.translate_with_setter(
lambda text: self.mode_combobox.setItemText(0, text), "ignore_list.mode.include"
)
Translations.translate_with_setter(
lambda text: self.mode_combobox.setItemText(1, text), "ignore_list.mode.exclude"
)
self.mode_combobox.setItemText(0, Translations["ignore_list.mode.include"])
self.mode_combobox.setItemText(1, Translations["ignore_list.mode.exclude"])
is_exclude_list = int(bool(self.lib.prefs(LibraryPrefs.IS_EXCLUDE_LIST)))

View File

@@ -1,26 +1,20 @@
# 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 typing
from typing import TYPE_CHECKING, override
from PySide6 import QtCore, QtGui
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QFileDialog,
QHBoxLayout,
QLabel,
QPushButton,
QVBoxLayout,
QWidget,
)
from PySide6.QtWidgets import QFileDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
from src.core.library import Library
from src.core.utils.dupe_files import DupeRegistry
from src.qt.modals.mirror_entities import MirrorEntriesModal
from src.qt.translations import Translations
# Only import for type checking/autocompletion, will not be imported at runtime.
if typing.TYPE_CHECKING:
if TYPE_CHECKING:
from src.qt.ts_qt import QtDriver
@@ -31,7 +25,7 @@ class FixDupeFilesModal(QWidget):
self.driver = driver
self.count = -1
self.filename = ""
Translations.translate_with_setter(self.setWindowTitle, "file.duplicates.fix")
self.setWindowTitle(Translations["file.duplicates.fix"])
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setMinimumSize(400, 300)
self.root_layout = QVBoxLayout(self)
@@ -39,11 +33,10 @@ class FixDupeFilesModal(QWidget):
self.tracker = DupeRegistry(library=self.lib)
self.desc_widget = QLabel()
self.desc_widget = QLabel(Translations["file.duplicates.description"])
self.desc_widget.setObjectName("descriptionLabel")
self.desc_widget.setWordWrap(True)
self.desc_widget.setStyleSheet("text-align:left;")
Translations.translate_qobject(self.desc_widget, "file.duplicates.description")
self.desc_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.dupe_count = QLabel()
@@ -51,35 +44,29 @@ class FixDupeFilesModal(QWidget):
self.dupe_count.setStyleSheet("font-weight:bold;" "font-size:14px;" "")
self.dupe_count.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.file_label = QLabel()
self.file_label = QLabel(Translations["file.duplicates.dupeguru.no_file"])
self.file_label.setObjectName("fileLabel")
Translations.translate_qobject(self.file_label, "file.duplicates.dupeguru.no_file")
self.open_button = QPushButton()
Translations.translate_qobject(self.open_button, "file.duplicates.dupeguru.load_file")
self.open_button = QPushButton(Translations["file.duplicates.dupeguru.load_file"])
self.open_button.clicked.connect(self.select_file)
self.mirror_modal = MirrorEntriesModal(self.driver, self.tracker)
self.mirror_modal.done.connect(self.refresh_dupes)
self.mirror_button = QPushButton()
Translations.translate_qobject(self.mirror_button, "file.duplicates.mirror_entries")
self.mirror_button = QPushButton(Translations["file.duplicates.mirror_entries"])
self.mirror_button.clicked.connect(self.mirror_modal.show)
self.mirror_desc = QLabel()
self.mirror_desc = QLabel(Translations["file.duplicates.mirror.description"])
self.mirror_desc.setWordWrap(True)
Translations.translate_qobject(self.mirror_desc, "file.duplicates.mirror.description")
self.advice_label = QLabel()
self.advice_label = QLabel(Translations["file.duplicates.dupeguru.advice"])
self.advice_label.setWordWrap(True)
Translations.translate_qobject(self.advice_label, "file.duplicates.dupeguru.advice")
self.button_container = QWidget()
self.button_layout = QHBoxLayout(self.button_container)
self.button_layout.setContentsMargins(6, 6, 6, 6)
self.button_layout.addStretch(1)
self.done_button = QPushButton()
Translations.translate_qobject(self.done_button, "generic.done_alt")
self.done_button = QPushButton(Translations["generic.done_alt"])
self.done_button.setDefault(True)
self.done_button.clicked.connect(self.hide)
self.button_layout.addWidget(self.done_button)
@@ -127,11 +114,15 @@ class FixDupeFilesModal(QWidget):
self.dupe_count.setText(Translations["file.duplicates.matches_uninitialized"])
elif count == 0:
self.mirror_button.setDisabled(True)
self.dupe_count.setText(
Translations.translate_formatted("file.duplicates.matches", count=count)
)
self.dupe_count.setText(Translations.format("file.duplicates.matches", count=count))
else:
self.mirror_button.setDisabled(False)
self.dupe_count.setText(
Translations.translate_formatted("file.duplicates.matches", count=count)
)
self.dupe_count.setText(Translations.format("file.duplicates.matches", count=count))
@override
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
if event.key() == QtCore.Qt.Key.Key_Escape:
self.done_button.click()
else: # Other key presses
pass
return super().keyPressEvent(event)

View File

@@ -1,10 +1,11 @@
# 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 typing
from typing import TYPE_CHECKING, override
from PySide6 import QtCore, QtGui
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
from src.core.library import Library
@@ -16,7 +17,7 @@ from src.qt.translations import Translations
from src.qt.widgets.progress import ProgressWidget
# Only import for type checking/autocompletion, will not be imported at runtime.
if typing.TYPE_CHECKING:
if TYPE_CHECKING:
from src.qt.ts_qt import QtDriver
@@ -30,17 +31,16 @@ class FixUnlinkedEntriesModal(QWidget):
self.missing_count = -1
self.dupe_count = -1
Translations.translate_with_setter(self.setWindowTitle, "entries.unlinked.title")
self.setWindowTitle(Translations["entries.unlinked.title"])
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setMinimumSize(400, 300)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 6, 6, 6)
self.unlinked_desc_widget = QLabel()
self.unlinked_desc_widget = QLabel(Translations["entries.unlinked.description"])
self.unlinked_desc_widget.setObjectName("unlinkedDescriptionLabel")
self.unlinked_desc_widget.setWordWrap(True)
self.unlinked_desc_widget.setStyleSheet("text-align:left;")
Translations.translate_qobject(self.unlinked_desc_widget, "entries.unlinked.description")
self.missing_count_label = QLabel()
self.missing_count_label.setObjectName("missingCountLabel")
@@ -52,15 +52,13 @@ class FixUnlinkedEntriesModal(QWidget):
self.dupe_count_label.setStyleSheet("font-weight:bold;" "font-size:14px;")
self.dupe_count_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.refresh_unlinked_button = QPushButton()
Translations.translate_qobject(self.refresh_unlinked_button, "entries.unlinked.refresh_all")
self.refresh_unlinked_button = QPushButton(Translations["entries.unlinked.refresh_all"])
self.refresh_unlinked_button.clicked.connect(self.refresh_missing_files)
self.merge_class = MergeDuplicateEntries(self.lib, self.driver)
self.relink_class = RelinkUnlinkedEntries(self.tracker)
self.search_button = QPushButton()
Translations.translate_qobject(self.search_button, "entries.unlinked.search_and_relink")
self.search_button = QPushButton(Translations["entries.unlinked.search_and_relink"])
self.relink_class.done.connect(
# refresh the grid
lambda: (
@@ -70,11 +68,10 @@ class FixUnlinkedEntriesModal(QWidget):
)
self.search_button.clicked.connect(self.relink_class.repair_entries)
self.manual_button = QPushButton()
Translations.translate_qobject(self.manual_button, "entries.unlinked.relink.manual")
self.manual_button = QPushButton(Translations["entries.unlinked.relink.manual"])
self.manual_button.setHidden(True)
self.delete_button = QPushButton()
self.delete_button = QPushButton(Translations["entries.unlinked.delete_alt"])
self.delete_modal = DeleteUnlinkedEntriesModal(self.driver, self.tracker)
self.delete_modal.done.connect(
lambda: (
@@ -83,7 +80,6 @@ class FixUnlinkedEntriesModal(QWidget):
self.driver.filter_items(),
)
)
Translations.translate_qobject(self.delete_button, "entries.unlinked.delete_alt")
self.delete_button.clicked.connect(self.delete_modal.show)
self.button_container = QWidget()
@@ -91,8 +87,7 @@ class FixUnlinkedEntriesModal(QWidget):
self.button_layout.setContentsMargins(6, 6, 6, 6)
self.button_layout.addStretch(1)
self.done_button = QPushButton()
Translations.translate_qobject(self.done_button, "generic.done_alt")
self.done_button = QPushButton(Translations["generic.done_alt"])
self.done_button.setDefault(True)
self.done_button.clicked.connect(self.hide)
self.button_layout.addWidget(self.done_button)
@@ -115,8 +110,8 @@ class FixUnlinkedEntriesModal(QWidget):
minimum=0,
maximum=self.lib.entries_count,
)
Translations.translate_with_setter(pw.setWindowTitle, "library.scan_library.title")
Translations.translate_with_setter(pw.update_label, "entries.unlinked.scanning")
pw.setWindowTitle(Translations["library.scan_library.title"])
pw.update_label(Translations["entries.unlinked.scanning"])
pw.from_iterable_function(
self.tracker.refresh_missing_files,
@@ -140,7 +135,13 @@ class FixUnlinkedEntriesModal(QWidget):
self.search_button.setDisabled(self.missing_count == 0)
self.delete_button.setDisabled(self.missing_count == 0)
self.missing_count_label.setText(
Translations.translate_formatted(
"entries.unlinked.missing_count.some", count=self.missing_count
)
Translations.format("entries.unlinked.missing_count.some", count=self.missing_count)
)
@override
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
if event.key() == QtCore.Qt.Key.Key_Escape:
self.done_button.click()
else: # Other key presses
pass
return super().keyPressEvent(event)

View File

@@ -4,10 +4,12 @@
import math
import typing
from collections.abc import Sequence
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, override
import structlog
from PySide6 import QtCore, QtGui
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QFrame,
@@ -25,7 +27,7 @@ from src.core.palette import ColorType, get_tag_color
from src.qt.flowlayout import FlowLayout
from src.qt.translations import Translations
if typing.TYPE_CHECKING:
if TYPE_CHECKING:
from src.qt.ts_qt import QtDriver
logger = structlog.get_logger(__name__)
@@ -73,7 +75,7 @@ def folders_to_tags(library: Library):
tag = add_folders_to_tree(library, tree, folders).tag
if tag and not entry.has_tag(tag):
library.add_tags_to_entry(entry.id, tag.id)
library.add_tags_to_entries(entry.id, tag.id)
logger.info("Done")
@@ -104,7 +106,7 @@ def generate_preview_data(library: Library) -> BranchData:
branch.dirs[tag.name] = BranchData(tag=tag)
branch = branch.dirs[tag.name]
def _add_folders_to_tree(items: typing.Sequence[str]) -> BranchData:
def _add_folders_to_tree(items: Sequence[str]) -> BranchData:
branch = tree
for folder in items:
if folder not in branch.dirs:
@@ -164,17 +166,16 @@ class FoldersToTagsModal(QWidget):
self.count = -1
self.filename = ""
Translations.translate_with_setter(self.setWindowTitle, "folders_to_tags.title")
self.setWindowTitle(Translations["folders_to_tags.title"])
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setMinimumSize(640, 640)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 6, 6, 6)
self.title_widget = QLabel()
self.title_widget = QLabel(Translations["folders_to_tags.title"])
self.title_widget.setObjectName("title")
self.title_widget.setWordWrap(True)
self.title_widget.setStyleSheet("font-weight:bold;" "font-size:14px;" "padding-top: 6px")
Translations.translate_qobject(self.title_widget, "folders_to_tags.title")
self.title_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.desc_widget = QLabel()
@@ -189,11 +190,9 @@ class FoldersToTagsModal(QWidget):
self.open_close_button_w = QWidget()
self.open_close_button_layout = QHBoxLayout(self.open_close_button_w)
self.open_all_button = QPushButton()
Translations.translate_qobject(self.open_all_button, "folders_to_tags.open_all")
self.open_all_button = QPushButton(Translations["folders_to_tags.open_all"])
self.open_all_button.clicked.connect(lambda: self.set_all_branches(False))
self.close_all_button = QPushButton()
Translations.translate_qobject(self.close_all_button, "folders_to_tags.close_all")
self.close_all_button = QPushButton(Translations["folders_to_tags.close_all"])
self.close_all_button.clicked.connect(lambda: self.set_all_branches(True))
self.open_close_button_layout.addWidget(self.open_all_button)
@@ -211,8 +210,7 @@ class FoldersToTagsModal(QWidget):
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
self.scroll_area.setWidget(self.scroll_contents)
self.apply_button = QPushButton()
Translations.translate_qobject(self.apply_button, "generic.apply_alt")
self.apply_button = QPushButton(Translations["generic.apply_alt"])
self.apply_button.setMinimumWidth(100)
self.apply_button.clicked.connect(self.on_apply)
@@ -245,6 +243,14 @@ class FoldersToTagsModal(QWidget):
if isinstance(child, TreeItem):
child.set_all_branches(hidden)
@override
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
if event.key() == QtCore.Qt.Key.Key_Escape:
self.close()
else: # Other key presses
pass
return super().keyPressEvent(event)
class TreeItem(QWidget):
def __init__(self, data: BranchData, parent_tag: Tag | None = None):

View File

@@ -30,7 +30,7 @@ class MergeDuplicateEntries(QObject):
minimum=0,
maximum=self.tracker.groups_count,
)
Translations.translate_with_setter(pw.setWindowTitle, "entries.duplicate.merge.label")
Translations.translate_with_setter(pw.update_label, "entries.duplicate.merge.label")
pw.setWindowTitle(Translations["entries.duplicate.merge.label"])
pw.update_label(Translations["entries.duplicate.merge.label"])
pw.from_iterable_function(self.tracker.merge_dupe_entries, None, self.done.emit)

View File

@@ -8,14 +8,7 @@ from time import sleep
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QStandardItem, QStandardItemModel
from PySide6.QtWidgets import (
QHBoxLayout,
QLabel,
QListView,
QPushButton,
QVBoxLayout,
QWidget,
)
from PySide6.QtWidgets import QHBoxLayout, QLabel, QListView, QPushButton, QVBoxLayout, QWidget
from src.core.utils.dupe_files import DupeRegistry
from src.qt.translations import Translations
from src.qt.widgets.progress import ProgressWidget
@@ -31,19 +24,18 @@ class MirrorEntriesModal(QWidget):
def __init__(self, driver: "QtDriver", tracker: DupeRegistry):
super().__init__()
self.driver = driver
Translations.translate_with_setter(self.setWindowTitle, "entries.mirror.window_title")
self.setWindowTitle(Translations["entries.mirror.window_title"])
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setMinimumSize(500, 400)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 6, 6, 6)
self.tracker = tracker
self.desc_widget = QLabel()
self.desc_widget = QLabel(
Translations.format("entries.mirror.confirmation", count=self.tracker.groups_count)
)
self.desc_widget.setObjectName("descriptionLabel")
self.desc_widget.setWordWrap(True)
Translations.translate_qobject(
self.desc_widget, "entries.mirror.confirmation", count=self.tracker.groups_count
)
self.desc_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.list_view = QListView()
@@ -55,14 +47,12 @@ class MirrorEntriesModal(QWidget):
self.button_layout.setContentsMargins(6, 6, 6, 6)
self.button_layout.addStretch(1)
self.cancel_button = QPushButton()
Translations.translate_qobject(self.cancel_button, "generic.cancel_alt")
self.cancel_button = QPushButton(Translations["generic.cancel_alt"])
self.cancel_button.setDefault(True)
self.cancel_button.clicked.connect(self.hide)
self.button_layout.addWidget(self.cancel_button)
self.mirror_button = QPushButton()
Translations.translate_qobject(self.mirror_button, "entries.mirror")
self.mirror_button = QPushButton(Translations["entries.mirror"])
self.mirror_button.clicked.connect(self.hide)
self.mirror_button.clicked.connect(self.mirror_entries)
self.button_layout.addWidget(self.mirror_button)
@@ -73,9 +63,7 @@ class MirrorEntriesModal(QWidget):
def refresh_list(self):
self.desc_widget.setText(
Translations.translate_formatted(
"entries.mirror.confirmation", count=self.tracker.groups_count
)
Translations.format("entries.mirror.confirmation", count=self.tracker.groups_count)
)
self.model.clear()
@@ -84,7 +72,7 @@ class MirrorEntriesModal(QWidget):
def mirror_entries(self):
def displayed_text(x):
return Translations.translate_formatted(
return Translations.format(
"entries.mirror.label", idx=x + 1, count=self.tracker.groups_count
)
@@ -93,7 +81,7 @@ class MirrorEntriesModal(QWidget):
minimum=0,
maximum=self.tracker.groups_count,
)
Translations.translate_with_setter(pw.setWindowTitle, "entries.mirror.title")
pw.setWindowTitle(Translations["entries.mirror.title"])
pw.from_iterable_function(
self.mirror_entries_runnable,

View File

@@ -18,7 +18,7 @@ class RelinkUnlinkedEntries(QObject):
def repair_entries(self):
def displayed_text(x):
return Translations.translate_formatted(
return Translations.format(
"entries.unlinked.relink.attempting",
idx=x,
missing_count=self.tracker.missing_file_entries_count,
@@ -31,6 +31,6 @@ class RelinkUnlinkedEntries(QObject):
minimum=0,
maximum=self.tracker.missing_file_entries_count,
)
Translations.translate_with_setter(pw.setWindowTitle, "entries.unlinked.relink.title")
pw.setWindowTitle(Translations["entries.unlinked.relink.title"])
pw.from_iterable_function(self.tracker.fix_unlinked_entries, displayed_text, self.done.emit)

View File

@@ -0,0 +1,71 @@
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QComboBox, QFormLayout, QLabel, QVBoxLayout, QWidget
from src.core.enums import SettingItems
from src.qt.translations import Translations
from src.qt.widgets.panel import PanelWidget
class SettingsPanel(PanelWidget):
def __init__(self, driver):
super().__init__()
self.driver = driver
self.setMinimumSize(320, 200)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 0, 6, 0)
self.form_container = QWidget()
self.form_layout = QFormLayout(self.form_container)
self.form_layout.setContentsMargins(0, 0, 0, 0)
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()]

View File

@@ -0,0 +1,221 @@
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from typing import TYPE_CHECKING, Callable, override
import structlog
from PySide6 import QtCore, QtGui
from PySide6.QtCore import Qt
from PySide6.QtGui import QGuiApplication
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
QLabel,
QMessageBox,
QPushButton,
QScrollArea,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from src.core.constants import RESERVED_NAMESPACE_PREFIX
from src.core.enums import Theme
from src.qt.modals.build_namespace import BuildNamespacePanel
from src.qt.translations import Translations
from src.qt.widgets.color_box import ColorBoxWidget
from src.qt.widgets.fields import FieldContainer
from src.qt.widgets.panel import PanelModal
logger = structlog.get_logger(__name__)
# Only import for type checking/autocompletion, will not be imported at runtime.
if TYPE_CHECKING:
from src.qt.ts_qt import QtDriver
class TagColorManager(QWidget):
create_namespace_modal: PanelModal | None = None
def __init__(
self,
driver: "QtDriver",
):
super().__init__()
self.driver = driver
self.lib = driver.lib
self.setWindowTitle(Translations["color_manager.title"])
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setMinimumSize(800, 600)
self.is_initialized = False
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 6, 6, 6)
panel_bg_color = (
Theme.COLOR_BG_DARK.value
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_BG_LIGHT.value
)
self.title_label = QLabel()
self.title_label.setObjectName("titleLabel")
self.title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.title_label.setText(f"<h3>{Translations["color_manager.title"]}</h3>")
self.scroll_layout = QVBoxLayout()
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.scroll_layout.setContentsMargins(3, 3, 3, 3)
self.scroll_layout.setSpacing(0)
scroll_container: QWidget = QWidget()
scroll_container.setObjectName("entryScrollContainer")
scroll_container.setLayout(self.scroll_layout)
self.scroll_area = QScrollArea()
self.scroll_area.setObjectName("entryScrollArea")
self.scroll_area.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignCenter)
self.scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setFrameShadow(QFrame.Shadow.Plain)
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
self.scroll_area.setStyleSheet(
"QWidget#entryScrollContainer{" f"background:{panel_bg_color};" "border-radius:6px;" "}"
)
self.scroll_area.setWidget(scroll_container)
self.setup_color_groups()
self.button_container = QWidget()
self.button_layout = QHBoxLayout(self.button_container)
self.button_layout.setContentsMargins(6, 6, 6, 6)
self.new_namespace_button = QPushButton(Translations["namespace.new.button"])
self.new_namespace_button.clicked.connect(self.create_namespace)
self.button_layout.addWidget(self.new_namespace_button)
# self.import_pack_button = QPushButton()
# Translations.translate_qobject(self.import_pack_button, "color.import_pack")
# self.button_layout.addWidget(self.import_pack_button)
self.button_layout.addStretch(1)
self.done_button = QPushButton(Translations["generic.done_alt"])
self.done_button.clicked.connect(self.hide)
self.button_layout.addWidget(self.done_button)
self.root_layout.addWidget(self.title_label)
self.root_layout.addWidget(self.scroll_area)
self.root_layout.addWidget(self.button_container)
logger.info(self.root_layout.dumpObjectTree())
def setup_color_groups(self):
all_default = True
if self.driver.lib.engine:
for group, colors in self.driver.lib.tag_color_groups.items():
if not group.startswith(RESERVED_NAMESPACE_PREFIX):
all_default = False
color_box = ColorBoxWidget(group, colors, self.driver.lib)
color_box.updated.connect(
lambda: (
self.reset(),
self.setup_color_groups(),
()
if len(self.driver.selected) < 1
else self.driver.preview_panel.fields.update_from_entry(
self.driver.selected[0], update_badges=False
),
)
)
field_container = FieldContainer(self.driver.lib.get_namespace_name(group))
field_container.set_inner_widget(color_box)
if not group.startswith(RESERVED_NAMESPACE_PREFIX):
field_container.set_remove_callback(
lambda checked=False, g=group: self.delete_namespace_dialog(
prompt=Translations["color.namespace.delete.prompt"],
callback=lambda namespace=g: (
self.lib.delete_namespace(namespace),
self.reset(),
self.setup_color_groups(),
()
if len(self.driver.selected) < 1
else self.driver.preview_panel.fields.update_from_entry(
self.driver.selected[0], update_badges=False
),
),
)
)
self.scroll_layout.addWidget(field_container)
if all_default:
ns_container = QWidget()
ns_layout = QHBoxLayout(ns_container)
ns_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
ns_layout.setContentsMargins(0, 18, 0, 18)
namespace_prompt = QPushButton(Translations["namespace.new.prompt"])
namespace_prompt.setFixedSize(namespace_prompt.sizeHint().width() + 8, 24)
namespace_prompt.clicked.connect(self.create_namespace)
ns_layout.addWidget(namespace_prompt)
self.scroll_layout.addWidget(ns_container)
self.is_initialized = True
def reset(self):
while self.scroll_layout.count():
widget = self.scroll_layout.itemAt(0).widget()
self.scroll_layout.removeWidget(widget)
widget.deleteLater()
self.is_initialized = False
def create_namespace(self):
build_namespace_panel = BuildNamespacePanel(self.lib)
self.create_namespace_modal = PanelModal(
build_namespace_panel,
Translations["namespace.create.title"],
Translations["namespace.create.title"],
has_save=True,
)
self.create_namespace_modal.saved.connect(
lambda: (
self.lib.add_namespace(build_namespace_panel.build_namespace()),
self.reset(),
self.setup_color_groups(),
)
)
self.create_namespace_modal.show()
def delete_namespace_dialog(self, prompt: str, callback: Callable) -> None:
message_box = QMessageBox()
message_box.setText(prompt)
message_box.setWindowTitle(Translations["color.namespace.delete.title"])
message_box.setIcon(QMessageBox.Icon.Warning)
cancel_button = message_box.addButton(
Translations["generic.cancel_alt"], QMessageBox.ButtonRole.RejectRole
)
message_box.addButton(
Translations["generic.delete_alt"], QMessageBox.ButtonRole.DestructiveRole
)
message_box.setEscapeButton(cancel_button)
result = message_box.exec_()
if result != QMessageBox.ButtonRole.ActionRole.value:
return
callback()
@override
def showEvent(self, event: QtGui.QShowEvent) -> None: # noqa N802
if not self.is_initialized:
self.setup_color_groups()
return super().showEvent(event)
@override
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
if event.key() == QtCore.Qt.Key.Key_Escape: # noqa SIM114
self.done_button.click()
elif event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter:
self.done_button.click()
return super().keyPressEvent(event)

View File

@@ -8,21 +8,25 @@ from PySide6.QtCore import Qt
from PySide6.QtGui import QColor
from PySide6.QtWidgets import (
QButtonGroup,
QFrame,
QLabel,
QRadioButton,
QScrollArea,
QSizePolicy,
QSpacerItem,
QVBoxLayout,
QWidget,
)
from src.core.library import Library
from src.core.library.alchemy.enums import TagColorEnum
from src.core.library.alchemy.models import TagColorGroup
from src.core.palette import ColorType, get_tag_color
from src.qt.flowlayout import FlowLayout
from src.qt.translations import Translations
from src.qt.widgets.panel import PanelWidget
from src.qt.widgets.tag_color_preview import (
from src.qt.widgets.tag import (
get_border_color,
get_highlight_color,
get_primary_color,
get_text_color,
)
@@ -37,19 +41,37 @@ class TagColorSelection(PanelWidget):
self.setMinimumSize(308, 540)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 0, 6, 0)
self.root_layout.setSpacing(6)
self.root_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.root_layout.setContentsMargins(0, 0, 0, 0)
self.root_layout.setSpacing(0)
self.scroll_layout = QVBoxLayout()
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.scroll_layout.setContentsMargins(6, 0, 6, 0)
self.scroll_layout.setSpacing(3)
scroll_container: QWidget = QWidget()
scroll_container.setObjectName("entryScrollContainer")
scroll_container.setLayout(self.scroll_layout)
self.scroll_area = QScrollArea()
self.scroll_area.setObjectName("entryScrollArea")
self.scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setFrameShadow(QFrame.Shadow.Plain)
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
self.scroll_area.setWidget(scroll_container)
self.root_layout.addWidget(self.scroll_area)
# Add Widgets to Layout ================================================
tag_color_groups = self.lib.tag_color_groups
self.button_group = QButtonGroup(self)
self.add_no_color_widget()
self.root_layout.addSpacerItem(QSpacerItem(1, 12))
self.scroll_layout.addSpacerItem(QSpacerItem(1, 6))
for group, colors in tag_color_groups.items():
display_name: str = self.lib.get_namespace_name(group)
self.root_layout.addWidget(
self.scroll_layout.addWidget(
QLabel(f"<h4>{display_name if display_name else group}</h4>")
)
color_box_widget = QWidget()
@@ -59,10 +81,10 @@ class TagColorSelection(PanelWidget):
color_group_layout.setContentsMargins(0, 0, 0, 0)
color_box_widget.setLayout(color_group_layout)
for color in colors:
primary_color = get_primary_color(color)
primary_color = self._get_primary_color(color)
border_color = (
get_border_color(primary_color)
if not (color and color.secondary)
if not (color and color.secondary and color.color_border)
else (QColor(color.secondary))
)
highlight_color = get_highlight_color(
@@ -78,11 +100,15 @@ class TagColorSelection(PanelWidget):
radio_button.setObjectName(f"{color.namespace}.{color.slug}")
radio_button.setToolTip(color.name)
radio_button.setFixedSize(24, 24)
bottom_color: str = (
f"border-bottom-color: rgba{text_color.toTuple()};" if color.secondary else ""
)
radio_button.setStyleSheet(
f"QRadioButton{{"
f"background: rgba{primary_color.toTuple()};"
f"color: rgba{text_color.toTuple()};"
f"border-color: rgba{border_color.toTuple()};"
f"{bottom_color}"
f"border-radius: 3px;"
f"border-style:solid;"
f"border-width: 2px;"
@@ -99,16 +125,22 @@ class TagColorSelection(PanelWidget):
f"QRadioButton::hover{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"}}"
f"QRadioButton::focus{{"
f"outline-style: solid;"
f"outline-width: 2px;"
f"outline-radius: 3px;"
f"outline-color: rgba{highlight_color.toTuple()};"
f"}}"
)
radio_button.clicked.connect(lambda checked=False, x=color: self.select_color(x))
color_group_layout.addWidget(radio_button)
self.button_group.addButton(radio_button)
self.root_layout.addWidget(color_box_widget)
self.root_layout.addSpacerItem(QSpacerItem(1, 12))
self.scroll_layout.addWidget(color_box_widget)
self.scroll_layout.addSpacerItem(QSpacerItem(1, 6))
def add_no_color_widget(self):
no_color_str: str = Translations.translate_formatted("color.title.no_color")
self.root_layout.addWidget(QLabel(f"<h4>{no_color_str}</h4>"))
no_color_str: str = Translations["color.title.no_color"]
self.scroll_layout.addWidget(QLabel(f"<h4>{no_color_str}</h4>"))
color_box_widget = QWidget()
color_group_layout = FlowLayout()
color_group_layout.setSpacing(4)
@@ -116,11 +148,11 @@ class TagColorSelection(PanelWidget):
color_group_layout.setContentsMargins(0, 0, 0, 0)
color_box_widget.setLayout(color_group_layout)
color = None
primary_color = get_primary_color(color)
primary_color = self._get_primary_color(color)
border_color = get_border_color(primary_color)
highlight_color = get_highlight_color(primary_color)
text_color: QColor
if color and color.secondary:
if color and color.secondary and color.color_border:
text_color = QColor(color.secondary)
else:
text_color = get_text_color(primary_color, highlight_color)
@@ -150,13 +182,19 @@ class TagColorSelection(PanelWidget):
f"QRadioButton::hover{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"}}"
f"QRadioButton::focus{{"
f"outline-style: solid;"
f"outline-width: 2px;"
f"outline-radius: 3px;"
f"outline-color: rgba{highlight_color.toTuple()};"
f"}}"
)
radio_button.clicked.connect(lambda checked=False, x=color: self.select_color(x))
color_group_layout.addWidget(radio_button)
self.button_group.addButton(radio_button)
self.root_layout.addWidget(color_box_widget)
self.scroll_layout.addWidget(color_box_widget)
def select_color(self, color: TagColorGroup):
def select_color(self, color: TagColorGroup | None):
self.selected_color = color
def select_radio_button(self, color: TagColorGroup | None):
@@ -164,4 +202,13 @@ class TagColorSelection(PanelWidget):
for button in self.button_group.buttons():
if button.objectName() == object_name:
button.setChecked(True)
self.select_color(color)
break
def _get_primary_color(self, tag_color_group: TagColorGroup | None) -> QColor:
primary_color = QColor(
get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)
if not tag_color_group
else tag_color_group.primary
)
return primary_color

View File

@@ -3,10 +3,7 @@
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import structlog
from PySide6.QtWidgets import (
QMessageBox,
QPushButton,
)
from PySide6.QtWidgets import QMessageBox, QPushButton
from src.core.constants import RESERVED_TAG_END, RESERVED_TAG_START
from src.core.library import Library, Tag
from src.qt.modals.build_tag import BuildTagPanel
@@ -18,19 +15,18 @@ logger = structlog.get_logger(__name__)
# TODO: Once this class is removed, the `is_tag_chooser` option of `TagSearchPanel`
# will most likely be enabled in every case
# and the possibilty of disabling it can therefore be removed
# and the possibility of disabling it can therefore be removed
class TagDatabasePanel(TagSearchPanel):
def __init__(self, library: Library):
def __init__(self, driver, library: Library):
super().__init__(library, is_tag_chooser=False)
self.driver = driver
self.create_tag_button = QPushButton()
Translations.translate_qobject(self.create_tag_button, "tag.create")
self.create_tag_button = QPushButton(Translations["tag.create"])
self.create_tag_button.clicked.connect(lambda: self.build_tag(self.search_field.text()))
self.root_layout.addWidget(self.create_tag_button)
self.update_tags()
def build_tag(self, name: str):
panel = BuildTagPanel(self.lib)
@@ -38,8 +34,8 @@ class TagDatabasePanel(TagSearchPanel):
panel,
has_save=True,
)
Translations.translate_with_setter(self.modal.setTitle, "tag.new")
Translations.translate_with_setter(self.modal.setWindowTitle, "tag.add")
self.modal.setTitle(Translations["tag.new"])
self.modal.setWindowTitle(Translations["tag.new"])
if name.strip():
panel.name_field.setText(name)
@@ -57,17 +53,16 @@ class TagDatabasePanel(TagSearchPanel):
)
self.modal.show()
def remove_tag(self, tag: Tag):
def delete_tag(self, tag: Tag):
if tag.id in range(RESERVED_TAG_START, RESERVED_TAG_END):
return
message_box = QMessageBox()
Translations.translate_with_setter(message_box.setWindowTitle, "tag.remove")
Translations.translate_qobject(
message_box, "tag.confirm_delete", tag_name=self.lib.tag_display_name(tag.id)
message_box = QMessageBox(
QMessageBox.Question, # type: ignore
Translations["tag.remove"],
Translations.format("tag.confirm_delete", tag_name=self.lib.tag_display_name(tag.id)),
QMessageBox.Ok | QMessageBox.Cancel, # type: ignore
)
message_box.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel) # type: ignore
message_box.setIcon(QMessageBox.Question) # type: ignore
result = message_box.exec()

View File

@@ -1,17 +1,22 @@
# 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 typing
import contextlib
from typing import TYPE_CHECKING, override
from warnings import catch_warnings
import src.qt.modals.build_tag as build_tag
import structlog
from PySide6 import QtCore, QtGui
from PySide6.QtCore import QSize, Qt, Signal
from PySide6.QtGui import QColor, QShowEvent
from PySide6.QtGui import QShowEvent
from PySide6.QtWidgets import (
QComboBox,
QFrame,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QScrollArea,
@@ -20,22 +25,18 @@ from PySide6.QtWidgets import (
)
from src.core.constants import RESERVED_TAG_END, RESERVED_TAG_START
from src.core.library import Library, Tag
from src.core.library.alchemy.enums import TagColorEnum
from src.core.library.alchemy.enums import FilterState, TagColorEnum
from src.core.palette import ColorType, get_tag_color
from src.qt.translations import Translations
from src.qt.widgets.panel import PanelModal, PanelWidget
from src.qt.widgets.tag import (
TagWidget,
get_border_color,
get_highlight_color,
get_primary_color,
get_text_color,
)
logger = structlog.get_logger(__name__)
# Only import for type checking/autocompletion, will not be imported at runtime.
if typing.TYPE_CHECKING:
if TYPE_CHECKING:
from src.qt.modals.build_tag import BuildTagPanel
@@ -47,6 +48,11 @@ class TagSearchPanel(PanelWidget):
is_tag_chooser: bool
exclude: list[int]
_limit_items: list[int | str] = [25, 50, 100, 250, 500, Translations["tag.all_tags"]]
_default_limit_idx: int = 0 # 50 Tag Limit (Default)
cur_limit_idx: int = _default_limit_idx
tag_limit: int | str = _limit_items[_default_limit_idx]
def __init__(
self,
library: Library,
@@ -55,18 +61,40 @@ class TagSearchPanel(PanelWidget):
):
super().__init__()
self.lib = library
self.driver = None
self.exclude = exclude or []
self.is_tag_chooser = is_tag_chooser
self.create_button_in_layout: bool = False
self.setMinimumSize(300, 400)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 0, 6, 0)
self.limit_container = QWidget()
self.limit_layout = QHBoxLayout(self.limit_container)
self.limit_layout.setContentsMargins(0, 0, 0, 0)
self.limit_layout.setSpacing(12)
self.limit_layout.addStretch(1)
self.limit_title = QLabel(Translations["tag.view_limit"])
self.limit_layout.addWidget(self.limit_title)
self.limit_combobox = QComboBox()
self.limit_combobox.setEditable(False)
self.limit_combobox.addItems([str(x) for x in TagSearchPanel._limit_items])
self.limit_combobox.setCurrentIndex(TagSearchPanel._default_limit_idx)
self.limit_combobox.currentIndexChanged.connect(self.update_limit)
self.previous_limit: int = (
TagSearchPanel.tag_limit if isinstance(TagSearchPanel.tag_limit, int) else -1
)
self.limit_layout.addWidget(self.limit_combobox)
self.limit_layout.addStretch(1)
self.search_field = QLineEdit()
self.search_field.setObjectName("searchField")
self.search_field.setMinimumSize(QSize(0, 32))
Translations.translate_with_setter(self.search_field.setPlaceholderText, "home.search_tags")
self.search_field.setPlaceholderText(Translations["home.search_tags"])
self.search_field.textEdited.connect(lambda text: self.update_tags(text))
self.search_field.returnPressed.connect(lambda: self.on_return(self.search_field.text()))
@@ -82,102 +110,19 @@ class TagSearchPanel(PanelWidget):
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
self.scroll_area.setWidget(self.scroll_contents)
self.root_layout.addWidget(self.limit_container)
self.root_layout.addWidget(self.search_field)
self.root_layout.addWidget(self.scroll_area)
def __build_row_item_widget(self, tag: Tag):
container = QWidget()
row = QHBoxLayout(container)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(3)
has_remove_button = False
if not self.is_tag_chooser:
has_remove_button = tag.id not in range(RESERVED_TAG_START, RESERVED_TAG_END)
tag_widget = TagWidget(
tag,
library=self.lib,
has_edit=True,
has_remove=has_remove_button,
)
tag_widget.on_edit.connect(lambda t=tag: self.edit_tag(t))
tag_widget.on_remove.connect(lambda t=tag: self.remove_tag(t))
# NOTE: A solution to this would be to pass the driver to TagSearchPanel, however that
# creates an exponential amount of work trying to fix the preexisting tests.
# 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)),
# )
# )
row.addWidget(tag_widget)
primary_color = get_primary_color(tag)
border_color = (
get_border_color(primary_color)
if not (tag.color and tag.color.secondary)
else (QColor(tag.color.secondary))
)
highlight_color = get_highlight_color(
primary_color
if not (tag.color and tag.color.secondary)
else QColor(tag.color.secondary)
)
text_color: QColor
if tag.color and tag.color.secondary:
text_color = QColor(tag.color.secondary)
else:
text_color = get_text_color(primary_color, highlight_color)
if self.is_tag_chooser:
add_button = QPushButton()
add_button.setMinimumSize(22, 22)
add_button.setMaximumSize(22, 22)
add_button.setText("+")
add_button.setStyleSheet(
f"QPushButton{{"
f"background: rgba{primary_color.toTuple()};"
f"color: rgba{text_color.toTuple()};"
f"font-weight: 600;"
f"border-color: rgba{border_color.toTuple()};"
f"border-radius: 6px;"
f"border-style:solid;"
f"border-width: 2px;"
f"padding-bottom: 4px;"
f"font-size: 20px;"
f"}}"
f"QPushButton::hover"
f"{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"color: rgba{primary_color.toTuple()};"
f"background: rgba{highlight_color.toTuple()};"
f"}}"
)
tag_id = tag.id
add_button.clicked.connect(lambda: self.tag_chosen.emit(tag_id))
row.addWidget(add_button)
return container
def build_create_tag_button(self, query: str | None):
"""Constructs a Create Tag Button."""
container = QWidget()
row = QHBoxLayout(container)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(3)
def set_driver(self, driver):
"""Set the QtDriver for this search panel. Used for main window operations."""
self.driver = driver
def build_create_button(self, query: str | None):
"""Constructs a "Create & Add Tag" QPushButton."""
create_button = QPushButton(self)
Translations.translate_qobject(create_button, "tag.create_add", query=query)
create_button.setFlat(True)
inner_layout = QHBoxLayout()
inner_layout.setObjectName("innerLayout")
inner_layout.setContentsMargins(2, 2, 2, 2)
create_button.setLayout(inner_layout)
create_button.setMinimumSize(22, 22)
create_button.setStyleSheet(
@@ -187,7 +132,7 @@ class TagSearchPanel(PanelWidget):
f"font-weight: 600;"
f"border-color:{get_tag_color(ColorType.BORDER, TagColorEnum.DEFAULT)};"
f"border-radius: 6px;"
f"border-style:solid;"
f"border-style:dashed;"
f"border-width: 2px;"
f"padding-right: 4px;"
f"padding-bottom: 1px;"
@@ -197,12 +142,18 @@ class TagSearchPanel(PanelWidget):
f"QPushButton::hover{{"
f"border-color:{get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};"
f"}}"
f"QPushButton::pressed{{"
f"background: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};"
f"color: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)};"
f"border-color: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)};"
f"}}"
f"QPushButton::focus{{"
f"border-color: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};"
f"outline:none;"
f"}}"
)
create_button.clicked.connect(lambda: self.create_and_add_tag(query))
row.addWidget(create_button)
return container
return create_button
def create_and_add_tag(self, name: str):
"""Opens "Create Tag" panel to create and add a new tag with given name."""
@@ -211,51 +162,158 @@ class TagSearchPanel(PanelWidget):
def on_tag_modal_saved():
"""Callback for actions to perform when a new tag is confirmed created."""
tag: Tag = self.build_tag_modal.build_tag()
self.lib.add_tag(tag)
self.lib.add_tag(
tag,
set(self.build_tag_modal.parent_ids),
set(self.build_tag_modal.alias_names),
set(self.build_tag_modal.alias_ids),
)
self.add_tag_modal.hide()
self.tag_chosen.emit(tag.id)
self.search_field.setText("")
self.search_field.setFocus()
self.update_tags()
self.build_tag_modal: BuildTagPanel = build_tag.BuildTagPanel(self.lib)
self.add_tag_modal: PanelModal = PanelModal(self.build_tag_modal, has_save=True)
Translations.translate_with_setter(self.add_tag_modal.setTitle, "tag.new")
Translations.translate_with_setter(self.add_tag_modal.setWindowTitle, "tag.add")
self.add_tag_modal.setTitle(Translations["tag.new"])
self.add_tag_modal.setWindowTitle(Translations["tag.add"])
self.build_tag_modal.name_field.setText(name)
self.add_tag_modal.saved.connect(on_tag_modal_saved)
self.add_tag_modal.save_button.setFocus()
self.add_tag_modal.show()
def update_tags(self, query: str | None = None):
logger.info("[Tag Search Super Class] Updating Tags")
# TODO: Look at recycling rather than deleting and re-initializing
while self.scroll_layout.count():
self.scroll_layout.takeAt(0).widget().deleteLater()
tag_results = self.lib.search_tags(name=query)
if len(tag_results) > 0:
results_1 = []
results_2 = []
for tag in tag_results:
if tag.id in self.exclude:
continue
elif query and tag.name.lower().startswith(query.lower()):
results_1.append(tag)
else:
results_2.append(tag)
results_1.sort(key=lambda tag: self.lib.tag_display_name(tag.id))
results_1.sort(key=lambda tag: len(self.lib.tag_display_name(tag.id)))
results_2.sort(key=lambda tag: self.lib.tag_display_name(tag.id))
self.first_tag_id = results_1[0].id if len(results_1) > 0 else tag_results[0].id
for tag in results_1 + results_2:
self.scroll_layout.addWidget(self.__build_row_item_widget(tag))
else:
# If query doesnt exist add create button
"""Update the tag list given a search query."""
logger.info("[TagSearchPanel] Updating Tags")
# Remove the "Create & Add" button if one exists
create_button: QPushButton | None = None
if self.create_button_in_layout and self.scroll_layout.count():
create_button = self.scroll_layout.takeAt(self.scroll_layout.count() - 1).widget() # type: ignore
create_button.deleteLater()
self.create_button_in_layout = False
# Get results for the search query
query_lower = "" if not query else query.lower()
# Only use the tag limit if it's an actual number (aka not "All Tags")
tag_limit = TagSearchPanel.tag_limit if isinstance(TagSearchPanel.tag_limit, int) else -1
tag_results: list[set[Tag]] = self.lib.search_tags(name=query, limit=tag_limit)
if self.exclude:
tag_results[0] = {t for t in tag_results[0] if t.id not in self.exclude}
tag_results[1] = {t for t in tag_results[1] if t.id not in self.exclude}
# Sort and prioritize the results
results_0 = list(tag_results[0])
results_0.sort(key=lambda tag: tag.name.lower())
results_1 = list(tag_results[1])
results_1.sort(key=lambda tag: tag.name.lower())
raw_results = list(results_0 + results_1)
priority_results: set[Tag] = set()
all_results: list[Tag] = []
if query and query.strip():
for tag in raw_results:
if tag.name.lower().startswith(query_lower):
priority_results.add(tag)
all_results = sorted(list(priority_results), key=lambda tag: len(tag.name)) + [
r for r in raw_results if r not in priority_results
]
if tag_limit > 0:
all_results = all_results[:tag_limit]
if all_results:
self.first_tag_id = None
c = self.build_create_tag_button(query)
self.scroll_layout.addWidget(c)
self.search_field.setFocus()
self.first_tag_id = all_results[0].id if len(all_results) > 0 else all_results[0].id
else:
self.first_tag_id = None
# Update every tag widget with the new search result data
norm_previous = self.previous_limit if self.previous_limit > 0 else len(self.lib.tags)
norm_limit = tag_limit if tag_limit > 0 else len(self.lib.tags)
range_limit = max(norm_previous, norm_limit)
for i in range(0, range_limit):
tag = None
with contextlib.suppress(IndexError):
tag = all_results[i]
self.set_tag_widget(tag=tag, index=i)
self.previous_limit = tag_limit
# Add back the "Create & Add" button
if query and query.strip():
cb: QPushButton = self.build_create_button(query)
cb.setText(Translations.format("tag.create_add", query=query))
with catch_warnings(record=True):
cb.clicked.disconnect()
cb.clicked.connect(lambda: self.create_and_add_tag(query or ""))
self.scroll_layout.addWidget(cb)
self.create_button_in_layout = True
def set_tag_widget(self, tag: Tag | None, index: int):
"""Set the tag of a tag widget at a specific index."""
# Create any new tag widgets needed up to the given index
if self.scroll_layout.count() <= index:
while self.scroll_layout.count() <= index:
new_tw = TagWidget(tag=None, has_edit=True, has_remove=True, library=self.lib)
new_tw.setHidden(True)
self.scroll_layout.addWidget(new_tw)
# Assign the tag to the widget at the given index.
tag_widget: TagWidget = self.scroll_layout.itemAt(index).widget() # type: ignore
tag_widget.set_tag(tag)
# Set tag widget viability and potentially return early
tag_widget.setHidden(bool(not tag))
if not tag:
return
# Configure any other aspects of the tag widget
has_remove_button = False
if not self.is_tag_chooser:
has_remove_button = tag.id not in range(RESERVED_TAG_START, RESERVED_TAG_END)
tag_widget.has_remove = has_remove_button
with catch_warnings(record=True):
tag_widget.on_edit.disconnect()
tag_widget.on_remove.disconnect()
tag_widget.bg_button.clicked.disconnect()
tag_id = tag.id
tag_widget.on_edit.connect(lambda t=tag: self.edit_tag(t))
tag_widget.on_remove.connect(lambda t=tag: self.delete_tag(t))
tag_widget.bg_button.clicked.connect(lambda: self.tag_chosen.emit(tag_id))
if self.driver:
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)),
)
)
tag_widget.search_for_tag_action.setEnabled(True)
else:
tag_widget.search_for_tag_action.setEnabled(False)
def update_limit(self, index: int):
logger.info("[TagSearchPanel] Updating tag limit")
TagSearchPanel.cur_limit_idx = index
if index < len(self._limit_items) - 1:
TagSearchPanel.tag_limit = int(self._limit_items[index])
else:
TagSearchPanel.tag_limit = -1
# Method was called outside the limit_combobox callback
if index != self.limit_combobox.currentIndex():
self.limit_combobox.setCurrentIndex(index)
if self.previous_limit == TagSearchPanel.tag_limit:
return
self.update_tags(self.search_field.text())
def on_return(self, text: str):
if text:
@@ -271,12 +329,25 @@ class TagSearchPanel(PanelWidget):
self.parentWidget().hide()
def showEvent(self, event: QShowEvent) -> None: # noqa N802
if not self.is_initialized:
self.update_tags()
self.is_initialized = True
self.update_limit(TagSearchPanel.cur_limit_idx)
self.update_tags()
self.scroll_area.verticalScrollBar().setValue(0)
self.search_field.setText("")
self.search_field.setFocus()
return super().showEvent(event)
def remove_tag(self, tag: Tag):
@override
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
# When Escape is pressed, focus back on the search box.
# If focus is already on the search box, close the modal.
if event.key() == QtCore.Qt.Key.Key_Escape:
if self.search_field.hasFocus():
return super().keyPressEvent(event)
else:
self.search_field.setFocus()
self.search_field.selectAll()
def delete_tag(self, tag: Tag):
pass
def edit_tag(self, tag: Tag):
@@ -294,7 +365,7 @@ class TagSearchPanel(PanelWidget):
done_callback=(self.update_tags(self.search_field.text())),
has_save=True,
)
Translations.translate_with_setter(self.edit_modal.setWindowTitle, "tag.edit")
self.edit_modal.setWindowTitle(Translations["tag.edit"])
self.edit_modal.saved.connect(lambda: callback(build_tag_panel))
self.edit_modal.show()

View File

@@ -1,4 +1,4 @@
# 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
@@ -9,10 +9,17 @@ import platform
from src.qt.translations import Translations
class PlatformStrings:
open_file_str: str = Translations["file.open_location.generic"]
def open_file_str() -> str:
if platform.system() == "Windows":
open_file_str = Translations["file.open_location.windows"]
return Translations["file.open_location.windows"]
elif platform.system() == "Darwin":
open_file_str = Translations["file.open_location.mac"]
return Translations["file.open_location.mac"]
else:
return Translations["file.open_location.generic"]
def trash_term() -> str:
if platform.system() == "Windows":
return Translations["trash.name.windows"]
else:
return Translations["trash.name.generic"]

View File

@@ -1,47 +1,22 @@
from collections import defaultdict
from pathlib import Path
from typing import Callable
from typing import Any
import structlog
import ujson
from PySide6.QtCore import QObject, Signal
from PySide6.QtGui import QAction
from PySide6.QtWidgets import QLabel, QMenu, QMessageBox, QPushButton
from .helpers.qbutton_wrapper import QPushButtonWrapper
logger = structlog.get_logger(__name__)
DEFAULT_TRANSLATION = "en"
class TranslatedString(QObject):
changed = Signal(str)
__default_value: str
__value: str | None = None
def __init__(self, value: str):
super().__init__()
self.__default_value = value
@property
def value(self) -> str:
return self.__value or self.__default_value
@value.setter
def value(self, value: str):
if self.__value != value:
self.__value = value
self.changed.emit(self.__value)
class Translator:
_strings: dict[str, TranslatedString] = {}
_default_strings: dict[str, str]
_strings: dict[str, str] = {}
_lang: str = DEFAULT_TRANSLATION
def __init__(self):
for k, v in self.__get_translation_dict(DEFAULT_TRANSLATION).items():
self._strings[k] = TranslatedString(v)
self._default_strings = self.__get_translation_dict(DEFAULT_TRANSLATION)
def __get_translation_dict(self, lang: str) -> dict[str, str]:
with open(
@@ -52,44 +27,27 @@ class Translator:
def change_language(self, lang: str):
self._lang = lang
translated = self.__get_translation_dict(lang)
for k in self._strings:
self._strings[k].value = translated.get(k, None)
def translate_qobject(self, widget: QObject, key: str, **kwargs):
"""Translates the text of the QObject using :func:`translate_with_setter`."""
if isinstance(widget, (QLabel, QAction, QPushButton, QMessageBox, QPushButtonWrapper)):
self.translate_with_setter(widget.setText, key, **kwargs)
elif isinstance(widget, (QMenu)):
self.translate_with_setter(widget.setTitle, key, **kwargs)
else:
raise RuntimeError
def translate_with_setter(self, setter: Callable[[str], None], key: str, **kwargs):
"""Calls `setter` everytime the language changes and passes the translated string for `key`.
Also formats the translation with the given keyword arguments.
"""
if key in self._strings:
self._strings[key].changed.connect(lambda text: setter(self.__format(text, **kwargs)))
setter(self.translate_formatted(key, **kwargs))
self._strings = self.__get_translation_dict(lang)
def __format(self, text: str, **kwargs) -> str:
try:
return text.format(**kwargs)
except KeyError:
logger.warning(
"Error while formatting translation.", text=text, kwargs=kwargs, language=self._lang
except (KeyError, ValueError):
logger.error(
"[Translations] Error while formatting translation.",
text=text,
kwargs=kwargs,
language=self._lang,
)
return text
params: defaultdict[str, Any] = defaultdict(lambda: "{unknown_key}")
params.update(kwargs)
return text.format_map(params)
def translate_formatted(self, key: str, **kwargs) -> str:
def format(self, key: str, **kwargs) -> str:
return self.__format(self[key], **kwargs)
def __getitem__(self, key: str) -> str:
# return "???"
return self._strings[key].value if key in self._strings else "Not Translated"
return self._strings.get(key) or self._default_strings.get(key) or f"[{key}]"
Translations = Translator()
# Translations.change_language("de")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,165 @@
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import typing
from collections.abc import Iterable
import structlog
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QMessageBox, QPushButton
from src.core.constants import RESERVED_NAMESPACE_PREFIX
from src.core.library.alchemy.enums import TagColorEnum
from src.core.library.alchemy.models import TagColorGroup
from src.core.palette import ColorType, get_tag_color
from src.qt.flowlayout import FlowLayout
from src.qt.modals.build_color import BuildColorPanel
from src.qt.translations import Translations
from src.qt.widgets.fields import FieldWidget
from src.qt.widgets.panel import PanelModal
from src.qt.widgets.tag_color_label import TagColorLabel
if typing.TYPE_CHECKING:
from src.core.library import Library
logger = structlog.get_logger(__name__)
class ColorBoxWidget(FieldWidget):
updated = Signal()
def __init__(
self,
group: str,
colors: list["TagColorGroup"],
library: "Library",
) -> None:
self.namespace = group
self.colors: list[TagColorGroup] = colors
self.lib: Library = library
title = "" if not self.lib.engine else self.lib.get_namespace_name(group)
super().__init__(title)
self.add_button_stylesheet = (
f"QPushButton{{"
f"background: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)};"
f"color: {get_tag_color(ColorType.TEXT, TagColorEnum.DEFAULT)};"
f"font-weight: 600;"
f"border-color:{get_tag_color(ColorType.BORDER, TagColorEnum.DEFAULT)};"
f"border-radius: 6px;"
f"border-style:solid;"
f"border-width: 2px;"
f"padding-right: 4px;"
f"padding-bottom: 2px;"
f"padding-left: 4px;"
f"font-size: 15px"
f"}}"
f"QPushButton::hover{{"
f"border-color:{get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};"
f"}}"
f"QPushButton::pressed{{"
f"background: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};"
f"color: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)};"
f"border-color: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)};"
f"}}"
f"QPushButton::focus{{"
f"border-color: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};"
f"outline:none;"
f"}}"
)
self.setObjectName("colorBox")
self.base_layout = FlowLayout()
self.base_layout.enable_grid_optimizations(value=True)
self.base_layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self.base_layout)
self.set_colors(self.colors)
def set_colors(self, colors: Iterable[TagColorGroup]):
colors_ = sorted(
list(colors), key=lambda color: self.lib.get_namespace_name(color.namespace)
)
is_mutable = not self.namespace.startswith(RESERVED_NAMESPACE_PREFIX)
max_width = 60
color_widgets: list[TagColorLabel] = []
while self.base_layout.itemAt(0):
self.base_layout.takeAt(0).widget().deleteLater()
for color in colors_:
color_widget = TagColorLabel(
color=color,
has_edit=is_mutable,
has_remove=is_mutable,
library=self.lib,
)
hint = color_widget.sizeHint().width()
if hint > max_width:
max_width = hint
color_widget.on_click.connect(lambda c=color: self.edit_color(c))
color_widget.on_remove.connect(lambda c=color: self.delete_color(c))
color_widgets.append(color_widget)
self.base_layout.addWidget(color_widget)
for color_widget in color_widgets:
color_widget.setFixedWidth(max_width)
if is_mutable:
add_button = QPushButton()
add_button.setText("+")
add_button.setFlat(True)
add_button.setFixedSize(22, 22)
add_button.setStyleSheet(self.add_button_stylesheet)
add_button.clicked.connect(
lambda: self.edit_color(
TagColorGroup(
slug="slug",
namespace=self.namespace,
name="Color",
primary="#FFFFFF",
secondary=None,
)
)
)
self.base_layout.addWidget(add_button)
def edit_color(self, color_group: TagColorGroup):
build_color_panel = BuildColorPanel(self.lib, color_group)
self.edit_modal = PanelModal(
build_color_panel,
"Edit Color",
"Edit Color",
has_save=True,
)
self.edit_modal.saved.connect(
lambda: (self.lib.update_color(*build_color_panel.build_color()), self.updated.emit())
)
self.edit_modal.show()
def delete_color(self, color_group: TagColorGroup):
message_box = QMessageBox(
QMessageBox.Icon.Warning,
Translations["color.delete"],
Translations.format("color.confirm_delete", color_name=color_group.name),
)
cancel_button = message_box.addButton(
Translations["generic.cancel_alt"], QMessageBox.ButtonRole.RejectRole
)
message_box.addButton(
Translations["generic.delete_alt"], QMessageBox.ButtonRole.DestructiveRole
)
message_box.setEscapeButton(cancel_button)
result = message_box.exec_()
logger.info(QMessageBox.ButtonRole.DestructiveRole.value)
if result != QMessageBox.ButtonRole.ActionRole.value:
return
logger.info("[ColorBoxWidget] Removing color", color=color_group)
self.lib.delete_color(color_group)
self.updated.emit()

View File

@@ -8,12 +8,15 @@ from pathlib import Path
from typing import Callable
from warnings import catch_warnings
import structlog
from PIL import Image, ImageQt
from PySide6.QtCore import QEvent, Qt
from PySide6.QtGui import QEnterEvent, QPixmap
from PySide6.QtGui import QEnterEvent, QPixmap, QResizeEvent
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
from src.core.enums import Theme
logger = structlog.get_logger(__name__)
class FieldContainer(QWidget):
# TODO: reference a resources folder rather than path.parents[3]?
@@ -70,7 +73,7 @@ class FieldContainer(QWidget):
self.title_container = QWidget()
self.title_layout = QHBoxLayout(self.title_container)
self.title_layout.setAlignment(Qt.AlignmentFlag.AlignHCenter)
self.title_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.title_layout.setObjectName("fieldLayout")
self.title_layout.setContentsMargins(0, 0, 0, 0)
self.title_layout.setSpacing(0)
@@ -122,6 +125,7 @@ class FieldContainer(QWidget):
self.field.setLayout(self.field_layout)
self.inner_layout.addWidget(self.field)
self.set_title(title)
self.setStyleSheet(FieldContainer.container_style)
def set_copy_callback(self, callback: Callable | None = None):
@@ -187,6 +191,10 @@ class FieldContainer(QWidget):
self.remove_button.setHidden(True)
return super().leaveEvent(event)
def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802
self.title_widget.setFixedWidth(int(event.size().width() // 1.5))
return super().resizeEvent(event)
class FieldWidget(QWidget):
def __init__(self, title) -> None:

View File

@@ -29,7 +29,7 @@ from src.core.library import ItemType, Library
from src.core.media_types import MediaCategories, MediaType
from src.qt.flowlayout import FlowWidget
from src.qt.helpers.file_opener import FileOpenerHelper
from src.qt.platform_strings import PlatformStrings
from src.qt.platform_strings import open_file_str, trash_term
from src.qt.translations import Translations
from src.qt.widgets.thumb_button import ThumbButton
from src.qt.widgets.thumb_renderer import ThumbRenderer
@@ -216,13 +216,19 @@ class ItemThumb(FlowWidget):
self.thumb_button.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
self.opener = FileOpenerHelper("")
open_file_action = QAction(self)
Translations.translate_qobject(open_file_action, "file.open_file")
open_file_action = QAction(Translations["file.open_file"], self)
open_file_action.triggered.connect(self.opener.open_file)
open_explorer_action = QAction(PlatformStrings.open_file_str, self)
open_explorer_action = QAction(open_file_str(), self)
open_explorer_action.triggered.connect(self.opener.open_explorer)
self.delete_action = QAction(
Translations.format("trash.context.ambiguous", trash_term=trash_term()),
self,
)
self.thumb_button.addAction(open_file_action)
self.thumb_button.addAction(open_explorer_action)
self.thumb_button.addAction(self.delete_action)
# Static Badges ========================================================
@@ -306,8 +312,7 @@ class ItemThumb(FlowWidget):
self.cb_layout.addWidget(badge)
# Filename Label =======================================================
self.file_label = QLabel()
Translations.translate_qobject(self.file_label, "generic.filename")
self.file_label = QLabel(Translations["generic.filename"])
self.file_label.setStyleSheet(ItemThumb.filename_style)
self.file_label.setMaximumHeight(self.label_height)
if not show_filename_label:
@@ -492,15 +497,11 @@ class ItemThumb(FlowWidget):
toggle_value: bool,
tag_id: int,
):
logger.info("toggle_item_tag", entry_id=entry_id, toggle_value=toggle_value, tag_id=tag_id)
if toggle_value:
self.lib.add_tags_to_entry(entry_id, tag_id)
else:
self.lib.remove_tags_from_entry(entry_id, tag_id)
if self.driver.preview_panel.is_open:
self.driver.preview_panel.update_widgets(update_preview=False)
if entry_id in self.driver.selected and self.driver.preview_panel.is_open:
if len(self.driver.selected) == 1:
self.driver.preview_panel.fields.update_toggled_tag(tag_id, toggle_value)
else:
pass
def mouseMoveEvent(self, event): # noqa: N802
if event.buttons() is not Qt.MouseButton.LeftButton:

View File

@@ -61,11 +61,10 @@ class LandingWidget(QWidget):
open_shortcut_text = "(⌘+O)"
else:
open_shortcut_text = "(Ctrl+O)"
self.open_button: QPushButton = QPushButton()
self.open_button.setMinimumWidth(200)
Translations.translate_qobject(
self.open_button, "landing.open_create_library", shortcut=open_shortcut_text
self.open_button: QPushButton = QPushButton(
Translations.format("landing.open_create_library", shortcut=open_shortcut_text)
)
self.open_button.setMinimumWidth(200)
self.open_button.clicked.connect(self.driver.open_library_from_dialog)
# Create status label --------------------------------------------------
@@ -161,7 +160,7 @@ class LandingWidget(QWidget):
# self.status_pos_anim.setEndValue(self.status_label.pos())
# self.status_pos_anim.start()
def set_status_label(self, text=str):
def set_status_label(self, text: str):
"""Set the text of the status label.
Args:

View File

@@ -57,7 +57,7 @@ class JsonMigrationModal(QObject):
self.is_migration_initialized: bool = False
self.discrepancies: list[str] = []
self.title: str = Translations.translate_formatted("json_migration.title", path=self.path)
self.title: str = Translations.format("json_migration.title", path=self.path)
self.warning: str = "<b><a style='color: #e22c3c'>(!)</a></b>"
self.old_entry_count: int = 0
@@ -81,17 +81,14 @@ class JsonMigrationModal(QObject):
def init_page_info(self) -> None:
"""Initialize the migration info page."""
body_wrapper: PagedBodyWrapper = PagedBodyWrapper()
body_label = QLabel()
Translations.translate_qobject(body_label, "json_migration.info.description")
body_label = QLabel(Translations["json_migration.info.description"])
body_label.setWordWrap(True)
body_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
body_wrapper.layout().addWidget(body_label)
body_wrapper.layout().setContentsMargins(0, 36, 0, 0)
cancel_button = QPushButtonWrapper()
Translations.translate_qobject(cancel_button, "generic.cancel")
next_button = QPushButtonWrapper()
Translations.translate_qobject(next_button, "generic.continue")
cancel_button = QPushButtonWrapper(Translations["generic.cancel"])
next_button = QPushButtonWrapper(Translations["generic.continue"])
cancel_button.clicked.connect(self.migration_cancelled.emit)
self.stack.append(
@@ -142,8 +139,7 @@ class JsonMigrationModal(QObject):
old_lib_container: QWidget = QWidget()
old_lib_layout: QVBoxLayout = QVBoxLayout(old_lib_container)
old_lib_title = QLabel()
Translations.translate_qobject(old_lib_title, "json_migration.title.old_lib")
old_lib_title = QLabel(Translations["json_migration.title.old_lib"])
old_lib_title.setAlignment(Qt.AlignmentFlag.AlignCenter)
old_lib_layout.addWidget(old_lib_title)
@@ -210,8 +206,7 @@ class JsonMigrationModal(QObject):
new_lib_container: QWidget = QWidget()
new_lib_layout: QVBoxLayout = QVBoxLayout(new_lib_container)
new_lib_title = QLabel()
Translations.translate_qobject(new_lib_title, "json_migration.title.new_lib")
new_lib_title = QLabel(Translations["json_migration.title.new_lib"])
new_lib_title.setAlignment(Qt.AlignmentFlag.AlignCenter)
new_lib_layout.addWidget(new_lib_title)
@@ -292,15 +287,14 @@ class JsonMigrationModal(QObject):
self.body_wrapper_01.layout().addWidget(desc_label)
self.body_wrapper_01.layout().setSpacing(12)
back_button = QPushButtonWrapper()
Translations.translate_qobject(back_button, "generic.navigation.back")
start_button = QPushButtonWrapper()
Translations.translate_qobject(start_button, "json_migration.start_and_preview")
back_button = QPushButtonWrapper(Translations["generic.navigation.back"])
start_button = QPushButtonWrapper(Translations["json_migration.start_and_preview"])
start_button.setMinimumWidth(120)
start_button.clicked.connect(self.migrate)
start_button.clicked.connect(lambda: start_button.setDisabled(True))
finish_button: QPushButtonWrapper = QPushButtonWrapper()
Translations.translate_qobject(finish_button, "json_migration.finish_migration")
finish_button: QPushButtonWrapper = QPushButtonWrapper(
Translations["json_migration.finish_migration"]
)
finish_button.setMinimumWidth(120)
finish_button.setDisabled(True)
finish_button.clicked.connect(self.finish_migration)
@@ -367,29 +361,35 @@ class JsonMigrationModal(QObject):
pb.setCancelButton(None)
self.body_wrapper_01.layout().addWidget(pb)
iterator = FunctionIterator(self.migration_iterator)
iterator.value.connect(
lambda x: (
pb.setLabelText(f"<h4>{x}</h4>"),
self.update_sql_value_ui(show_msg_box=False)
if x == Translations["json_migration.checking_for_parity"]
else (),
self.update_parity_ui()
if x == Translations["json_migration.checking_for_parity"]
else (),
try:
iterator = FunctionIterator(self.migration_iterator)
iterator.value.connect(
lambda x: (
pb.setLabelText(f"<h4>{x}</h4>"),
self.update_sql_value_ui(show_msg_box=False)
if x == Translations["json_migration.checking_for_parity"]
else (),
self.update_parity_ui()
if x == Translations["json_migration.checking_for_parity"]
else (),
)
)
)
r = CustomRunnable(iterator.run)
r.done.connect(
lambda: (
self.update_sql_value_ui(show_msg_box=not skip_ui),
pb.setMinimum(1),
pb.setValue(1),
# Enable the finish button
self.stack[1].buttons[4].setDisabled(False), # type: ignore
r = CustomRunnable(iterator.run)
r.done.connect(
lambda: (
self.update_sql_value_ui(show_msg_box=not skip_ui),
pb.setMinimum(1),
pb.setValue(1),
# Enable the finish button
self.stack[1].buttons[4].setDisabled(False), # type: ignore
)
)
)
QThreadPool.globalInstance().start(r)
QThreadPool.globalInstance().start(r)
except Exception as e:
logger.error("[MigrationModal][Iterator] Error:", error=e)
pb.setLabelText(f"<h4>{type(e).__name__}</h4>")
pb.setMinimum(1)
pb.setValue(1)
def migration_iterator(self):
"""Iterate over the library migration process."""
@@ -405,7 +405,7 @@ class JsonMigrationModal(QObject):
logger.info('Temporary migration file "temp_path" already exists. Removing...')
self.temp_path.unlink()
self.sql_lib.open_sqlite_library(self.json_lib.library_dir, is_new=True)
yield Translations.translate_formatted(
yield Translations.format(
"json_migration.migrating_files_entries", entries=len(self.json_lib.entries)
)
self.sql_lib.migrate_json_to_sqlite(self.json_lib)
@@ -475,15 +475,12 @@ class JsonMigrationModal(QObject):
QApplication.alert(self.paged_panel)
if not show_msg_box:
return
msg_box = QMessageBox()
Translations.translate_with_setter(
msg_box.setWindowTitle, "json_migration.discrepancies_found"
)
Translations.translate_qobject(
msg_box, "json_migration.discrepancies_found.description"
msg_box = QMessageBox(
QMessageBox.Icon.Warning,
Translations["json_migration.discrepancies_found"],
Translations["json_migration.discrepancies_found.description"],
)
msg_box.setDetailedText("\n".join(self.discrepancies))
msg_box.setIcon(QMessageBox.Icon.Warning)
msg_box.exec()
def finish_migration(self):

View File

@@ -1,8 +1,11 @@
# 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
from typing import override
import structlog
from PySide6 import QtCore, QtGui
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QHBoxLayout,
@@ -110,3 +113,11 @@ class PagedPanel(QWidget):
item.setHidden(False)
elif isinstance(item, int):
self.button_nav_layout.addStretch(item)
@override
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
if event.key() == QtCore.Qt.Key.Key_Escape:
self.close()
else: # Other key presses
pass
return super().keyPressEvent(event)

View File

@@ -15,8 +15,8 @@ class PagedPanelState:
title: str,
body_wrapper: PagedBodyWrapper,
buttons: list[QPushButton | int],
connect_to_back=list[QPushButton],
connect_to_next=list[QPushButton],
connect_to_back: list[QPushButton],
connect_to_next: list[QPushButton],
):
self.title: str = title
self.body_wrapper: PagedBodyWrapper = body_wrapper

View File

@@ -1,13 +1,16 @@
# 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
from typing import Callable
import structlog
from PySide6 import QtCore, QtGui
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
from src.qt.translations import Translations
logger = structlog.get_logger(__name__)
class PanelModal(QWidget):
saved = Signal()
@@ -49,8 +52,7 @@ class PanelModal(QWidget):
# self.cancel_button.setText('Cancel')
if not (save_callback or has_save):
self.done_button = QPushButton()
Translations.translate_qobject(self.done_button, "generic.done")
self.done_button = QPushButton(Translations["generic.done"])
self.done_button.setAutoDefault(True)
self.done_button.clicked.connect(self.hide)
if done_callback:
@@ -59,16 +61,14 @@ class PanelModal(QWidget):
self.button_layout.addWidget(self.done_button)
if save_callback or has_save:
self.cancel_button = QPushButton()
Translations.translate_qobject(self.cancel_button, "generic.cancel")
self.cancel_button = QPushButton(Translations["generic.cancel"])
self.cancel_button.clicked.connect(self.hide)
self.cancel_button.clicked.connect(widget.reset)
# self.cancel_button.clicked.connect(cancel_callback)
self.widget.panel_cancel_button = self.cancel_button
self.button_layout.addWidget(self.cancel_button)
self.save_button = QPushButton()
Translations.translate_qobject(self.save_button, "generic.save")
self.save_button = QPushButton(Translations["generic.save"])
self.save_button.setAutoDefault(True)
self.save_button.clicked.connect(self.hide)
self.save_button.clicked.connect(self.saved.emit)
@@ -95,7 +95,10 @@ class PanelModal(QWidget):
widget.parent_post_init()
def closeEvent(self, event): # noqa: N802
self.done_button.click()
if self.cancel_button:
self.cancel_button.click()
elif self.done_button:
self.done_button.click()
event.accept()
def setTitle(self, title: str): # noqa: N802
@@ -124,4 +127,19 @@ class PanelWidget(QWidget):
pass
def add_callback(self, callback: Callable, event: str = "returnPressed"):
logging.warning(f"add_callback not implemented for {self.__class__.__name__}")
logger.warning(f"[PanelModal] add_callback not implemented for {self.__class__.__name__}")
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
if event.key() == QtCore.Qt.Key.Key_Escape:
if self.panel_cancel_button:
self.panel_cancel_button.click()
elif self.panel_done_button:
self.panel_done_button.click()
elif event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter:
if self.panel_save_button:
self.panel_save_button.click()
elif self.panel_done_button:
self.panel_done_button.click()
else: # Other key presses
pass
return super().keyPressEvent(event)

View File

@@ -114,13 +114,18 @@ class FieldContainers(QWidget):
logger.warning("[FieldContainers] Updating Selection", entry_id=entry_id)
self.cached_entries = [self.lib.get_entry_full(entry_id)]
entry_ = self.cached_entries[0]
container_len: int = len(entry_.fields)
container_index = 0
entry = self.cached_entries[0]
self.update_granular(entry.tags, entry.fields, update_badges)
def update_granular(
self, entry_tags: set[Tag], entry_fields: list[BaseField], update_badges: bool = True
):
"""Individually update elements of the item preview."""
container_len: int = len(entry_fields)
container_index = 0
# Write tag container(s)
if entry_.tags:
categories = self.get_tag_categories(entry_.tags)
if entry_tags:
categories = self.get_tag_categories(entry_tags)
for cat, tags in sorted(categories.items(), key=lambda kv: (kv[0] is None, kv)):
self.write_tag_container(
container_index, tags=tags, category_tag=cat, is_mixed=False
@@ -128,10 +133,10 @@ class FieldContainers(QWidget):
container_index += 1
container_len += 1
if update_badges:
self.emit_badge_signals({t.id for t in entry_.tags})
self.emit_badge_signals({t.id for t in entry_tags})
# Write field container(s)
for index, field in enumerate(entry_.fields, start=container_index):
for index, field in enumerate(entry_fields, start=container_index):
self.write_container(index, field, is_mixed=False)
# Hide leftover container(s)
@@ -140,6 +145,17 @@ class FieldContainers(QWidget):
if i > (container_len - 1):
c.setHidden(True)
def update_toggled_tag(self, tag_id: int, toggle_value: bool):
"""Visually add or remove a tag from the item preview without needing to query the db."""
entry = self.cached_entries[0]
tag = self.lib.get_tag(tag_id)
if not tag:
return
new_tags = (
entry.tags.union({tag}) if toggle_value else {t for t in entry.tags if t.id != tag_id}
)
self.update_granular(entry_tags=new_tags, entry_fields=entry.fields, update_badges=False)
def hide_containers(self):
"""Hide all field and tag containers."""
for c in self.containers:
@@ -230,7 +246,7 @@ class FieldContainers(QWidget):
return cats
def remove_field_prompt(self, name: str) -> str:
return Translations.translate_formatted("library.field.confirm_remove", name=name)
return Translations.format("library.field.confirm_remove", name=name)
def add_field_to_selected(self, field_list: list):
"""Add list of entry fields to one or more selected items.
@@ -262,7 +278,7 @@ class FieldContainers(QWidget):
tags=tags,
)
for entry_id in self.driver.selected:
self.lib.add_tags_to_entry(
self.lib.add_tags_to_entries(
entry_id,
tag_ids=tags,
)
@@ -500,10 +516,9 @@ class FieldContainers(QWidget):
Translations["generic.cancel_alt"], QMessageBox.ButtonRole.DestructiveRole
)
remove_mb.addButton("&Remove", QMessageBox.ButtonRole.RejectRole)
remove_mb.setDefaultButton(cancel_button)
remove_mb.setEscapeButton(cancel_button)
result = remove_mb.exec_()
if result == 3: # TODO - what is this magic number?
if result == QMessageBox.ButtonRole.ActionRole.value:
callback()
def emit_badge_signals(self, tag_ids: list[int] | set[int], emit_on_absent: bool = True):

View File

@@ -23,6 +23,7 @@ from src.core.enums import Theme
from src.core.library.alchemy.library import Library
from src.core.media_types import MediaCategories
from src.qt.helpers.file_opener import FileOpenerHelper, FileOpenerLabel
from src.qt.translations import Translations
if typing.TYPE_CHECKING:
from src.qt.ts_qt import QtDriver
@@ -108,16 +109,22 @@ class FileAttributes(QWidget):
created = dt.fromtimestamp(filepath.stat().st_ctime)
modified: dt = dt.fromtimestamp(filepath.stat().st_mtime)
self.date_created_label.setText(
f"<b>Date Created:</b> {dt.strftime(created, "%a, %x, %X")}" # TODO: Translate
f"<b>{Translations["file.date_created"]}:</b> "
f"{dt.strftime(created, "%a, %x, %X")}"
)
self.date_modified_label.setText(
f"<b>Date Modified:</b> {dt.strftime(modified, "%a, %x, %X")}" # TODO: Translate
f"<b>{Translations["file.date_modified"]}:</b> "
f"{dt.strftime(modified, "%a, %x, %X")}"
)
self.date_created_label.setHidden(False)
self.date_modified_label.setHidden(False)
elif filepath:
self.date_created_label.setText("<b>Date Created:</b> <i>N/A</i>") # TODO: Translate
self.date_modified_label.setText("<b>Date Modified:</b> <i>N/A</i>") # TODO: Translate
self.date_created_label.setText(
f"<b>{Translations["file.date_created"]}:</b> <i>N/A</i>"
)
self.date_modified_label.setText(
f"<b>{Translations["file.date_modified"]}:</b> <i>N/A</i>"
)
self.date_created_label.setHidden(False)
self.date_modified_label.setHidden(False)
else:
@@ -132,7 +139,7 @@ class FileAttributes(QWidget):
if not filepath:
self.layout().setSpacing(0)
self.file_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.file_label.setText("<i>No Items Selected</i>") # TODO: Translate
self.file_label.setText(f"<i>{Translations["preview.no_selection"]}</i>")
self.file_label.set_file_path("")
self.file_label.setCursor(Qt.CursorShape.ArrowCursor)
self.dimensions_label.setText("")
@@ -204,11 +211,14 @@ class FileAttributes(QWidget):
if duration_text:
stats_label_text = add_newline(stats_label_text)
dur_str = str(timedelta(seconds=float(duration_text)))[:-7]
if dur_str.startswith("0:"):
dur_str = dur_str[2:]
if dur_str.startswith("0"):
dur_str = dur_str[1:]
try:
dur_str = str(timedelta(seconds=float(duration_text)))[:-7]
if dur_str.startswith("0:"):
dur_str = dur_str[2:]
if dur_str.startswith("0"):
dur_str = dur_str[1:]
except OverflowError:
dur_str = "-:--"
stats_label_text += f"{dur_str}"
if font_family:
@@ -221,7 +231,7 @@ class FileAttributes(QWidget):
"""Format attributes for multiple selected items."""
self.layout().setSpacing(0)
self.file_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.file_label.setText(f"<b>{count}</b> Items Selected") # TODO: Translate
self.file_label.setText(Translations.format("preview.multiple_selection", count=count))
self.file_label.setCursor(Qt.CursorShape.ArrowCursor)
self.file_label.set_file_path("")
self.dimensions_label.setText("")

View File

@@ -6,6 +6,7 @@ import io
import time
import typing
from pathlib import Path
from warnings import catch_warnings
import cv2
import rawpy
@@ -24,7 +25,8 @@ from src.qt.helpers.file_opener import FileOpenerHelper, open_file
from src.qt.helpers.file_tester import is_readable_video
from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper
from src.qt.helpers.rounded_pixmap_style import RoundedPixmapStyle
from src.qt.platform_strings import PlatformStrings
from src.qt.platform_strings import open_file_str, trash_term
from src.qt.resource_manager import ResourceManager
from src.qt.translations import Translations
from src.qt.widgets.media_player import MediaPlayer
from src.qt.widgets.thumb_renderer import ThumbRenderer
@@ -52,9 +54,12 @@ class PreviewThumb(QWidget):
image_layout = QHBoxLayout(self)
image_layout.setContentsMargins(0, 0, 0, 0)
self.open_file_action = QAction(self)
Translations.translate_qobject(self.open_file_action, "file.open_file")
self.open_explorer_action = QAction(PlatformStrings.open_file_str, self)
self.open_file_action = QAction(Translations["file.open_file"], self)
self.open_explorer_action = QAction(open_file_str(), self)
self.delete_action = QAction(
Translations.format("trash.context.ambiguous", trash_term=trash_term()),
self,
)
self.preview_img = QPushButtonWrapper()
self.preview_img.setMinimumSize(*self.img_button_size)
@@ -62,6 +67,7 @@ class PreviewThumb(QWidget):
self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
self.preview_img.addAction(self.open_file_action)
self.preview_img.addAction(self.open_explorer_action)
self.preview_img.addAction(self.delete_action)
self.preview_gif = QLabel()
self.preview_gif.setMinimumSize(*self.img_button_size)
@@ -69,10 +75,12 @@ class PreviewThumb(QWidget):
self.preview_gif.setCursor(Qt.CursorShape.ArrowCursor)
self.preview_gif.addAction(self.open_file_action)
self.preview_gif.addAction(self.open_explorer_action)
self.preview_gif.addAction(self.delete_action)
self.preview_gif.hide()
self.gif_buffer: QBuffer = QBuffer()
self.preview_vid = VideoPlayer(driver)
self.preview_vid.addAction(self.delete_action)
self.preview_vid.hide()
self.thumb_renderer = ThumbRenderer(self.lib)
self.thumb_renderer.updated.connect(lambda ts, i, s: (self.preview_img.setIcon(i)))
@@ -355,7 +363,7 @@ class PreviewThumb(QWidget):
update_on_ratio_change=True,
)
if self.preview_img.is_connected:
with catch_warnings(record=True):
self.preview_img.clicked.disconnect()
self.preview_img.clicked.connect(lambda checked=False, path=filepath: open_file(path))
self.preview_img.is_connected = True
@@ -367,12 +375,31 @@ class PreviewThumb(QWidget):
self.open_file_action.triggered.connect(self.opener.open_file)
self.open_explorer_action.triggered.connect(self.opener.open_explorer)
with catch_warnings(record=True):
self.delete_action.triggered.disconnect()
self.delete_action.setText(
Translations.format("trash.context.singular", trash_term=trash_term())
)
self.delete_action.triggered.connect(
lambda checked=False, f=filepath: self.driver.delete_files_callback(f)
)
self.delete_action.setEnabled(bool(filepath))
return stats
def hide_preview(self):
"""Completely hide the file preview."""
self.switch_preview("")
def stop_file_use(self):
"""Stops the use of the currently previewed file. Used to release file permissions."""
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()
def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802
self.update_image_size((self.size().width(), self.size().height()))
return super().resizeEvent(event)

View File

@@ -74,10 +74,8 @@ class PreviewPanel(QWidget):
self.fields = FieldContainers(library, driver)
self.tag_search_panel = TagSearchPanel(self.driver.lib, is_tag_chooser=True)
self.add_tag_modal = PanelModal(
self.tag_search_panel, Translations.translate_formatted("tag.add.plural")
)
Translations.translate_with_setter(self.add_tag_modal.setWindowTitle, "tag.add.plural")
self.add_tag_modal = PanelModal(self.tag_search_panel, Translations["tag.add.plural"])
self.add_tag_modal.setWindowTitle(Translations["tag.add.plural"])
self.add_field_modal = AddFieldModal(self.lib)
@@ -100,19 +98,17 @@ class PreviewPanel(QWidget):
add_buttons_layout.setContentsMargins(0, 0, 0, 0)
add_buttons_layout.setSpacing(6)
self.add_tag_button = QPushButton()
self.add_tag_button = QPushButton(Translations["tag.add"])
self.add_tag_button.setEnabled(False)
self.add_tag_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.add_tag_button.setMinimumHeight(28)
self.add_tag_button.setStyleSheet(PreviewPanel.button_style)
self.add_tag_button.setText("Add Tag") # TODO: Translate
self.add_field_button = QPushButton()
self.add_field_button = QPushButton(Translations["library.field.add"])
self.add_field_button.setEnabled(False)
self.add_field_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.add_field_button.setMinimumHeight(28)
self.add_field_button.setStyleSheet(PreviewPanel.button_style)
self.add_field_button.setText("Add Field") # TODO: Translate
add_buttons_layout.addWidget(self.add_tag_button)
add_buttons_layout.addWidget(self.add_field_button)
@@ -211,9 +207,4 @@ class PreviewPanel(QWidget):
)
)
self.add_tag_button.clicked.connect(
lambda: (
self.tag_search_panel.update_tags(),
self.add_tag_modal.show(),
)
)
self.add_tag_button.clicked.connect(self.add_tag_modal.show)

View File

@@ -19,6 +19,7 @@ from PySide6.QtWidgets import (
from src.core.library import Tag
from src.core.library.alchemy.enums import TagColorEnum
from src.core.palette import ColorType, get_tag_color
from src.qt.helpers.escape_text import escape_text
from src.qt.translations import Translations
logger = structlog.get_logger(__name__)
@@ -104,7 +105,7 @@ class TagWidget(QWidget):
def __init__(
self,
tag: Tag,
tag: Tag | None,
has_edit: bool,
has_remove: bool,
library: "Library | None" = None,
@@ -126,13 +127,10 @@ class TagWidget(QWidget):
self.bg_button = QPushButton(self)
self.bg_button.setFlat(True)
if self.lib:
self.bg_button.setText(self.lib.tag_display_name(tag.id))
else:
self.bg_button.setText(tag.name)
if has_edit:
edit_action = QAction(self)
edit_action.setText(Translations.translate_formatted("generic.edit"))
edit_action.setText(Translations["generic.edit"])
edit_action.triggered.connect(on_edit_callback)
edit_action.triggered.connect(self.on_edit.emit)
self.bg_button.addAction(edit_action)
@@ -142,7 +140,7 @@ class TagWidget(QWidget):
# TODO: This currently doesn't work in "Add Tag" menus. Either fix this or
# disable it in that context.
self.search_for_tag_action = QAction(self)
self.search_for_tag_action.setText(Translations.translate_formatted("tag.search_for_tag"))
self.search_for_tag_action.setText(Translations["tag.search_for_tag"])
self.bg_button.addAction(self.search_for_tag_action)
# add_to_search_action = QAction(self)
# add_to_search_action.setText(Translations.translate_formatted("tag.add_to_search"))
@@ -150,15 +148,44 @@ class TagWidget(QWidget):
self.inner_layout = QHBoxLayout()
self.inner_layout.setObjectName("innerLayout")
self.inner_layout.setContentsMargins(2, 2, 2, 2)
self.inner_layout.setContentsMargins(0, 0, 0, 0)
self.remove_button = QPushButton(self)
self.remove_button.setFlat(True)
self.remove_button.setText("")
self.remove_button.setHidden(True)
self.remove_button.setMinimumSize(22, 22)
self.remove_button.setMaximumSize(22, 22)
self.remove_button.clicked.connect(self.on_remove.emit)
self.remove_button.setHidden(True)
self.inner_layout.addWidget(self.remove_button)
self.inner_layout.addStretch(1)
self.bg_button.setLayout(self.inner_layout)
self.bg_button.setMinimumSize(22, 22)
self.bg_button.setMinimumSize(44, 22)
self.bg_button.setMinimumHeight(22)
self.bg_button.setMaximumHeight(22)
self.base_layout.addWidget(self.bg_button)
# NOTE: Do this if you don't want the tag to stretch, like in a search.
# self.bg_button.setMaximumWidth(self.bg_button.sizeHint().width())
self.bg_button.clicked.connect(self.on_click.emit)
self.set_tag(tag)
def set_tag(self, tag: Tag | None) -> None:
self.tag = tag
if not tag:
return
primary_color = get_primary_color(tag)
border_color = (
get_border_color(primary_color)
if not (tag.color and tag.color.secondary)
if not (tag.color and tag.color.secondary and tag.color.color_border)
else (QColor(tag.color.secondary))
)
highlight_color = get_highlight_color(
@@ -182,45 +209,62 @@ class TagWidget(QWidget):
f"border-style:solid;"
f"border-width: 2px;"
f"padding-right: 4px;"
f"padding-bottom: 1px;"
f"padding-left: 4px;"
f"font-size: 13px"
f"}}"
f"QPushButton::hover{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"}}"
f"QPushButton::pressed{{"
f"background: rgba{highlight_color.toTuple()};"
f"color: rgba{primary_color.toTuple()};"
f"border-color: rgba{primary_color.toTuple()};"
f"}}"
f"QPushButton::focus{{"
f"padding-right: 0px;"
f"padding-left: 0px;"
f"outline-style: solid;"
f"outline-width: 1px;"
f"outline-radius: 4px;"
f"outline-color: rgba{text_color.toTuple()};"
f"}}"
)
self.bg_button.setMinimumHeight(22)
self.bg_button.setMaximumHeight(22)
self.base_layout.addWidget(self.bg_button)
self.remove_button.setStyleSheet(
f"QPushButton{{"
f"color: rgba{primary_color.toTuple()};"
f"background: rgba{text_color.toTuple()};"
f"font-weight: 800;"
f"border-radius: 5px;"
f"border-width: 4;"
f"border-color: rgba(0,0,0,0);"
f"padding-bottom: 4px;"
f"font-size: 14px"
f"}}"
f"QPushButton::hover{{"
f"background: rgba{primary_color.toTuple()};"
f"color: rgba{text_color.toTuple()};"
f"border-color: rgba{highlight_color.toTuple()};"
f"border-width: 2;"
f"border-radius: 6px;"
f"}}"
f"QPushButton::pressed{{"
f"background: rgba{border_color.toTuple()};"
f"color: rgba{highlight_color.toTuple()};"
f"}}"
f"QPushButton::focus{{"
f"background: rgba{border_color.toTuple()};"
f"outline:none;"
f"}}"
)
if has_remove:
self.remove_button = QPushButton(self)
self.remove_button.setFlat(True)
self.remove_button.setText("")
self.remove_button.setHidden(True)
self.remove_button.setStyleSheet(
f"color: rgba{primary_color.toTuple()};"
f"background: rgba{text_color.toTuple()};"
f"font-weight: 800;"
f"border-radius: 3px;"
f"border-width:0;"
f"padding-bottom: 4px;"
f"font-size: 14px"
)
self.remove_button.setMinimumSize(18, 18)
self.remove_button.setMaximumSize(18, 18)
self.remove_button.clicked.connect(self.on_remove.emit)
if self.lib:
self.bg_button.setText(escape_text(self.lib.tag_display_name(tag.id)))
else:
self.bg_button.setText(escape_text(tag.name))
if has_remove:
self.inner_layout.addWidget(self.remove_button)
self.inner_layout.addStretch(1)
# NOTE: Do this if you don't want the tag to stretch, like in a search.
# self.bg_button.setMaximumWidth(self.bg_button.sizeHint().width())
self.bg_button.clicked.connect(self.on_click.emit)
def set_has_remove(self, has_remove: bool):
self.has_remove = has_remove
def enterEvent(self, event: QEnterEvent) -> None: # noqa: N802
if self.has_remove:
@@ -264,6 +308,7 @@ def get_highlight_color(primary_color: QColor) -> QColor:
def get_text_color(primary_color: QColor, highlight_color: QColor) -> QColor:
# logger.info("[TagWidget] Evaluating tag text color", lightness=primary_color.lightness())
if primary_color.lightness() > 120:
text_color = QColor(primary_color)
text_color = text_color.toHsl()

View File

@@ -101,6 +101,6 @@ class TagBoxWidget(FieldWidget):
)
for entry_id in self.driver.selected:
self.driver.lib.remove_tags_from_entry(entry_id, tag_id)
self.driver.lib.remove_tags_from_entries(entry_id, tag_id)
self.updated.emit()

View File

@@ -0,0 +1,201 @@
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import typing
import structlog
from PySide6.QtCore import QEvent, Qt, Signal
from PySide6.QtGui import QAction, QColor, QEnterEvent
from PySide6.QtWidgets import (
QHBoxLayout,
QPushButton,
QVBoxLayout,
QWidget,
)
from src.core.library.alchemy.models import TagColorGroup
from src.qt.helpers.escape_text import escape_text
from src.qt.translations import Translations
from src.qt.widgets.tag import (
get_border_color,
get_highlight_color,
get_text_color,
)
logger = structlog.get_logger(__name__)
# Only import for type checking/autocompletion, will not be imported at runtime.
if typing.TYPE_CHECKING:
from src.core.library.alchemy import Library
class TagColorLabel(QWidget):
"""A widget for displaying a tag color's name.
Not to be confused with a tag color swatch widget.
"""
on_remove = Signal()
on_click = Signal()
def __init__(
self,
color: TagColorGroup | None,
has_edit: bool,
has_remove: bool,
library: "Library | None" = None,
) -> None:
super().__init__()
self.color = color
self.lib: Library | None = library
self.has_edit = has_edit
self.has_remove = has_remove
self.base_layout = QVBoxLayout(self)
self.base_layout.setObjectName("baseLayout")
self.base_layout.setContentsMargins(0, 0, 0, 0)
self.bg_button = QPushButton(self)
self.bg_button.setFlat(True)
edit_action = QAction(self)
edit_action.setText(Translations["generic.edit"])
edit_action.triggered.connect(self.on_click.emit)
self.bg_button.addAction(edit_action)
self.bg_button.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
if has_edit:
self.bg_button.clicked.connect(self.on_click.emit)
self.setCursor(Qt.CursorShape.PointingHandCursor)
else:
edit_action.setEnabled(False)
self.inner_layout = QHBoxLayout()
self.inner_layout.setObjectName("innerLayout")
self.inner_layout.setContentsMargins(0, 0, 0, 0)
self.remove_button = QPushButton(self)
self.remove_button.setFlat(True)
self.remove_button.setText("")
self.remove_button.setHidden(True)
self.remove_button.setMinimumSize(22, 22)
self.remove_button.setMaximumSize(22, 22)
self.inner_layout.addWidget(self.remove_button)
self.inner_layout.addStretch(1)
if self.has_remove:
self.remove_button.clicked.connect(self.on_remove.emit)
else:
self.remove_button.setHidden(True)
self.bg_button.setLayout(self.inner_layout)
self.bg_button.setMinimumSize(44, 22)
self.bg_button.setMaximumHeight(22)
self.base_layout.addWidget(self.bg_button)
# NOTE: Do this if you don't want the tag to stretch, like in a search.
# self.bg_button.setMaximumWidth(self.bg_button.sizeHint().width())
self.set_color(color)
def set_color(self, color: TagColorGroup | None) -> None:
self.color = color
if not color:
return
primary_color = self._get_primary_color(color)
border_color = (
get_border_color(primary_color)
if not (color and color.secondary and color.color_border)
else (QColor(color.secondary))
)
highlight_color = get_highlight_color(
primary_color if not (color and color.secondary) else QColor(color.secondary)
)
text_color: QColor
if color and color.secondary:
text_color = QColor(color.secondary)
else:
text_color = get_text_color(primary_color, highlight_color)
self.bg_button.setStyleSheet(
f"QPushButton{{"
f"background: rgba{primary_color.toTuple()};"
f"color: rgba{text_color.toTuple()};"
f"font-weight: 600;"
f"border-color: rgba{border_color.toTuple()};"
f"border-radius: 6px;"
f"border-style:solid;"
f"border-width: 2px;"
f"padding-right: 4px;"
f"padding-left: 4px;"
f"font-size: 13px"
f"}}"
f"QPushButton::hover{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"}}"
f"QPushButton::pressed{{"
f"background: rgba{highlight_color.toTuple()};"
f"color: rgba{primary_color.toTuple()};"
f"border-color: rgba{primary_color.toTuple()};"
f"}}"
f"QPushButton::focus{{"
f"padding-right: 0px;"
f"padding-left: 0px;"
f"outline-style: solid;"
f"outline-width: 1px;"
f"outline-radius: 4px;"
f"outline-color: rgba{text_color.toTuple()};"
f"}}"
)
self.remove_button.setStyleSheet(
f"QPushButton{{"
f"color: rgba{primary_color.toTuple()};"
f"background: rgba{text_color.toTuple()};"
f"font-weight: 800;"
f"border-radius: 5px;"
f"border-width: 4;"
f"border-color: rgba(0,0,0,0);"
f"padding-bottom: 4px;"
f"font-size: 14px"
f"}}"
f"QPushButton::hover{{"
f"background: rgba{primary_color.toTuple()};"
f"color: rgba{text_color.toTuple()};"
f"border-color: rgba{highlight_color.toTuple()};"
f"border-width: 2;"
f"border-radius: 6px;"
f"}}"
f"QPushButton::pressed{{"
f"background: rgba{border_color.toTuple()};"
f"color: rgba{highlight_color.toTuple()};"
f"}}"
f"QPushButton::focus{{"
f"background: rgba{border_color.toTuple()};"
f"outline:none;"
f"}}"
)
self.bg_button.setText(escape_text(color.name))
def _get_primary_color(self, color: TagColorGroup) -> QColor:
primary_color = QColor(color.primary)
return primary_color
def set_has_remove(self, has_remove: bool):
self.has_remove = has_remove
def enterEvent(self, event: QEnterEvent) -> None: # noqa: N802
if self.has_remove:
self.remove_button.setHidden(False)
self.update()
return super().enterEvent(event)
def leaveEvent(self, event: QEvent) -> None: # noqa: N802
if self.has_remove:
self.remove_button.setHidden(True)
self.update()
return super().leaveEvent(event)

View File

@@ -3,6 +3,8 @@
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import typing
import structlog
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QColor
@@ -15,6 +17,14 @@ from src.core.library.alchemy.enums import TagColorEnum
from src.core.library.alchemy.models import TagColorGroup
from src.core.palette import ColorType, get_tag_color
from src.qt.translations import Translations
from src.qt.widgets.tag import (
get_border_color,
get_highlight_color,
get_text_color,
)
if typing.TYPE_CHECKING:
from src.core.library import Library
logger = structlog.get_logger(__name__)
@@ -24,9 +34,11 @@ class TagColorPreview(QWidget):
def __init__(
self,
library: "Library",
tag_color_group: TagColorGroup | None,
) -> None:
super().__init__()
self.lib: Library = library
self.tag_color_group = tag_color_group
self.setCursor(Qt.CursorShape.PointingHandCursor)
@@ -44,28 +56,36 @@ class TagColorPreview(QWidget):
self.set_tag_color_group(tag_color_group)
def set_tag_color_group(self, tag_color_group: TagColorGroup | None):
self.tag_color_group = tag_color_group
def set_tag_color_group(self, color_group: TagColorGroup | None):
logger.info(
"[TagColorPreview] Setting tag color",
primary=color_group.primary if color_group else None,
secondary=color_group.secondary if color_group else None,
)
self.tag_color_group = color_group
if tag_color_group:
self.button.setText(tag_color_group.name)
if color_group:
self.button.setText(color_group.name)
self.button.setText(
f"{color_group.name} ({self.lib.get_namespace_name(color_group.namespace)})"
)
else:
Translations.translate_qobject(self.button, "generic.none")
self.button.setText(Translations["color.title.no_color"])
primary_color = get_primary_color(tag_color_group)
primary_color = self._get_primary_color(color_group)
border_color = (
get_border_color(primary_color)
if not (tag_color_group and tag_color_group.secondary)
else (QColor(tag_color_group.secondary))
if not (color_group and color_group.secondary and color_group.color_border)
else (QColor(color_group.secondary))
)
highlight_color = get_highlight_color(
primary_color
if not (tag_color_group and tag_color_group.secondary)
else QColor(tag_color_group.secondary)
if not (color_group and color_group.secondary)
else QColor(color_group.secondary)
)
text_color: QColor
if tag_color_group and tag_color_group.secondary:
text_color = QColor(tag_color_group.secondary)
if color_group and color_group.secondary:
text_color = QColor(color_group.secondary)
else:
text_color = get_text_color(primary_color, highlight_color)
@@ -79,50 +99,31 @@ class TagColorPreview(QWidget):
f"border-style:solid;"
f"border-width: 2px;"
f"padding-right: 8px;"
f"padding-bottom: 1px;"
f"padding-left: 8px;"
f"font-size: 14px"
f"}}"
f"QPushButton::hover{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"}}"
f"QPushButton::focus{{"
f"padding-right: 0px;"
f"padding-left: 0px;"
f"outline-style: solid;"
f"outline-width: 1px;"
f"outline-radius: 4px;"
f"outline-color: rgba{text_color.toTuple()};"
f"}}"
)
# Add back the padding if the hint is generated while the button has focus (no padding)
self.button.setMinimumWidth(
self.button.sizeHint().width() + (16 if self.button.hasFocus() else 0)
)
self.button.setMaximumWidth(self.button.sizeHint().width())
def _get_primary_color(self, tag_color_group: TagColorGroup | None) -> QColor:
primary_color = QColor(
get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)
if not tag_color_group
else tag_color_group.primary
)
def get_primary_color(tag_color_group: TagColorGroup | None) -> QColor:
primary_color = QColor(
get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)
if not tag_color_group
else tag_color_group.primary
)
return primary_color
def get_border_color(primary_color: QColor) -> QColor:
border_color: QColor = QColor(primary_color)
border_color.setRed(min(border_color.red() + 20, 255))
border_color.setGreen(min(border_color.green() + 20, 255))
border_color.setBlue(min(border_color.blue() + 20, 255))
return border_color
def get_highlight_color(primary_color: QColor) -> QColor:
highlight_color: QColor = QColor(primary_color)
highlight_color = highlight_color.toHsl()
highlight_color.setHsl(highlight_color.hue(), min(highlight_color.saturation(), 200), 225, 255)
highlight_color = highlight_color.toRgb()
return highlight_color
def get_text_color(primary_color: QColor, highlight_color: QColor) -> QColor:
if primary_color.lightness() > 120:
text_color = QColor(primary_color)
text_color = text_color.toHsl()
text_color.setHsl(text_color.hue(), text_color.saturation(), 50, 255)
return text_color.toRgb()
else:
return highlight_color
return primary_color

View File

@@ -30,7 +30,7 @@ from PySide6.QtSvgWidgets import QSvgWidget
from PySide6.QtWidgets import QGraphicsScene, QGraphicsView
from src.core.enums import SettingItems
from src.qt.helpers.file_opener import FileOpenerHelper
from src.qt.platform_strings import PlatformStrings
from src.qt.platform_strings import open_file_str
from src.qt.translations import Translations
if typing.TYPE_CHECKING:
@@ -116,8 +116,7 @@ class VideoPlayer(QGraphicsView):
self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
self.opener = FileOpenerHelper(filepath=self.filepath)
autoplay_action = QAction(self)
Translations.translate_qobject(autoplay_action, "media_player.autoplay")
autoplay_action = QAction(Translations["media_player.autoplay"], self)
autoplay_action.setCheckable(True)
self.addAction(autoplay_action)
autoplay_action.setChecked(
@@ -126,11 +125,10 @@ class VideoPlayer(QGraphicsView):
autoplay_action.triggered.connect(lambda: self.toggle_autoplay())
self.autoplay = autoplay_action
open_file_action = QAction(self)
Translations.translate_qobject(open_file_action, "file.open_file")
open_file_action = QAction(Translations["file.open_file"], self)
open_file_action.triggered.connect(self.opener.open_file)
open_explorer_action = QAction(PlatformStrings.open_file_str, self)
open_explorer_action = QAction(open_file_str(), self)
open_explorer_action.triggered.connect(self.opener.open_explorer)
self.addAction(open_file_action)

View File

@@ -1,11 +1,11 @@
import pathlib
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import Mock, patch
import pytest
CWD = pathlib.Path(__file__).parent
CWD = Path(__file__).parent
# this needs to be above `src` imports
sys.path.insert(0, str(CWD.parent))
@@ -23,24 +23,24 @@ def cwd():
def file_mediatypes_library():
lib = Library()
status = lib.open_library(pathlib.Path(""), ":memory:")
status = lib.open_library(Path(""), ":memory:")
assert status.success
entry1 = Entry(
folder=lib.folder,
path=pathlib.Path("foo.png"),
path=Path("foo.png"),
fields=lib.default_fields,
)
entry2 = Entry(
folder=lib.folder,
path=pathlib.Path("bar.png"),
path=Path("bar.png"),
fields=lib.default_fields,
)
entry3 = Entry(
folder=lib.folder,
path=pathlib.Path("baz.apng"),
path=Path("baz.apng"),
fields=lib.default_fields,
)
@@ -61,7 +61,7 @@ def library(request):
library_path = request.param
lib = Library()
status = lib.open_library(pathlib.Path(library_path), ":memory:")
status = lib.open_library(Path(library_path), ":memory:")
assert status.success
tag = Tag(
@@ -92,18 +92,18 @@ def library(request):
entry = Entry(
id=1,
folder=lib.folder,
path=pathlib.Path("foo.txt"),
path=Path("foo.txt"),
fields=lib.default_fields,
)
assert lib.add_tags_to_entry(entry.id, tag.id)
assert lib.add_tags_to_entries(entry.id, tag.id)
entry2 = Entry(
id=2,
folder=lib.folder,
path=pathlib.Path("one/two/bar.md"),
path=Path("one/two/bar.md"),
fields=lib.default_fields,
)
assert lib.add_tags_to_entry(entry2.id, tag2.id)
assert lib.add_tags_to_entries(entry2.id, tag2.id)
assert lib.add_entries([entry, entry2])
assert len(lib.tags) == 6
@@ -114,7 +114,7 @@ def library(request):
@pytest.fixture
def search_library() -> Library:
lib = Library()
lib.open_library(pathlib.Path(CWD / "fixtures" / "search_library"))
lib.open_library(Path(CWD / "fixtures" / "search_library"))
return lib
@@ -133,8 +133,8 @@ def qt_driver(qtbot, library):
with TemporaryDirectory() as tmp_dir:
class Args:
config_file = pathlib.Path(tmp_dir) / "tagstudio.ini"
open = pathlib.Path(tmp_dir)
config_file = Path(tmp_dir) / "tagstudio.ini"
open = Path(tmp_dir)
ci = True
with patch("src.qt.ts_qt.Consumer"), patch("src.qt.ts_qt.CustomRunnable"):

Some files were not shown because too many files have changed in this diff Show More