Compare commits

...

82 Commits

Author SHA1 Message Date
Travis Abendshien 6aa0cf74f9 chore: removed unused collage_icon.py file 2026-07-12 21:08:31 -07:00
Travis Abendshien 49b450c3a4 fix: don't prevent greater minor version DBs from opening 2026-07-12 20:58:53 -07:00
Ludvig Sandh a1dfa62e4a fix: correctly display duration statistic in preview panel (#1421)
* fix: correctly display duration statistic in preview panel

* style: format file_attributes.py with ruff

* refactor: consolidate preview thumb current file state

* refactor: remove another redundant current file state

* refactor: move generic format_duration function to string formatting util file

* refactor: tighten signal's declared types
2026-07-12 13:16:14 -07:00
Xarvex aa2d9d4815 fix(ci): misapplied rebase, cont 9d5200b
fixes: #1445 (for good)
2026-07-11 19:33:29 -05:00
Xarvex 16cfa8d2ff chore(ci): ignore translations pushes for reuse 2026-07-11 19:27:21 -05:00
Xarvex 9d5200b2f2 fix(ci): path matching fixes, corrected concurrency
Pull requests are now correctly handled and concurrency is per-job.
translations branch can safely be ignored for pushes.
2026-07-11 19:19:42 -05:00
Jann Stute f252a86fd5 refactor: split out sql migrations from library (#1432)
* refactor: minor simplification

* refactor: split open_library into new and not new case

Functionality is entirely unchanged, but entire method is duplicated.
Removing dead code from either copy is next.

* refactor: remove dead code

* doc: add todos and remove trivially true assert

* refactor: add assurance 1 version check

Assurance 1 was introduced with library version 101, specifically commmit 12e074b71d.
Thus all libraries created on version 101 and above already fullfill it and don't need it.
Furthermore, it only fails if the library didn't need the assurance, meaning the try-except and warning catching can be removed

* refactor: remove various unnecessary try-except statements in new_lib

All of these were introduced to account for the differences between new libraries and existing ones,
which makes them unnecessary after this split.

* refactor: remove unnecessary check in new_lib

* refactor: move folder assurance after migrations

The folder assurance has been present since the very first SQL commit e5e7b8afc6,
and thus only has an effect when the library is moved, meaning that is semantically not part of the migrations.

* refactor: massively simplify open_library

* refactor: move engine creation to static method

* refactor: add version check for assurance 3

Assurance 3 was introduced in commit 47c3d5338f
shortly (24h 20min) after the DB version had been bumped to 200.
Since it was also included in the first release with DB version 200,
all libraries created on DB version 200 and above
can be presumed to have already had this applied on creation.

* refactor: add assurance 3 to DB 200 migration

* refactor: move assurance 1 to a proper migration method

Moving the assurance after the migrations 7, 8, 9, and 100 is fine because it doesn't affect those.

* refactor: update version after every successfull migration

Since every migration migrates the library to a certain DB_VERSION, we can simply update the library version after such a successfull migration.
This way if a migration fails, the previous migrations won't be rerun the next time the library is opened.

* refactor: rewrite migration procedure as loop

* refactor: apply migration and update version in same transaction

None of the content of the migrations is changed, only the `with session:` statements are removed.

* refactor: replace all commits in the migrations with flushes

Also removes various try-except statements in the migrations.
These were introduced to catch the case where the migration is run twice due to a later migration failing,
this is not necessary anymore as every migration will only be executed at most once per library.

* refactor: make sure the migration log statements are consistent

* fix: pass library dir to migrations

* fix(db migration 8): only add colors that are actually new

DB Migration 8 was previously adding all colors except for the neon ones,
however, all but three of those have been around since DB version 4,
meaning that they don't need to be added at all (which caused the tests to fail).

* fix: json migration used outdated interface

* fix(open_library): create TS directory only if not opened in memory

* fix: enable sane transaction behaviour

By default in SQLAlchemy schema changes are automatically committed,
even if auto-commit is turned off.
This commit turns off auto-commit completely,
which caused some schema to not be applied correctly,
but those were fixed here as well.

* refactor: hide 'argument is not accessed' notices

* fix(db migration 9): filename property wasn't written correctly

* fix(db migration 104): include/exclude list was loaded incorrectly

* feat: log start and end of DB migrations

* fix: don't use double transaction in open_sqlite_library

* fix: only commit once when creating new library

* doc: add comment on removing Folder logic

* refactor: remove dunder naming
2026-07-11 12:36:55 -07:00
Xarvex a0fb679729 fix(ci): more path matching 2026-07-11 01:29:11 -05:00
Xarvex b182b2ff7e chore(ci): more paths for pytest to trigger on 2026-07-09 22:23:17 -05:00
purpletennisball 308b36b31e fix: thumbnail rendering for older pxd files (#1441) 2026-07-09 18:03:47 -07:00
Weblate (bot) 1d8817833d translations: update using Weblate (Spanish) (#1439)
Currently translated at 100.0% (422 of 422 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (422 of 422 strings)



Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/es/
Translation: TagStudio/Application Strings

Co-authored-by: 2004milenadiaz-source <2004.milena.diaz@gmail.com>
Co-authored-by: Joan <joancanalscrehuet@gmail.com>
2026-07-09 16:34:19 -07:00
Travis Abendshien 4397e087ef docs: update install instructions 2026-07-09 16:31:41 -07:00
Travis Abendshien fee9480cc3 docs: update changelog 2026-07-09 16:30:48 -07:00
Travis Abendshien 1f6aae98ed ci: update paths for python workflows 2026-07-09 16:30:26 -07:00
Travis Abendshien 8edbb7a258 docs: update README badges 2026-07-08 23:42:26 -07:00
Travis Abendshien 9c3f76ce16 docs: update changelog 2026-07-08 23:13:29 -07:00
Sola-ris c755894c84 feat: render .pxd thumbnails (#1430)
* feat: render .pxd thumbnails.

* move .pxd to _DOCUMENT_SET, rename method, fix doc and typos.

* add missing break.
2026-07-08 23:05:48 -07:00
Xarvex 377593b190 feat(ci)!: complete workflows revamp (#1437)
* chore(ci): move spec-file into scripts

* feat(ci)!: rework workflows and build processes

All Python setups route through one local action, and checks are much
more conditional. There is more to be done, but this is a significant
improvement in processing time and redundancy.

As well, nightly builds are made (though not made as GitHub releases..
yet).

* ci: remove symlinked CHANGELONG.md from build_docs.yml paths

* docs: update location of tagstudio.spec

* ci: enforce single quotes in github yaml files

* ci(tests): remove macOS pytest runner

---------

Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
2026-07-08 22:49:57 -07:00
Xarvex 993eeff0fc fix(deps): replace optional dependencies with dependency groups (#1435)
Definition: https://packaging.python.org/en/latest/specifications/dependency-groups

The optional dependency table ([project.optional-dependencies]) is
supposed to be user-facing for installs of TagStudio. Meanwhile, the
replacement dependency groups is explicitly meant for extra development
tools, so lets use that.

This does mean the command for a full development environment has changed
from:
```sh
pip install -e '.[dev]'
```

To:
```sh
pip install -e . --group all
```

The documentation has been adjusted for a quick replacement, but
probably should have more information on a later time.
2026-07-07 14:36:02 -07:00
Xarvex faf88a912e nit(dev): make supplementary configuratios CC0
This has the approval of myself and @CyanVoxel, whom are the only
contributors to these files.
2026-07-06 15:30:59 -05:00
Xarvex 3e26b990f7 nit(dev): put filetype clause on top 2026-07-06 15:15:28 -05:00
Xarvex 65cb49e472 fix(dev): use indent_size of 4 by default 2026-07-06 15:05:44 -05:00
Travis Abendshien 3593acdd59 docs: add v9.6.1 changelog 2026-07-06 11:42:30 -07:00
Weblate (bot) 858d433b6f translations: update from Hosted Weblate (#1402)
* Translated using Weblate (German)

Currently translated at 85.7% (362 of 422 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/de/
Translation: TagStudio/Application Strings

* Translated using Weblate (Estonian)

Currently translated at 0.7% (3 of 418 strings)

Added translation using Weblate (Estonian)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Priit Jõerüüt <jrthwlate@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/et/
Translation: TagStudio/Application Strings

* Translated using Weblate (Russian)

Currently translated at 93.7% (392 of 418 strings)

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

* Translated using Weblate (Japanese)

Currently translated at 86.1% (360 of 418 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/ja/
Translation: TagStudio/Application Strings

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (422 of 422 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (421 of 421 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (418 of 418 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (388 of 388 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/Application Strings
Translation: TagStudio/Application Strings [GPLv3]

* Translated using Weblate (Spanish)

Currently translated at 99.7% (387 of 388 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (387 of 387 strings)

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

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/
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/fr/
Translation: TagStudio/Strings

---------

Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Co-authored-by: Priit Jõerüüt <jrthwlate@users.noreply.hosted.weblate.org>
Co-authored-by: romandobra <romanreachless@gmail.com>
Co-authored-by: Szíjártó Levente Pál <szijartoleventepal@gmail.com>
Co-authored-by: Joan <joancanalscrehuet@gmail.com>
Co-authored-by: Med <45147847+kitsumed@users.noreply.github.com>
2026-07-06 10:25:38 -07:00
TheBobBobs 2965d2056b fix: Thumbnail Grid not resizing to fill width (#1433)
* fix: update thumb_grid when row width changes

* fix: prevent index error in thumb_grid
2026-07-05 18:26:41 -07:00
purpletennisball 45b4b57b3a fix: mouse down selects thumbnail (#1420)
* fix: mouse down selects thumbnail

* fix: selecting a single item out of a selecting

* fix: multiple file dragging
2026-07-05 16:59:31 -07:00
Travis Abendshien 0ec562929c chore: bump version to v9.6.1 2026-07-05 16:56:48 -07:00
Travis Abendshien 23ef9964d3 fix/refactor: use generic ModuleStatus class for getting status of optional modules (#1427)
* fix/refactor: use generic ModuleStatus class for getting status of optional modules

* fix: add and order macOS bin locations by PATH precedence

* ui: add title for optional modules section in the about window

* fix: revert errant license change

* fix: return which(cmd) instead of the original cmd

* refactor: implement review feedback

* feat(ui): add tooltips for module locations on about screen
2026-07-05 15:25:56 -07:00
purpletennisball 85a51f8e2b feat(ui): organize settings into panels (#1425)
* feat(ui): organize settings into new panels

* fix: typo

* fix(ui): add localization for setting panels

* fix(ui): move language to localization panel
2026-07-04 17:39:13 -07:00
Travis Abendshien 34814e8723 fix: fix "search for tag" function in tag manager (#1411)
* fix: set driver for tag manager

* refactor(ui): require titles for TagSearch widgets
2026-07-04 13:35:51 -07:00
Travis Abendshien 95e2fe7b44 fix: remove invalid child_id relationships from tag_parents (#1423)
* fix: remove invalid child_id relationships from tag_parents

* fix: use single statements in DB 102 & 202 migrations

* fix: remove unnecessary session.scalars() from migrations
2026-07-04 13:35:40 -07:00
purpletennisball 83473379db feat: on click edit tag while in tag manager (#1416) 2026-07-04 13:13:53 -07:00
Travis Abendshien 5af3cc4faf docs: update library-changes 2026-07-03 10:15:35 -07:00
Andreas Heglingegård 4a543396ae fix: use optimized SQL when selecting non-hidden entries (#1240) 2026-07-02 16:38:29 -07:00
Jann Stute 3ffa012857 fix: make tagstudio python package executable (#1414)
* fix: make tagstudio python package executable by adding stub __main__.py

* fix: add license note + set exec bit

* Update src/tagstudio/__main__.py

Co-authored-by: Xarvex <60973030+xarvex@users.noreply.github.com>

---------

Co-authored-by: Xarvex <60973030+xarvex@users.noreply.github.com>
2026-07-02 21:44:03 +02:00
Jann Stute 00b6f918f3 fix(nix): add pyright to devshell (#1415)
---------

Co-authored-by: Xarvex <dev.ellz6@xarvex.simplelogin.com>
2026-07-02 14:15:32 -05:00
Travis Abendshien 7c1c1dd6ae fix: fix issues with tag alias updating (#1412) 2026-07-02 20:16:35 +02:00
Travis Abendshien d7918fd82c tests: filter irrelevant SQLAlchemy or_() warning 2026-07-01 23:20:45 -07:00
Travis Abendshien c062f6b6bd build: relax Send2Trash version 2026-07-01 23:20:03 -07:00
Travis Abendshien 26e04e829c docs: update roadmap 2026-07-01 21:37:50 -07:00
Travis Abendshien 4d49e9c270 translations: translate language names 2026-07-01 21:37:16 -07:00
Travis Abendshien daaae17113 tests: filter irrelevant SQLAlchemy DISTINCT ON warning spam 2026-06-30 14:17:05 -07:00
Xarvex c38fbb034d chore(nix): relax more dependencies
fixes: #1403
2026-06-30 12:51:39 -05:00
Travis Abendshien 0433908ae8 refactor: remove QPushButtonWrapper 2026-06-30 02:22:48 -07:00
Travis Abendshien 9e04922e7b fix(docs): fix header levels in changelog 2026-06-29 14:36:10 -07:00
Travis Abendshien c6f2e3c7d1 docs: update changelog 2026-06-29 14:33:56 -07:00
Travis Abendshien 4e93e953d9 chore: fix missing ext in REUSE.toml 2026-06-29 13:30:43 -07:00
Travis Abendshien 008cef363d chore: remove REUSE comments from PULL_REQUEST_TEMPLATE.md and FUNDING.yml 2026-06-29 13:29:12 -07:00
Travis Abendshien 727f441c55 fix(ci): bump pyinstaller version to 6.21 2026-06-29 13:25:07 -07:00
Travis Abendshien e327dc7ee1 chore: bump version to v9.6.0 2026-06-29 11:56:39 -07:00
Weblate (bot) 8eded29f45 translations: update from Hosted Weblate (#1401)
* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Russian)

Currently translated at 97.0% (362 of 373 strings)

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

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Portuguese)

Currently translated at 87.1% (325 of 373 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: ssantos <ssantos@web.de>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/pt/
Translation: TagStudio/Strings

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Hungarian)

Currently translated at 100.0% (373 of 373 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/
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/hu/
Translation: TagStudio/Strings

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

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

---------

Co-authored-by: Dott-rus <antonamelin8@gmail.com>
Co-authored-by: ssantos <ssantos@web.de>
Co-authored-by: Szíjártó Levente Pál <szijartoleventepal@gmail.com>
2026-06-29 11:55:44 -07:00
Travis Abendshien a01b5c423b chore: format with ruff 2026-06-29 11:55:01 -07:00
Ludvig Sandh a865573c4b fix: fix off-by-one issues in progress bars (#1293)
* fix: faulty progress bar

The progress bar UI element always showed 1 step ahead of what it supposed to, also causing the last step to display a visual glitch (empty window)

* fix: corrected drop import progress bar text

* fix: corrected relink progress bar

Update progress bar at the beginning of the relink iteration instead of the end, which previously showed incorrect progress text and an empty window during the first iteration.

* fix: corrected 'refresh ignore entries' progress bar

Update progress bar at the beginning of the refresh loop iteration instead of the end, which previously showed incorrect progress text and an empty window during the first iteration.

* fix: corrected 'refresh unlinked' progress bar

Update progress bar at the beginning of the refresh loop iteration instead of the end, which previously showed incorrect progress text and an empty window during the first iteration.

* fix: corrected 'mirror entries' progress bar

Same as last commit

* fix: Unknown key in 'mirror entries' progress bar text (translation formatting)

* fix: corrected 'merge duplicates' progress bar

Note: Seems like this bar is never used in the UI. But if it is in the future, this fixes it just like the other progress bars.
2026-06-29 11:53:14 -07:00
Sola-ris 6005129003 feat: allow all raster images to be used as archive thumbnails (#1373) 2026-06-29 11:31:01 -07:00
Travis Abendshien f44662296f fix(ui): fix thumb res setting not updating during runtime 2026-06-29 10:33:44 -07:00
Travis Abendshien 0fa15ce18e fix(ui): fix settings not saving, expose cached thumb size setting 2026-06-29 10:20:00 -07:00
Travis Abendshien 41024260de fix(ui): fix incorrect size hints in about modal 2026-06-29 02:36:16 -07:00
Travis Abendshien fca21bf5fa fix(ui): use is_version_outdated() method in about modal 2026-06-29 02:27:53 -07:00
Travis Abendshien 6b15beefbd feat: dismiss update notification, change missing ffmpeg popup to banner (#1400)
* feat: dismiss update notification, change ffmpeg to banner

* chore: sort resources.pyi list

* fix: remove unused asset

* refactor: rename dismiss_update()

* fix: remove *other* unused asset
2026-06-29 02:18:45 -07:00
Weblate (bot) 4919c972da translations: update using Weblate (Hungarian) (#1395)
Currently translated at 100.0% (373 of 373 strings)


Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/hu/
Translation: TagStudio/Strings

Co-authored-by: Szíjártó Levente Pál <szijartoleventepal@gmail.com>
2026-06-28 23:07:24 -07:00
Travis Abendshien e509e247d5 fix: fix tag aliases (#1399)
* fix: fix tag aliases

* chore: remove logger statements from tag panel test

* chore: very important fix

* chore: remove commented-out code
2026-06-28 23:06:40 -07:00
Travis Abendshien 4da6037cbd docs: update misc docs 2026-06-28 22:48:36 -07:00
Travis Abendshien 0d87307f38 fix(docs): don't use symlinked image for README 2026-06-28 01:55:50 -07:00
Travis Abendshien 1b0bbba080 feat: add field template editor, editable field names (#1396)
* feat: add basic field template editor

* fix: fix various issues with adding templates, reduce reused code

* feat: add field name editing on entries

* ui: add multiline checkbox to field template editor

* refactor: move stylesheets to central file

* fix(ui): fix untranslated key

* docs: update field documentation
2026-06-28 01:45:32 -07:00
Travis Abendshien 0f319985c4 ui: add v9.6 assets, update misc resources (#1398)
* ui: add v9.6 assets, update misc resources

* ui: use symlink for favicon

* refactor: simplify icon switching methods in media_player.py

* ui: update about window

* fix(ui): apply dpi scaling to about window background

* fix(ui): fix image symlinks

* fix(ui): fix dark media player buttons in light theme

* ui: use TagStudio icon in update notification instead of warning
2026-06-28 01:22:09 -07:00
Xarvex a3477722b3 fix(ci): temporarily remove coverage check
This check has never actually been referred to or otherwise tracked, and
now it has been failing for UI files which do not need tests, making it
unsuitable in its current state. Long term there should be some kind of
coverage enforcement.
2026-06-28 02:10:59 -05:00
Xarvex 7a0a1f6047 fix(pyproject): allow Python 3.13 to function (#1047)
* fix(pyproject): allow Python 3.13 to function

* chore(nix): use Python 3.13

* fix(nix): add forgotten `audioop-lts`
2026-06-27 23:23:03 -07:00
Travis Abendshien d640859856 chore: move REUSE info for issue templates to REUSE.toml 2026-06-27 05:07:23 -07:00
Travis Abendshien cf1006ee51 fix(docs): use updated logo path in readme 2026-06-26 13:15:49 -07:00
Travis Abendshien 7f15990084 fix(docs): rename text logo file 2026-06-26 12:05:27 -07:00
Travis Abendshien d9c56d534e docs: update roadmap 2026-06-26 11:55:21 -07:00
Travis Abendshien 85676c0836 fix(ci): bump ruff version in workflow 2026-06-24 04:02:11 -07:00
Travis Abendshien 71a01c7156 chore: bump py7zr to 1.1.3 2026-06-22 23:11:28 -07:00
Travis Abendshien e499a3e68b chore: apply misc ruff fixes 2026-06-22 23:10:29 -07:00
Travis Abendshien 4321bbfdb3 chore: bump ruff to 0.15.17 (#1393) 2026-06-22 22:00:03 -07:00
Trigam 185691bcd2 feat: field template manager (#1374)
* refactor: merge tag_database.py into tag_search.py

* refactor: mvc split of tag_search.py

* refactor: tweaks

* doc: REUSE license information

* doc: add REUSE license information

* refactor: split generic functionality into "search panel"

* feat: field template search panel

* feat: field template manager

* fix: rename `tag.view_limit` key for other languages

* tweak: remove exclusion from field template search panel

* tweak: rename `is_field_chooser` to `is_field_template_chooser`

* feat: switch from multiple inheritance to composition

---------

Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
2026-06-22 21:59:26 -07:00
Weblate (bot) ae787eaf5e translations: update from Hosted Weblate (#1366)
* Translated using Weblate (Russian)

Currently translated at 98.9% (361 of 365 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: NikitaNik_of <nikita-s03@mail.ru>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/ru/
Translation: TagStudio/Strings

* Translated using Weblate (Japanese)

Currently translated at 98.6% (360 of 365 strings)

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

* Translated using Weblate (Hebrew)

Currently translated at 2.7% (10 of 365 strings)

Added translation using Weblate (Hebrew)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: JonathanGlixman <yonatan.glixman@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/he/
Translation: TagStudio/Strings

* Translated using Weblate (Italian)

Currently translated at 100.0% (365 of 365 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 100.0% (365 of 365 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Julen Arratibel Etxabe <jarratibeletxabe@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/es/
Translation: TagStudio/Strings

* Translated using Weblate (French)

Currently translated at 100.0% (365 of 365 strings)

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

* Translated using Weblate (Toki Pona)

Currently translated at 95.8% (350 of 365 strings)

Co-authored-by: Cyborus <cyborus@disroot.org>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/tok/
Translation: TagStudio/Strings

* Translated using Weblate (Viossa)

Currently translated at 88.2% (322 of 365 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/qpv/
Translation: TagStudio/Strings

---------

Co-authored-by: NikitaNik_of <nikita-s03@mail.ru>
Co-authored-by: wany-oh <wany-oh@users.noreply.hosted.weblate.org>
Co-authored-by: JonathanGlixman <yonatan.glixman@gmail.com>
Co-authored-by: Octavian <223219150+EdelFlosWeiss@users.noreply.github.com>
Co-authored-by: Julen Arratibel Etxabe <jarratibeletxabe@gmail.com>
Co-authored-by: Med <45147847+kitsumed@users.noreply.github.com>
Co-authored-by: Cyborus <cyborus@disroot.org>
Co-authored-by: Nginearing <142851004+Nginearing@users.noreply.github.com>
2026-06-22 20:45:17 -07:00
Travis Abendshien 8b6f649c54 docs: update roadmap 2026-06-20 17:52:17 -07:00
Travis Abendshien 204d4be303 translations: add Cebuano, Finnish, and Greek to settings UI 2026-06-17 22:12:07 -07:00
Travis Abendshien e5fa85448a docs: update library documentation 2026-06-17 02:15:42 -07:00
Travis Abendshien 6f3c66efd9 chore: update .gitignore 2026-06-16 08:14:57 -07:00
Trigam 2c85c082b7 fix: match against the correct path in the ignore registry (#1382)
* fix: match against the correct path in the ignore registry

* fix: match against the correct path in the ignore registry
2026-06-13 14:05:00 -07:00
232 changed files with 6294 additions and 4159 deletions
+2 -4
View File
@@ -10,13 +10,11 @@ root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
max_line_length = 100
trim_trailing_whitespace = true
[*.{css,json,md}]
indent_size = 4
[*.{yaml,yml}]
[*.{nix,yaml,yml}]
indent_size = 2
-3
View File
@@ -1,5 +1,2 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
---
patreon: cyanvoxel
-2
View File
@@ -1,5 +1,3 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
---
name: Bug Report
description: File a bug or issue report.
@@ -1,5 +1,3 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
---
name: Feature Request
description: Suggest a new feature.
-3
View File
@@ -1,6 +1,3 @@
<!-- SPDX-FileCopyrightText: (c) TagStudio Contributors -->
<!-- SPDX-License-Identifier: GPL-3.0-only -->
### Summary
<!--
+59
View File
@@ -0,0 +1,59 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: CC0-1.0
---
name: Setup Python
description: Combination of Python setup actions
inputs:
skip-setup:
default: 'false'
description: Whether to skip setup actions and only execute installs
groups:
description: Newline-separated list of dependency groups to install
runs:
using: composite
steps:
- name: Setup Python install
if: inputs.skip-setup != 'true'
uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Setup uv install
if: inputs.skip-setup != 'true'
uses: astral-sh/setup-uv@v8.2.0
- name: Run pip install (Windows)
if: runner.os == 'Windows'
env:
INPUT_GROUPS: ${{ inputs.groups }}
UV_PYTHON_DOWNLOADS: never
UV_SYSTEM_PYTHON: '1'
shell: pwsh
run: |
if (![string]::IsNullOrEmpty(${env:INPUT_GROUPS})) {
$groupArgs = -split ${env:INPUT_GROUPS} -replace '^', '--group='
}
uv pip install . ${groupArgs}
- name: Run pip install (non-Windows)
if: runner.os != 'Windows'
env:
INPUT_GROUPS: ${{ inputs.groups }}
UV_PYTHON_DOWNLOADS: never
UV_SYSTEM_PYTHON: '1'
shell: bash
run: |
extra_args=()
if [ -n "${INPUT_GROUPS}" ]; then
while IFS= read -r group; do
if [ -n "${group}" ]; then
extra_args+=("--group=${group}")
fi
done <<<"${INPUT_GROUPS}"
fi
uv pip install . "${extra_args[@]}"
+203
View File
@@ -0,0 +1,203 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: CC0-1.0
# TODO: This will be invoked by a 'Release' workflow in the future.
---
name: Build
on:
push:
tags:
- v*
schedule:
# From: https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule
# To decrease the chance of delay, schedule your workflow to run at a different time of the hour.
- cron: 42 3 * * *
timezone: America/Los_Angeles
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
defaults:
run:
shell: sh
jobs:
meta:
name: Metadata
runs-on: ubuntu-latest
outputs:
type: ${{ steps.meta.outputs.type }}
version: ${{ steps.meta.outputs.version }}
steps:
- name: Set metadata
id: meta
run: |
type=Nightly
if [ "${GITHUB_REF_TYPE}" = tag ]; then
case ${GITHUB_REF_NAME} in
v*)
type=Release
case ${GITHUB_REF_NAME} in
*-pr*) type=Pre-${type} ;;
esac
;;
esac
fi
if [ ${type} = Nightly ]; then
version=dev+$(date --iso-8601 | tr -d -)
else
version=${GITHUB_REF_NAME}
fi
cat <<EOF >>"${GITHUB_OUTPUT}"
type=${type}
version=${version}
EOF
build:
strategy:
fail-fast: ${{ needs.meta.outputs.type != 'Nightly' }}
matrix:
include:
- os: ubuntu-22.04
os-label: Linux x86
- os: macos-15-intel
os-label: macOS x86
- os: macos-15
os-label: macOS ARM
- os: windows-2022
os-label: Windows x86
name: Build${{ needs.meta.outputs.type && format(' {0}', needs.meta.outputs.type) }}${{ matrix.os-label && format(' ({0})', matrix.os-label) }}
needs: meta
runs-on: ${{ matrix.os }}
env:
BUILD_PREFIX: tagstudio_${{ needs.meta.outputs.version }}
steps:
- name: Prepare build (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
$dir = "${env:RUNNER_TEMP}/upload"
New-Item -Path ${dir} -ItemType Directory -Force
$os = ${env:RUNNER_OS}.ToLower()
$arch = uname -m
$commonPath = "${dir}/${env:BUILD_PREFIX}_${os}_${arch}"
Add-Content -Path ${env:GITHUB_ENV} -Value @"
BUILD_ARTIFACT_DEFAULT=${commonPath}.zip
BUILD_ARTIFACT_PORTABLE=${commonPath}_portable.zip
"@ -Encoding utf8
- name: Prepare build (non-Windows)
if: runner.os != 'Windows'
run: |
dir=${RUNNER_TEMP}/upload
mkdir -p "${dir}"
os=$(
tr '[:upper:]' '[:lower:]' <<EOF
${RUNNER_OS}
EOF
)
arch=$(uname -m)
common_path=${dir}/${BUILD_PREFIX}_${os}_${arch}
artifact_default=${common_path}.tar.gz
if [ "${RUNNER_OS}" = macOS ]; then
artifact_portable=
else
artifact_portable=${common_path}_portable.tar.gz
fi
cat <<EOF >>"${GITHUB_ENV}"
BUILD_ARTIFACT_DEFAULT=${artifact_default}
${artifact_portable:+BUILD_ARTIFACT_PORTABLE=${artifact_portable}}
EOF
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup Python
uses: ./.github/actions/setup-python
with:
groups: build
- parallel:
- name: Run PyInstaller (Linux)
if: runner.os == 'Linux'
run: |
pyinstaller --distpath dist/default --workpath build/default scripts/tagstudio.spec
tar czf "${BUILD_ARTIFACT_DEFAULT}" -C dist/default/tagstudio .
- name: Run PyInstaller (Linux portable)
if: runner.os == 'Linux'
run: |
pyinstaller --distpath dist/portable --workpath build/portable scripts/tagstudio.spec -- --portable
tar czf "${BUILD_ARTIFACT_PORTABLE}" -C dist/portable tagstudio
- name: Run PyInstaller (macOS)
if: runner.os == 'macOS'
run: |
scripts/tagstudio.spec
tar czf "${BUILD_ARTIFACT_DEFAULT}" -C dist TagStudio.app
- name: Run PyInstaller (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
PyInstaller --distpath dist/default --workpath build/default scripts/tagstudio.spec
Compress-Archive -Path dist/default/TagStudio -DestinationPath ${env:BUILD_ARTIFACT_DEFAULT}
- name: Run PyInstaller (Windows portable)
if: runner.os == 'Windows'
shell: pwsh
run: |
PyInstaller --distpath dist/portable --workpath build/portable scripts/tagstudio.spec -- --portable
Compress-Archive -Path dist/portable/TagStudio.exe -DestinationPath ${env:BUILD_ARTIFACT_PORTABLE}
- parallel:
- name: Upload build artifact
uses: actions/upload-artifact@v7
with:
path: ${{ env.BUILD_ARTIFACT_DEFAULT }}
if-no-files-found: error
# Short retention for 'Pre-Release' and 'Release' because they get uploaded to a GitHub release.
retention-days: &upload-artifact-retention-days ${{ case(needs.meta.outputs.type == 'Nightly', '7', '1') }}
archive: false
- name: Upload build artifact (portable)
if: env.BUILD_ARTIFACT_PORTABLE != ''
uses: actions/upload-artifact@v7
with:
path: ${{ env.BUILD_ARTIFACT_PORTABLE }}
if-no-files-found: error
retention-days: *upload-artifact-retention-days
archive: false
upload:
permissions:
contents: write
name: Upload Builds to Release
needs: [meta, build]
if: needs.meta.outputs.type != 'Nightly'
runs-on: ubuntu-latest
steps:
- name: Download build artifacts
uses: actions/download-artifact@v8
with:
path: ${{ runner.temp }}/download
merge-multiple: 'true'
skip-decompress: 'true'
- name: Upload build artifacts to release
uses: softprops/action-gh-release@v3
with:
files: ${{ runner.temp }}/download/*
+56
View File
@@ -0,0 +1,56 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: CC0-1.0
# TODO: In the future, docs will be built (but not published) for PRs.
---
name: Build Docs
on:
push:
branches:
- main
paths:
- .github/actions/setup-python/action.yml
- .github/workflows/build_docs.yml
- docs/**
- mkdocs.yml
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
defaults:
run:
shell: sh
jobs:
publish:
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
permissions:
contents: write
name: Publish Docs
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup Python
uses: ./.github/actions/setup-python
with:
groups: docs
- name: Use cache
uses: actions/cache@v6
with:
key: mkdocs-material-${{ github.run_id }}
path: .cache
restore-keys: |
mkdocs-material-
- name: Run mkdocs
env:
DISABLE_MKDOCS_2_WARNING: 'true'
run: mkdocs gh-deploy --force
+220
View File
@@ -0,0 +1,220 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: CC0-1.0
---
name: Checks (Python)
on:
pull_request:
paths: &on_paths
- .github/actions/setup-python/action.yml
- .github/workflows/checks_python.yml
- src/**/resources/**
- src/**/*.json
- src/**/*.qrc
- tests/**
- .editorconfig
- pyproject.toml
- uv.lock
- '**.pyi?'
push:
branches-ignore:
- translations
paths: *on_paths
workflow_dispatch:
defaults:
run:
shell: sh
jobs:
run-conditions:
permissions:
pull-requests: read
name: Run Conditions
runs-on: ubuntu-latest
outputs:
pyright: ${{ steps.run-conditions.outputs.pyright }}
pytest: ${{ steps.run-conditions.outputs.pytest }}
ruff: ${{ steps.run-conditions.outputs.ruff }}
steps:
- name: Checkout repository
if: github.event_name != 'pull_request'
uses: actions/checkout@v7
with:
# Largest positive number; infinite depth.
# Using 0 would grab all branches.
# See: https://github.com/actions/checkout/issues/520
# See: https://stackoverflow.com/questions/6802145/how-to-convert-a-git-shallow-clone-to-a-full-clone/6802238#6802238
# `git fetch --unshallow` as suggested in later answers would be an extra operation.
fetch-depth: '2147483647'
- name: Check changed files
id: changed-files
uses: tj-actions/changed-files@v47.0.6
with:
fail_on_initial_diff_error: 'true'
fail_on_submodule_diff_error: 'true'
skip_initial_fetch: 'true'
# WARNING: Does not support `?` glob operand!
files_yaml: |
generic:
- .github/workflows/checks_python.yml
- pyproject.toml
- uv.lock
- '**.py'
- '**.pyi'
pyright:
- .github/actions/setup-python/action.yml
pytest:
- .github/actions/setup-python/action.yml
- src/**/resources/**
- src/**/*.json
- src/**/*.qrc
- tests/**
ruff:
- .editorconfig
- name: Set run conditions
id: run-conditions
env:
CHANGED_GENERIC: ${{ steps.changed-files.outputs.generic_any_changed }}
CHANGED_PYRIGHT: ${{ steps.changed-files.outputs.pyright_any_changed }}
CHANGED_PYTEST: ${{ steps.changed-files.outputs.pytest_any_changed }}
CHANGED_RUFF: ${{ steps.changed-files.outputs.ruff_any_changed }}
run: |
pyright=false
pytest=false
ruff=false
if [ "${CHANGED_GENERIC}" = true ]; then
pyright=true
pytest=true
ruff=true
else
if [ "${CHANGED_PYRIGHT}" = true ]; then
pyright=true
fi
if [ "${CHANGED_PYTEST}" = true ]; then
pytest=true
fi
if [ "${CHANGED_RUFF}" = true ]; then
ruff=true
fi
fi
cat <<EOF >>"${GITHUB_OUTPUT}"
pyright=${pyright}
pytest=${pytest}
ruff=${ruff}
EOF
check-pyright:
concurrency:
group: pyright-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
name: Pyright
needs: run-conditions
if: needs.run-conditions.outputs.pyright == 'true'
runs-on: ubuntu-latest
steps:
- &checkout
name: Checkout repository
uses: actions/checkout@v7
- name: Setup Python
uses: ./.github/actions/setup-python
with:
groups: |
pyright
pytest
- name: Execute Pyright
run: pyright
check-pytest:
concurrency:
group: ${{ matrix.os }}-pytest-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-22.04
os-label: Linux
- os: windows-2022
os-label: Windows
name: pytest${{ matrix.os-label && format(' ({0})', matrix.os-label) }}
needs: run-conditions
if: needs.run-conditions.outputs.pytest == 'true'
runs-on: ${{ matrix.os }}
steps:
- *checkout
- name: Setup Python
uses: ./.github/actions/setup-python
with:
groups: pytest
- name: Install system dependencies (Linux)
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libegl1 \
libgl1 \
libopengl0 \
libpulse0 \
libxcb-cursor0 \
libxcb-icccm4 \
libxcb-image0 \
libxcb-keysyms1 \
libxcb-randr0 \
libxcb-render-util0 \
libxcb-xinerama0 \
libxkbcommon-x11-0 \
libyaml-dev \
ripgrep \
x11-utils
- name: Install system dependencies (macOS)
if: runner.os == 'macOS'
run: brew install ripgrep
- name: Install system dependencies (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: choco install ripgrep
- name: Execute pytest (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: pytest
- name: Execute pytest (non-Windows)
if: runner.os != 'Windows'
run: pytest
check-ruff:
concurrency:
group: ruff-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
name: Ruff
needs: run-conditions
if: needs.run-conditions.outputs.ruff == 'true'
runs-on: ubuntu-latest
steps:
- *checkout
- parallel:
- name: Run Ruff linter
uses: astral-sh/ruff-action@v4.0.0
- name: Run Ruff formatter
uses: astral-sh/ruff-action@v4.0.0
with:
args: format --check
-51
View File
@@ -1,51 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
---
name: Publish Docs
on:
push:
branches:
- main
paths:
- .github/workflows/publish_docs.yaml
- docs/**
- mkdocs.yml
- CHANGELOG.md
permissions:
contents: write
concurrency:
group: publish-docs
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Install Python dependencies
run: |
python -m pip install --upgrade uv
uv pip install --system .[mkdocs]
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
- uses: actions/cache@v4
with:
key: mkdocs-material-${{ env.cache_id }}
path: .cache
restore-keys: |
mkdocs-material-
- name: Execute mkdocs
run: mkdocs gh-deploy --force
-31
View File
@@ -1,31 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
---
name: Pyright
on: [push, pull_request]
jobs:
pyright:
name: Run Pyright
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Install Python dependencies
run: |
python -m pip install --upgrade uv
uv pip install --system .[pyright,pytest]
- name: Execute Pyright
uses: jordemort/action-pyright@v1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
reporter: ${{ github.event_name == 'pull_request' && 'github-pr-review' || 'github-check' }}
-97
View File
@@ -1,97 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
---
name: pytest
on: [push, pull_request]
jobs:
pytest-linux:
name: Run pytest (Linux)
runs-on: ubuntu-24.04
steps:
- &checkout
name: Checkout repo
uses: actions/checkout@v4
- &setup-python
name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- &install-dependencies
name: Install Python dependencies
run: |
python -m pip install --upgrade uv
uv pip install --system .[pytest]
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libegl1 \
libgl1 \
libopengl0 \
libpulse0 \
libxcb-cursor0 \
libxcb-icccm4 \
libxcb-image0 \
libxcb-keysyms1 \
libxcb-randr0 \
libxcb-render-util0 \
libxcb-xinerama0 \
libxkbcommon-x11-0 \
libyaml-dev \
ripgrep \
x11-utils
- name: Execute pytest
run: |
xvfb-run pytest --cov-report xml --cov=tagstudio
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage.xml
pytest-windows:
name: Run pytest (Windows)
runs-on: windows-2025
steps:
- *checkout
- *setup-python
- *install-dependencies
- name: Install system dependencies
run: |
choco install ripgrep
- name: Execute pytest
run: |
pytest
coverage:
name: Check coverage
runs-on: ubuntu-latest
needs: pytest-linux
steps:
- name: Fetch coverage
uses: actions/download-artifact@v4
with:
name: coverage
- name: Check coverage
uses: yedpodtrzitko/coverage@main
with:
thresholdAll: 0.1
thresholdNew: 0.1
thresholdModified: 0.1
coverageFile: coverage.xml
token: ${{ secrets.GITHUB_TOKEN }}
sourceDir: tagstudio/src
-156
View File
@@ -1,156 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
---
name: Release
on:
push:
tags:
- v[0-9]+.[0-9]+.[0-9]+*
jobs:
linux:
strategy:
matrix:
build-type: ["", portable]
include:
- build-type: ""
build-flag: ""
suffix: ""
- build-type: portable
build-flag: --portable
suffix: _portable
runs-on: ubuntu-22.04
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Install Python dependencies
run: |
python -m pip install --upgrade uv
uv pip install --system .[pyinstaller]
- name: Execute PyInstaller
run: |
pyinstaller tagstudio.spec -- ${{ matrix.build-flag }}
tar czfC dist/tagstudio_linux_x86_64${{ matrix.suffix }}.tar.gz dist tagstudio
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: tagstudio_linux_x86_64${{ matrix.suffix }}
path: dist/tagstudio_linux_x86_64${{ matrix.suffix }}.tar.gz
macos:
strategy:
matrix:
os-version: ["14", "15"]
include:
- os-version: "14"
arch: x86_64
- os-version: "15"
arch: aarch64
runs-on: macos-${{ matrix.os-version }}
env:
# INFO: Even though we run on 14, target towards compatibility
MACOSX_DEPLOYMENT_TARGET: "11.0"
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Install Python dependencies
run: |
python -m pip install --upgrade uv
uv pip install --system .[pyinstaller]
- name: Execute PyInstaller
run: |
pyinstaller tagstudio.spec
tar czfC dist/tagstudio_macos_${{ matrix.arch }}.tar.gz dist TagStudio.app
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: tagstudio_macos_${{ matrix.arch }}
path: dist/tagstudio_macos_${{ matrix.arch }}.tar.gz
windows:
strategy:
matrix:
build-type: ["", portable]
include:
- build-type: ""
build-flag: ""
suffix: ""
file-end: ""
- build-type: portable
build-flag: --portable
suffix: _portable
file-end: .exe
runs-on: windows-2022
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Install Python dependencies
run: |
python -m pip install --upgrade uv
uv pip install --system .[pyinstaller]
- name: Execute PyInstaller
run: |
PyInstaller tagstudio.spec -- ${{ matrix.build-flag }}
Compress-Archive -Path dist/TagStudio${{ matrix.file-end }} -DestinationPath dist/tagstudio_windows_x86_64${{ matrix.suffix }}.zip
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: tagstudio_windows_x86_64${{ matrix.suffix }}
path: dist/tagstudio_windows_x86_64${{ matrix.suffix }}.zip
publish:
needs: [linux, macos, windows]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Fetch artifacts
uses: actions/download-artifact@v4
- name: Publish release
uses: softprops/action-gh-release@v2
with:
files: |
tagstudio_linux_x86_64/*
tagstudio_linux_x86_64_portable/*
tagstudio_macos_x86_64/*
tagstudio_macos_aarch64/*
tagstudio_windows_x86_64/*
tagstudio_windows_x86_64_portable/*
-12
View File
@@ -1,12 +0,0 @@
# SPDX-FileCopyrightText: 2020 Free Software Foundation Europe e.V.
# SPDX-License-Identifier: CC0-1.0
name: REUSE compliance check
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: REUSE Compliance Check
uses: fsfe/reuse-action@v6
+30
View File
@@ -0,0 +1,30 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: CC0-1.0
---
name: REUSE Compliance Check
on:
pull_request:
push:
branches-ignore:
- translations
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
defaults:
run:
shell: sh
jobs:
check:
name: REUSE
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Run REUSE
uses: fsfe/reuse-action@v6
-33
View File
@@ -1,33 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
---
name: Ruff
on: [push, pull_request]
jobs:
ruff-format:
name: Run Ruff format
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Execute Ruff format
uses: astral-sh/ruff-action@v3
with:
version: 0.11.0
args: format --check
ruff-check:
name: Run Ruff check
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Execute Ruff check
uses: astral-sh/ruff-action@v3
with:
version: 0.11.8
args: check
+34 -7
View File
@@ -7,10 +7,10 @@ __pycache__/
*.py[cod]
*$py.class
# C extensions
# C Extensions
*.so
# Distribution / packaging
# Distribution / Packaging
.Python
build/
develop-eggs/
@@ -41,7 +41,7 @@ MANIFEST
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
# Unit test / Coverage reports
htmlcov/
.tox/
.nox/
@@ -88,6 +88,8 @@ 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
# pipenv
@@ -95,14 +97,21 @@ ipython_config.py
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
poetry.lock
poetry.toml
# uv
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
uv.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
@@ -111,6 +120,8 @@ ipython_config.py
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
@@ -138,8 +149,24 @@ venv.bak/
# Rope project settings
.ropeproject
# macoS
*.DS_Store
# macOS
.DocumentRevisions-V100
.DS_Store
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
# Linux
.directory
.fuse_hidden*
.nfs*
.Trash-*
# Windows
[Dd]esktop.ini
$RECYCLE.BIN/
# mkdocs documentation
/site
+2
View File
@@ -1,3 +1,5 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: CC0-1.0
---
repos:
- repo: local
+5
View File
@@ -8,3 +8,8 @@ bracketSpacing = true
objectWrap = "preserve"
quoteProps = "as-needed"
singleQuote = false
[[overrides]]
files = [".github/**/*.yml", ".github/**/*.yaml"]
[overrides.options]
singleQuote = true
+4 -6
View File
@@ -3,14 +3,13 @@
# TagStudio: A User-Focused Photo & File Management System
[![Downloads](https://img.shields.io/github/downloads/TagStudioDev/TagStudio/total.svg?maxAge=2592001)](https://github.com/TagStudioDev/TagStudio/releases)
[![Downloads](https://img.shields.io/github/downloads/TagStudioDev/TagStudio/total.svg)](https://github.com/TagStudioDev/TagStudio/releases)
[![Translations](https://hosted.weblate.org/widget/tagstudio/strings/svg-badge.svg)](https://hosted.weblate.org/projects/tagstudio/strings/)
[![PyTest](https://github.com/TagStudioDev/TagStudio/actions/workflows/pytest.yaml/badge.svg)](https://github.com/TagStudioDev/TagStudio/actions/workflows/pytest.yaml)
[![MyPy](https://github.com/TagStudioDev/TagStudio/actions/workflows/mypy.yaml/badge.svg)](https://github.com/TagStudioDev/TagStudio/actions/workflows/mypy.yaml)
[![Ruff](https://github.com/TagStudioDev/TagStudio/actions/workflows/ruff.yaml/badge.svg)](https://github.com/TagStudioDev/TagStudio/actions/workflows/ruff.yaml)
[![REUSE Status](https://api.reuse.software/badge/github.com/TagStudioDev/TagStudio)](https://api.reuse.software/info/github.com/TagStudioDev/TagStudio)
[![Python Checks](https://github.com/TagStudioDev/TagStudio/actions/workflows/checks_python.yml/badge.svg)](https://github.com/TagStudioDev/TagStudio/actions/workflows/checks_python.yml)
<p align="center">
<img width="60%" src="docs/assets/ts-9-3_logo_text.png">
<img width="60%" src="src/tagstudio/resources/qt/images/tagstudio_logo-text_color.png">
</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. **Read the documentation and more at [docs.tagstud.io](https://docs.tagstud.io)!**
@@ -172,7 +171,6 @@ See the [**Roadmap**](docs/roadmap.md) on the documentation site for a complete
- To provide powerful methods for organization, notably the concept of tag inheritance, or "taggable tags" _(and in the near future, the combination of composition-based tags)._
- To create an implementation of such a system that is resilient against a users actions outside the program (modifying, moving, or renaming files) while also not burdening the user with mandatory sidecar files or requiring them to change their existing file structures and workflows.
- To support a wide range of users spanning across different platforms, multi-user setups, and those with large (several terabyte) libraries.
- To make the dang thing look nice, too. Its 2025, not 1995.
### Project Priorities
+8 -7
View File
@@ -4,8 +4,6 @@ version = 1
path = [
"tests/fixtures/**",
"contrib/.envrc-nix",
"contrib/.envrc-uv",
"contrib/.vscode/launch.json",
"docs/CNAME",
"docs/assets/**",
@@ -19,8 +17,10 @@ path = [
".git-blame-ignore-revs",
".gitattributes",
".github/FUNDING.yml",
".github/ISSUE_TEMPLATE/**",
".github/PULL_REQUEST_TEMPLATE.md",
".gitignore",
".pre-commit-config.yaml",
"flake.lock",
]
SPDX-FileCopyrightText = "(c) TagStudio Contributors"
@@ -33,17 +33,18 @@ SPDX-License-Identifier = "GPL-3.0-or-later"
[[annotations]]
path = [
"src/tagstudio/resources/qt/images/bxs-clipboard-regular.png",
"src/tagstudio/resources/qt/images/bxs-left-arrow.png",
"src/tagstudio/resources/qt/images/bxs-pencil-solid.png",
"src/tagstudio/resources/qt/images/bxs-right-arrow.png",
"src/tagstudio/resources/qt/images/bxs-trash-solid.png",
"src/tagstudio/resources/qt/images/bxs-volume-full-solid.png",
"src/tagstudio/resources/qt/images/file_icons/database.png",
]
SPDX-FileCopyrightText = "(c) 2026 Boxicons"
SPDX-License-Identifier = "MIT"
[[annotations]]
path = [
"src/tagstudio/resources/qt/images/volume.svg",
"src/tagstudio/resources/qt/images/volume_mute.svg",
]
path = ["src/tagstudio/resources/qt/images/dupe_file_stat.png"]
SPDX-FileCopyrightText = "(c) github:google/material-design-icons Contributors"
SPDX-License-Identifier = "Apache-2.0"
+3 -2
View File
@@ -1,4 +1,7 @@
# vi: ft=bash
# shellcheck shell=bash
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: CC0-1.0
# If you wish to use this file, symlink or copy it to `.envrc` for direnv to read it.
# This will use the Nix flake development shell.
@@ -15,5 +18,3 @@ use flake
# Only watch now, or direnv will execute again if created or modified by itself.
watch_file "${UV_PROJECT_ENVIRONMENT:-.venv}"/bin/activate
# vi: ft=bash
+4 -3
View File
@@ -1,4 +1,7 @@
# vi: ft=sh
# shellcheck shell=bash
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: CC0-1.0
# If you wish to use this file, symlink or copy it to `.envrc` for direnv to read it.
# This will use a virtual environment created by uv.
@@ -23,10 +26,8 @@ source "${venv}"/bin/activate
if [ ! -f "${venv}"/pyproject.toml ] || ! diff --brief pyproject.toml "${venv}"/pyproject.toml >/dev/null; then
printf '%s\n' 'Installing dependencies, pyproject.toml changed...' >&2
uv pip install --quiet --editable '.[dev]'
uv pip install --quiet --editable . --group all
cp pyproject.toml "${venv}"/pyproject.toml
fi
pre-commit install
# vi: ft=bash
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+1
View File
@@ -0,0 +1 @@
../../src/tagstudio/resources/icon.ico
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 677 KiB

+3 -6
View File
@@ -1,10 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 739 739" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.986683,0,0,0.986683,-136.081,-136.081)">
<path d="M535.939,863.161C515.931,843.153 203.505,529.713 183.497,509.705C169.086,495.294 161.469,476.645 160.649,457.754C160.046,443.845 138.078,230.102 137.923,217.139C137.681,196.785 145.323,176.356 160.839,160.839C177.115,144.564 198.795,136.951 220.125,138.016C232.439,138.63 447.036,159.52 461.817,160.931C479.3,162.6 496.329,170.12 509.705,183.497C523.113,196.904 849.753,522.531 863.161,535.939C893.716,566.494 893.716,616.108 863.161,646.663L646.663,863.161C616.108,893.716 566.494,893.716 535.939,863.161ZM321.355,223.613C296.045,198.303 254.947,198.303 229.636,223.613C204.326,248.924 204.326,290.022 229.636,315.332C254.947,340.643 296.045,340.643 321.355,315.332C346.666,290.022 346.666,248.924 321.355,223.613ZM362.109,606.786C409.476,654.152 424.103,598.401 454.027,606.786C468.584,610.865 453.72,642.505 443.028,673.551C425.086,725.641 484.094,757.817 516.601,720.743C545.603,687.667 503.579,655.692 520.581,632.527C537.795,609.074 563.542,633.319 565.542,665.527C568.921,719.955 535.825,735.585 543.999,774.591C553.59,820.348 624.181,827.565 638,774.591C647.736,737.269 603.102,705.31 628.352,644.476C636.209,625.545 662.786,619.154 669.759,644.476C673.976,659.791 660.264,670.152 666.759,693.55C674.41,721.114 725.088,732.96 740.374,693.55C746.873,676.793 734.853,651.273 731.597,640.406C714.283,582.611 826.807,582.426 762.374,517.789C703.034,458.263 493.6,249.017 493.6,249.017C479.164,234.58 457.464,234.58 443.028,249.017L249.017,443.028C234.58,457.464 234.58,479.164 249.017,493.6C249.017,493.6 340.149,584.825 362.109,606.786Z" style="fill:white;"/>
</g>
<g transform="matrix(0.986683,0,0,0.986683,-136.081,-136.081)">
<path d="M733.962,560.164C740.987,553.139 740.987,541.733 733.962,534.708L482.173,282.92C475.148,275.895 463.742,275.895 456.717,282.92L431.261,308.376C424.237,315.4 424.237,326.807 431.261,333.831L683.05,585.62C690.075,592.645 701.481,592.645 708.506,585.62L733.962,560.164ZM439.639,559.207C446.664,552.182 446.664,540.776 439.639,533.751L335.491,429.602C328.466,422.578 317.059,422.578 310.035,429.602L284.579,455.058C277.554,462.083 277.554,473.489 284.579,480.514L388.728,584.663C395.752,591.688 407.159,591.688 414.184,584.663L439.639,559.207ZM584.306,556.624C591.331,549.599 591.331,538.192 584.306,531.168L409.115,355.977C402.091,348.953 390.684,348.953 383.66,355.977L358.204,381.433C351.179,388.458 351.179,399.864 358.204,406.889L533.394,582.079C540.419,589.104 551.825,589.104 558.85,582.079L584.306,556.624ZM298.425,246.543C311.081,259.198 311.081,279.747 298.425,292.402C285.77,305.058 265.221,305.058 252.566,292.402C239.911,279.747 239.911,259.198 252.566,246.543C265.221,233.888 285.77,233.888 298.425,246.543Z" style="fill:white;"/>
<svg width="100%" height="100%" viewBox="0 0 944 944" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.707107,0.707107,-0.707107,0.707107,48.0404,-676.037)">
<path d="M754.971,240.435C773.719,224.654 797.438,216 821.944,216L1440,216C1497.44,216 1544,262.562 1544,320L1544,704C1544,761.438 1497.44,808 1440,808L821.944,808C797.438,808 773.719,799.346 754.971,783.565L526.872,591.565C503.396,571.805 489.845,542.685 489.845,512C489.845,481.315 503.396,452.195 526.872,432.435L754.971,240.435ZM1059.36,727.98C1142.98,727.98 1106.68,665.856 1140.5,646.842C1156.95,637.593 1171.76,678.644 1189.72,715.486C1219.87,777.305 1300.35,753.619 1296.32,692.2C1292.73,637.402 1227.41,646.272 1221.97,610.817C1216.46,574.918 1260.59,573.593 1290.78,600.258C1341.81,645.319 1326.39,688.332 1368.04,715.546C1416.89,747.471 1485.58,691.53 1451.02,632.571C1426.66,591.033 1359.05,602.221 1327.64,526.233C1317.87,502.586 1335.69,473.485 1364.19,489.683C1381.44,499.479 1378.48,520.729 1404.87,535.649C1435.95,553.226 1491.14,518.948 1469.85,470.669C1460.79,450.139 1427.65,438.223 1415.19,431.504C1348.89,395.77 1448.05,296.282 1334.12,296.102C1229.19,295.936 859.62,296.102 859.62,296.102C834.132,296.102 814.978,315.256 814.978,340.744L814.978,683.256C814.978,708.744 834.132,727.898 859.62,727.898C859.62,727.898 1020.59,727.98 1059.36,727.98ZM1085.8,618L1085.8,662C1085.8,674.142 1075.94,684 1063.8,684L878,684C865.858,684 856,674.142 856,662L856,618C856,605.858 865.858,596 878,596L1063.8,596C1075.94,596 1085.8,605.858 1085.8,618ZM1346.44,362L1346.44,406C1346.44,418.142 1336.59,428 1324.44,428L878,428C865.858,428 856,418.142 856,406L856,362C856,349.858 865.858,340 878,340L1324.44,340C1336.59,340 1346.44,349.858 1346.44,362ZM1216,490L1216,534C1216,546.142 1206.14,556 1194,556L878,556C865.858,556 856,546.142 856,534L856,490C856,477.858 865.858,468 878,468L1194,468C1206.14,468 1216,477.858 1216,490ZM752.58,455.42C721.352,424.193 670.648,424.193 639.42,455.42C608.193,486.648 608.193,537.352 639.42,568.58C670.648,599.807 721.352,599.807 752.58,568.58C783.807,537.352 783.807,486.648 752.58,455.42ZM724.29,483.71C739.903,499.324 739.903,524.676 724.29,540.29C708.676,555.903 683.324,555.903 667.71,540.29C652.097,524.676 652.097,499.324 667.71,483.71C683.324,468.097 708.676,468.097 724.29,483.71Z" style="fill:white;"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 156 KiB

+1
View File
@@ -0,0 +1 @@
../../src/tagstudio/resources/qt/images/tagstudio_logo-text_color.png
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

+119 -7
View File
@@ -9,6 +9,122 @@ toc_depth: 2
# :material-script-text: Changelog
## 9.6.1 <small>July 9th, 2026</small>
### Added
- feat(ui): render .pxd thumbnails by @Sola-ris in #1430
### Changed
- feat(ui): organize settings into panels by @purpletennisball in #1425
- feat(ui): left click to edit tag in tag manager by @purpletennisball in #1416
### Fixed
- fix: fix issues with updating tag aliases causing freezes by @CyanVoxel in #1412
- fix: make TagStudio python package executable by @Computerdores in #1414
- fix: use optimized SQL when selecting non-hidden entries by @racerand in #1240
- fix: fix ripgrep not being located for macOS builds by @CyanVoxel in #1427
- fix: remove invalid child_id relationships from tag_parents by @CyanVoxel in #1423
- fix(ui): fix "search for tag" function in tag manager by @CyanVoxel in #1411
- fix(ui): mouse down selects thumbnail instead of mouse up by @purpletennisball in #1420
- fix(ui): thumbnail grid not resizing to fill width by @TheBobBobs in #1433
#### Internal Changes
- fix(nix): add pyright to devshell by @Computerdores in #1415
- fix(deps): replace optional dependencies with dependency groups by @Xarvex in #1435
- feat(ci)!: complete workflows revamp by @Xarvex in #1437
#### Translations
- **French** updated by @kitsumed
- **Hungarian** updated by @smileyhead
- **Russian** updated by @romandobra
- **Spanish** updated by @JCC1998, @2004milenadiaz-source
---
## 9.6.0 <small>June 29th, 2026</small>
<p align="center">
<img width="480" alt="aurora" src="https://github.com/user-attachments/assets/6103bb88-bc66-4222-9844-ed64d27dd2e7" />
</p>
### Added
#### Customizable Fields
[Fields](fields.md) are now fully customizable!
- The built-in list of fields (now referred to as "field templates") has been reduced to a list of handy essential templates that can be fully modified or deleted
- "Text Line" and "Text Box" field types have been combined into a single "Text" type with a "Multiline" option that can be configured on templates or toggled dynamically on existing text fields
- You can now create your own field templates in with custom names, types, and other options
- Fields applied to file entries can now have their names edited
<img width="314" height="507" alt="field_template_manager" src="https://github.com/user-attachments/assets/523fac64-e0d6-4697-ba25-6ff89273d0cb" />
<img width="494" height="342" alt="text_field_editor" src="https://github.com/user-attachments/assets/918956ee-647b-43ca-9843-e8acb67ee58f" />
- refactor!: uncouple fields from hard-coded values by @CyanVoxel in #1354
- fix: drop type_key column from text_fields and datetime_fields tables by @CyanVoxel in #1370
- feat: field template manager by @TrigamDev in #1374
- feat: add field template editor, editable field names by @CyanVoxel in #1396
#### Other Additions
- feat: add ability to configure automatic loading of new files over 10000 by @socalledtheraven in #1349
- feat: dismiss update notification, change missing ffmpeg popup to banner by @CyanVoxel in #1400
- feat: allow all raster images to be used as archive thumbnails. by @Sola-ris in #1373
### Fixed
- fix: Fix update notification crash by @Sola-ris in #1278
- fix: correct ESCAPABLE_CHARS in query_lang tokenizer by @hieuit095 in #1334
- fix: empty ORLists now return false by @TheBobBobs in #1297
- fix: update thumb grid when adding tags from preview panel by @TheBobBobs in #1245
- fix: match against the correct path in the ignore registry by @TrigamDev in #1382
- fix: fix tag aliases by @CyanVoxel in #1399
- fix: fix faulty progress bars by @ludvig-sandh in #1293
- perf: Optimize searching tags with DB indexes by @TheBobBobs in #1129
### Changed
- ui: add v9.6 assets, update misc resources by @CyanVoxel in #1398
#### Internal Changes
- minor refactor: cleanup parameters of open_library and open_sqlite_library by @Computerdores in #1294
- refactor: sql migrations by @Computerdores in #1295
- fix: remove preferences table by @Computerdores in #1298
- doc: add REUSE license information by @Computerdores in #1361
- docs/chore: add .editorconfig and .prettierrc.toml configs by @CyanVoxel in #1362
- refactor(docs): uniform formatting pass by @CyanVoxel in #1363
- fix: pyright errors in ts_qt.py by @Computerdores in #1237
- fix(ci): address remaining pyright errors by @CyanVoxel in #1368
- ci: add pyright workflow by @Computerdores in #1232
- refactor(ci): remove mypy by @CyanVoxel in #1371
- chore(deps-dev): bump pytest from 8.3.5 to 9.0.3 by @dependabot[bot] in #1372
- doc(install): add common error message help text by @Computerdores in #1369
- chore(ci): bump ruff to 0.15.17 by @CyanVoxel in #1393
- fix(pyproject): allow Python 3.13 to function by @xarvex in #1047
#### Translations
- **Amharic** added by @Birhant _(11%, can manually enable in settings file)_
- **Hebrew** added by @JonathanGlixman _(2%, can manually enable in settings file)_
- **French** updated by @SodiumBismuth, @kitsumed
- **Russian** updated by @WerDei, @NikitaNik-of, @Dott-rus
- **Hungarian** updated by @smileyhead
- **Japanese** updated by wany-oh
- **Italian** updated by @EdelFlosWeiss
- **Spanish** updated by @JulArr22
- **Toki Pona** updated by Cyborus
- **Viossa** updated by @Nginearing
- **Portuguese** updated by ssantos
---
## 9.5.7 <small>May 5th, 2026</small>
This update adds several bugfixes and additions that have been sitting on the main branch for quite some time.
@@ -68,12 +184,6 @@ This update adds several bugfixes and additions that have been sitting on the ma
- **Tamil** updated by @TamilNeram
- **Toki Pona** updated by @Math-Bee, Star Athendwyl
### New Contributors
- @Ambossmann made their first contribution in #1189
- @CallMeHein made their first contribution in #1173
- @terahidro2003 made their first contribution in #1328
---
## 9.5.6 <small>October 20th, 2025</small>
@@ -422,7 +532,9 @@ A new "Library Information" window has been added and is accessible under the "V
## 9.5.0 <small>March 3rd, 2025</small>
<img width="500" src="https://github.com/user-attachments/assets/858f1494-216f-4521-aefe-d0aa4f754b9e" alt="TagStudio 9.5 Banner" />
<p align="center">
<img width="480" alt="TagStudio 9.5 Banner" src="https://github.com/user-attachments/assets/858f1494-216f-4521-aefe-d0aa4f754b9e" />
</p>
### Added
+1 -1
View File
@@ -23,7 +23,7 @@ If you wish to discuss TagStudio further, feel free to join the [Discord Server]
#### Feature Additions
- [x] I've read the [Feature Roadmap](roadmap.md) and understand what core features are planned, their priorities, and their scheduled timelines
- [x] I've read the [Roadmap](roadmap.md) and understand what core features are planned, their priorities, and their scheduled timelines
- [x] I've found an existing [feature request](https://github.com/TagStudioDev/TagStudio/issues) or created my own **_before starting work on a feature_** so that the feature can be discussed beforehand
<!-- prettier-ignore -->
+7 -7
View File
@@ -57,7 +57,7 @@ To install the required dependencies, you can use a dependency manager such as [
If using [uv](https://docs.astral.sh/uv), you can install the dependencies for TagStudio with the following command:
```sh
uv pip install -e ".[dev]"
uv pip install -e . --group all
```
TagStudio should now be runnable using the `tagstudio` command.
@@ -109,7 +109,7 @@ If you choose to manually set up a virtual environment and install dependencies
3. Use the following PIP command to create an editable installation and install the required development dependencies:
```sh
pip install -e ".[dev]"
pip install -e . --group all
```
4. TagStudio should now be runnable using the `tagstudio` command.
@@ -161,7 +161,7 @@ The entry point for TagStudio is `src/tagstudio/main.py`. You can target this fi
[Ruff](https://github.com/astral-sh/ruff) is a Python linter and code formatter that helps enforce a consistent formatting style across our codebase.
Ruff is installed alongside the `pip install -e ".[dev]"` command, but is also available as a VS Code [extension](https://marketplace.visualstudio.com/items?itemName=charliermarsh.ruff), PyCharm [plugin](https://plugins.jetbrains.com/plugin/20574-ruff), and [more](https://docs.astral.sh/ruff/integrations/).
Ruff is installed alongside the `pip install -e . --group all` command, but is also available as a VS Code [extension](https://marketplace.visualstudio.com/items?itemName=charliermarsh.ruff), PyCharm [plugin](https://plugins.jetbrains.com/plugin/20574-ruff), and [more](https://docs.astral.sh/ruff/integrations/).
```sh title="Lint Code"
ruff check
@@ -180,7 +180,7 @@ Ruff should automatically discover the configuration options inside the [pyproje
[Pyright](https://github.com/microsoft/pyright) is a static type checker for Python that helps enforce type strictness and prevent easy-to-miss errors across our codebase.
Pyright is installed alongside the `pip install -e ".[dev]"` command, but is also available as VS Code extensions (see the [Pyright](https://marketplace.visualstudio.com/items?itemName=ms-pyright.pyright), [Pylance](https://marketplace.visualstudio.com/items?itemName=ms-python.vscode-pylance), and [basedpyright](https://marketplace.visualstudio.com/items?itemName=detachhead.basedpyright) extensions), a PyCharm [setting](https://www.jetbrains.com/help/pycharm/lsp-tools.html#pyright), or in the form of forks such as [basedpyright](https://docs.basedpyright.com/latest/).
Pyright is installed alongside the `pip install -e . --group all` command, but is also available as VS Code extensions (see the [Pyright](https://marketplace.visualstudio.com/items?itemName=ms-pyright.pyright), [Pylance](https://marketplace.visualstudio.com/items?itemName=ms-python.vscode-pylance), and [basedpyright](https://marketplace.visualstudio.com/items?itemName=detachhead.basedpyright) extensions), a PyCharm [setting](https://www.jetbrains.com/help/pycharm/lsp-tools.html#pyright), or in the form of forks such as [basedpyright](https://docs.basedpyright.com/latest/).
```sh title="Run Checks"
pyright
@@ -192,7 +192,7 @@ Pyright/basedpyright should automatically discover the configuration options ins
[Pytest](https://github.com/pytest-dev/pytest) runs our Python code against the tests inside the [`tests/`](https://github.com/TagStudioDev/TagStudio/tree/main/tests) directory.
Pytest is installed alongside the `pip install -e ".[dev]"` command.
Pytest is installed alongside the `pip install -e . --group all` command.
```sh title="Run Tests"
pytest tests/
@@ -236,13 +236,13 @@ direnv allow
To build your own executables of TagStudio, first follow the steps in "[Installing Dependencies](#installing-dependencies)." Once that's complete, run the following PyInstaller command:
```
pyinstaller tagstudio.spec
pyinstaller scripts/tagstudio.spec
```
If you're on Windows or Linux and wish to build a portable executable, then pass the following flag:
```
pyinstaller tagstudio.spec -- --portable
pyinstaller scripts/tagstudio.spec -- --portable
```
The resulting executable file(s) will be located in a new folder named "dist".
+40 -11
View File
@@ -8,24 +8,53 @@ icon: material/text-box
# :material-text-box: Fields
Fields are additional types of metadata that you can attach to [file entries](./entries.md). Like [tags](tags.md), fields are not stored inside files themselves nor in sidecar files, but rather inside the respective TagStudio [library](./index.md) save file.
Fields are extra pieces of information you can add to [file entries](./entries.md), similar to how [tags](tags.md) are added to entries. Fields are useful for storing information that doesn't nessisarily need to be a tag, such as titles, comments, notes, specific dates or times, etc.
## Field Types
To add a field to an entry, click the "Add Field" button in the preview panel. From there you can search and/or select a [field template](#field-templates) to choose from, or create a new one from the search bar. Alternatively you can create new field templates from **Edit -> Manage Field Templates**.
### Text Line
<figure markdown="span">
![Fields Example](assets/fields_example.png)
<figcaption>Example of tags and various fields on a file entry.</figcaption>
</figure>
A string of text, displayed as a single line.
## :material-text-box-plus-outline: Field Templates
- e.g: Title, Author, Artist, URL, etc.
Field templates are handy templates to use when adding fields to entries that contain preconfigured options but no actual data. When you add a field to an entry from the "Add Field" button, you choose from a template to add and then fill in the information afterwards. TagStudio includes a handful of field templates to start you off with, but you're free to modify or delete them, or simply create your own.
### Text Box
Field templates can be viewed, created, and deleted from the **Edit -> Manage Field Templates** window. You can also edit field templates from the "Add Field" menu, and create new ones on the fly from the search bar. Note that you can not currently delete field templates from the "Add Field" menu, just like tags.
A long string of text displayed as a box of text.
<figure markdown="span">
![Field Template Manager](assets/field_template_manager.png)
<figcaption>Field Template Manager from <b>Edit -> Manage Field Templates</b>.</figcaption>
</figure>
- e.g: Description, Notes, etc.
<figure markdown="span">
![Field Template Editor](assets/field_template_editor.png)
<figcaption>The field template editor, shown creating a new "Citations" field.</figcaption>
</figure>
### Datetime
## :material-format-list-bulleted-type: Field Types
A date and time value.
Fields come in a variety of types that are better suited for different types of information, and may provide additional options unique to those types. Single lines are good for fields like titles, while multiline blocks are good for things like comments and notes.
- e.g: Date Published, Date Taken, etc.
### :material-text-box: Text
Text fields contain a piece of text with the option to display it either a single line or a multiline body of text.
| Option | Value | Description |
| --------- | ---------- | ------------------------------------------------------------------------ |
| Multiline | True/False | Indicates if the text should be displayed on multiple lines or just one. |
<figure markdown="span">
![Text Field Editor](assets/text_field_editor.png)
<figcaption>The text field editor, editing a "Comments" field on an entry.</figcaption>
</figure>
### :material-calendar-month: Datetime
Datetime fields contain a date and time value. Dates are formatted using the format specified in your application settings.
<figure markdown="span">
![Datetime Field Editor](assets/datetime_field_editor.png)
<figcaption>The datetime field editor, expanded to show the date picker.</figcaption>
</figure>
+2 -2
View File
@@ -13,7 +13,7 @@ hide:
<link rel="stylesheet" href="stylesheets/home.css">
<figure markdown="span">
![TagStudio](./assets/ts-9-3_logo_text.png){ width=80% }<h2>A User-Focused Photo & File Management System</h2>
![TagStudio](./assets/tagstudio_logo-text_color.png){ width=80% }<h2>A User-Focused Photo & File Management System</h2>
</figure>
<br>
@@ -87,7 +87,7 @@ hide:
[:material-arrow-right: View License](https://github.com/TagStudioDev/TagStudio/blob/main/LICENSE)
[:material-arrow-right: Roadmap to MIT Core Library License](roadmap.md#core-library-api)
[:material-arrow-right: Roadmap to MIT Core Library License](roadmap.md#core-library-cli)
- :material-database:{ .lg .middle } **Central Save File**
+21 -24
View File
@@ -1,12 +1,12 @@
---
title: Installation
title: Installing
icon: material/download
---
<!-- SPDX-FileCopyrightText: (c) TagStudio Contributors -->
<!-- SPDX-License-Identifier: GPL-3.0-only -->
# :material-download: Installation
# :material-download: Installing
TagStudio provides executable [releases](https://github.com/TagStudioDev/TagStudio/releases) as well as full access to its [source code](https://github.com/TagStudioDev/TagStudio) under the [GPLv3](https://github.com/TagStudioDev/TagStudio/blob/main/LICENSE) license.
@@ -17,8 +17,8 @@ To download executable builds of TagStudio, visit the [Releases](https://github.
TagStudio has builds for :fontawesome-brands-windows: **Windows**, :fontawesome-brands-apple: **macOS** _(Apple Silicon & Intel)_, and :material-penguin: **Linux**. We also offer portable releases for Windows and Linux which are self-contained and easier to move around.
<!-- prettier-ignore -->
!!! info "Third-Party Dependencies"
You may need to install [third-party dependencies](#third-party-dependencies) such as [FFmpeg](https://ffmpeg.org/download.html) to use the full feature set of TagStudio.
!!! info "Optional Dependencies"
You may need to install [optional third-party dependencies](#optional-dependencies) such as [FFmpeg](https://ffmpeg.org/download.html) to use the full feature set of TagStudio.
<!-- prettier-ignore -->
!!! warning ":fontawesome-brands-apple: macOS "Privacy & Security" Popup"
@@ -54,7 +54,7 @@ pip install .
!!! note "Developer Dependencies"
If you wish to create an editable install with the additional dependencies required for developing TagStudio, use this modified PIP command instead:
```sh
pip install -e ".[dev]"
pip install -e . --group all
```
_See more under "[Developing](developing.md)"_
@@ -82,6 +82,19 @@ Some external dependencies are required for TagStudio to execute. Below is a tab
| [qt-multimedia](https://repology.org/project/qt) | required |
| [qt-wayland](https://repology.org/project/qt) | Wayland support |
<!-- prettier-ignore -->
!!! bug "Missing Linux Dependency Example"
An error message such as the following indicates that you're missing the `libxcb-cursor` or `xcb-util-cursor` library, depending on your distro:
```
qt.qpa.plugin: From 6.5.0, xcb-cursor0 or libxcb-cursor0 is needed to load the Qt xcb platform plugin.
qt.qpa.plugin: Could not load the Qt platform plugin "xcb" in "/tmp/_MEIayuTiW/cv2/qt/plugins" even though it was found.
This application failed to start because no Qt platform plugin could be initialized. Reinstalling the application may fix this problem.
Available platform plugins are: vnc, wayland-egl, offscreen, wayland, linuxfb, minimalegl, eglfs, minimal, vkkhrdisplay, xcb.
Aborted (core dumped)
```
### :material-nix: Nix(OS)
For [Nix(OS)](https://nixos.org/), the TagStudio repository includes a [flake](https://wiki.nixos.org/wiki/Flakes) that provides some outputs such as a development shell and package.
@@ -212,7 +225,9 @@ Finally, `inputs` can be used in a module to add the package to your packages li
Don't forget to rebuild!
## Third-Party Dependencies
## Optional Dependencies
Some TagStudio functionality such as multimedia thumbnails and playback, RAR archive thumbnails, and improved directory scanning performance will require installing optional third-party dependencies. Depending on your system, you may already have one or more of these installed. To check
<!-- prettier-ignore -->
!!! tip
@@ -242,21 +257,3 @@ To generate thumbnails for RAR-based files (like `.cbr`) you'll need an extracto
### ripgrep
A recommended tool to improve the performance of directory scanning is [`ripgrep`](https://github.com/BurntSushi/ripgrep), a Rust-based directory walker that natively integrates with our [`.ts_ignore`](ignore.md) (`.gitignore`-style) pattern matching system for excluding files and directories. Ripgrep is already pre-installed on some Linux distributions and also available from several package managers.
## Common Error Messages
### Could not load the Qt platform plugin "xcb"
If you get an error message like this one:
```
qt.qpa.plugin: From 6.5.0, xcb-cursor0 or libxcb-cursor0 is needed to load the Qt xcb platform plugin.
qt.qpa.plugin: Could not load the Qt platform plugin "xcb" in "/tmp/_MEIayuTiW/cv2/qt/plugins" even though it was found.
This application failed to start because no Qt platform plugin could be initialized. Reinstalling the application may fix this problem.
Available platform plugins are: vnc, wayland-egl, offscreen, wayland, linuxfb, minimalegl, eglfs, minimal, vkkhrdisplay, xcb.
Aborted (core dumped)
```
Make sure you installed `libxcb-cursor` or `xcb-util-cursor`.
+73 -4
View File
@@ -8,10 +8,79 @@ icon: material/database
# :material-database: Libraries
A TagStudio library represents a folder of content (photos, documents, or [any other files](preview-support.md)) and contains TagStudio data specific to that library ([tags](tags.md), [fields](fields.md), [colors](colors.md), etc.) along with the associations between that data and your files. A library folder can be stored locally on your computer, on an external drive, on a network drive/NAS, or most other locations accessible by your system. TagStudio passively and non-destructively includes contents of this folder (including subfolders) in your library as [file entries](entries.md).
**Your files are not _moved_, _copied_, or _modified_ in any way!**
<!-- prettier-ignore -->
!!! info
This page is a work in progress and needs to be updated with additional information.
!!! note "Planned Library Features & Changes"
- The *option* to **store library data separately** from library content
- This will enable TagStudio libraries to be created for read-only folders
- This will enable TagStudio to have different libraries for the same content folder(s)
- **Multi-root libraries** that can read from multiple content folders
- This will reduce the need for using complex [`.ts_ignore`](ignore.md) rules
- This will enable having library content that spans across different drives, most notably on Windows, without the need for OS-dependant workarounds such as symlinks
- Sharable tag packs and color packs
- Global tags, accessible across different libraries
The library is how TagStudio represents your chosen directory, with every file inside being represented by a [file entry](entries.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.
See the [Roadmap](roadmap.md#library) for more information.
Note that this means [tags](tags.md) you create only exist _per-library_. Global tags along with other library structure updates are planned for future releases on the [roadmap](roadmap.md#library).
## :material-database-plus: Creating/Opening a Library
To create or open a [library](libraries.md), go to **File -> Open/Create Library** in the menu bar or use <kbd>Ctrl</kbd>+<kbd>O</kbd> (<kbd>⌘ Command </kbd>+<kbd>O</kbd> on macOS) and chose a folder with file contents you'd like to use as a TagStudio library. If a `.TagStudio` folder doesn't already exist inside the directory, TagStudio will create one and automatically scan the folder for files to include. Otherwise, the pre-existing library is opened.
<!-- prettier-ignore -->
!!! info "Legacy Library Migration"
If you open a library created with TagStudio **v9.4.2 or earlier** in **[v9.5.0](changelog.md#950-march-3rd-2025) or later**, you'll be walked through a migration process that converts the old `ts_library.json` save file to the new `ts_library.sqlite` format. The original JSON file is preserved and can be easily deleted from the **View -> Library Information** panel once you're satisfied with the migration.
## :material-database-refresh: Refreshing Directories
TagStudio automatically scans for new or updated files when opening a library by default. This behavior can be toggled in the settings if your library is very large and/or located on a slow drive.
![Settings -> Automatically Load New Files](assets/settings_refresh_library_on_open.png)
To manually refresh your library at any time, use **File -> Refresh Directories** from the menu or by using <kbd>Ctrl</kbd>+<kbd>R</kbd> (<kbd>⌘ Command </kbd>+<kbd>R</kbd> on macOS).
## :material-database-cog: Library Information Panel
The "Library Information" panel can be accessed from **Tools -> Library Information** in the menu bar, and includes various statistics about your library along with quick access to managing common library cleanup tasks such as relinking entries, updating ignored files, and managing library data backups.
![Library Information Panel](assets/library_information.png)
## :material-database-clock: Saving and Creating Backups
As of v9.5.0, libraries save automatically as you work.
To create a timestamped backup of your library save file, go to **File -> Save Library Backup** or use <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>S</kbd> (<kbd>⌘ Command </kbd>+<kbd>Shift</kbd>+<kbd>S</kbd> on macOS). Backups are also automatically created whenever the database file is migrated to a newer version as a precautionary measure. Backups currently _only_ include your `ts_library.sqlite` file, as that's the database file that contains your core TagStudio data. Your own files are **not** part of any of these backups.
Backups are stored inside the library data folder under `.TagStudio/backups/` and can be managed from the **Tools -> Library Information** panel.
## :material-folder: Library Data Folder
When you create a library, TagStudio creates a hidden `.TagStudio` folder at the root of the chosen content folder. This "data folder" contains all TagStudio data for that library. Library data includes what files are included in your library, what [tags](tags.md) you've created in that library, which files have what tags, and more. Note that this means tags you create only exist _per-library_. Global tags that are accessible across libraries are planned for a [future update](roadmap.md#library).
### :material-file-tree: Data Folder Structure
The library data folder (currently only named `.TagStudio`) is internally structured as follows:
| File/Folder | Description |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ts_library.sqlite` | The library save file. Stores all entries, tags, fields, and other metadata. _(v9.5.0+)_ |
| `.ts_ignore` | An optional ["ignore" file](ignore.md) for excluding files and folders from library scans, similar to a [`.gitignore`](https://git-scm.com/docs/gitignore) file. |
| `backups/` | Timestamped backups of the library save file. |
| `thumbs/` | Thumbnail images for file previews. |
```yaml title="Library Folder Example"
My Library/ # (Content Folder)
├─ file_1.jpg
├─ file_2.txt
├─ .TagStudio/ # (Data Folder)
│ ├─ ts_library.sqlite (References outer folder for files)
│ ├─ .ts_ignore
│ ├─ backups/
│ ├─ thumbs/
```
### :material-bag-suitcase: Library Portability
Because the `.TagStudio` _data folder_ is located in your library _content folder_, and it stores all file entry paths _relative_ to the content folder, your library folder can be freely moved to another location without files becoming [unlinked](entries.md#unlinked-entries). This also means that if you have a TagStudio library stored on an external drive, it can be freely moved around to different computers running TagStudio with no issues. Likewise, if your library is located on a network drive or NAS, you can access it from different computers that may map the network location differently from each other _(note that TagStudio currently does not support multiple users accessing the same library at once)._
+63 -45
View File
@@ -1,12 +1,12 @@
---
title: Library Format
title: Library Changes
icon: material/database-edit
---
<!-- SPDX-FileCopyrightText: (c) TagStudio Contributors -->
<!-- SPDX-License-Identifier: GPL-3.0-only -->
# :material-database-edit: Library Format
# :material-database-edit: Library Changes
This page outlines the various changes made to the TagStudio library save file format over time, sometimes referred to as the "database" or "database file".
@@ -18,9 +18,9 @@ Legacy (JSON) library save format versions were tied to the release version of t
### Versions 1.0.0 - 9.4.2
| Used From | Format | Location |
| --------- | ------ | --------------------------------------------- |
| v1.0.0 | JSON | `<Library Folder>`/.TagStudio/ts_library.json |
| Used in Releases | Format | Location |
| ---------------- | ------ | --------------------------------------------- |
| v1.0.0 - 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.
@@ -30,7 +30,11 @@ Replaced by the new SQLite format introduced in TagStudio [v9.5.0 Pre-Release 1]
## SQLite <small>v9.5.0+</small>
Starting with TagStudio [v9.5.0-pr1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr1), the library save format has been moved to a [SQLite](https://sqlite.org) format. Legacy JSON libraries are migrated (with the user's consent) to the new format when opening in current versions of the program. The save format versioning is now separate from the program's versioning number.
Starting with TagStudio [v9.5.0-pr1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr1), the library save format has been moved to the [SQLite](https://sqlite.org) format. Legacy JSON libraries are migrated (with the user's consent) to the new format when opening in current versions of the program. The save format versioning is now separate from the program's versioning number.
The database storage location of all SQLite versions to date is at:
`<Library Folder>`/.TagStudio/ts_library.sqlite
### Versioning
@@ -38,12 +42,12 @@ Versions **1-100** stored the database version in a table called `preferences` i
Versions **>101** store the database version in a table called `versions` in a row with the `key` column of `'CURRENT'` inside the corresponding `value` column. The `versions` table also stores the initial database version in which the file was created with under the `'INITIAL'` key. Databases created before this key was introduced will always have `'INITIAL'` value of `100`.
#### "versions" Table
#### `versions` Table
| key (`VARCHAR`) | value (`INTEGER`) |
| --------------- | --------------------------------------------- |
| `'INITIAL'` | <Version DB was created with, minimum `100`\> |
| `'CURRENT'` | <Current version of DB\> |
| key (`VARCHAR`) | value (`INTEGER`) |
| --------------- | ------------------------------------------ |
| `'INITIAL'` | Version DB was created with, minimum `100` |
| `'CURRENT'` | Current version of DB |
#### Major and Minor Versioning
@@ -51,19 +55,23 @@ Version **100** came along with a major/minor versioning system built into to th
For example, a database with version 204 would still be readable in an older version of TagStudio that understands version 200. A database with version 300, on the other hand, would no longer be readable in that same older version and an error message would display.
<!-- prettier-ignore -->
!!! note ""Version 0" Message"
If you see an message when opening a library along the lines of **"Found Version 0"**, this means that you're opening a library created with a newer version of TagStudio in an older version of TagStudio that does not recognize the future versioning system. To open your library, you should use a TagStudio version greater than or equal to the one the library was created or last used with.
---
### Versions 1 - 5
These versions were used while developing the new SQLite file format, outside any official or recommended release. These versions **were never supported** in any official capacity and were actively warned against using for real libraries.
These versions were used while developing the new SQLite file format, outside of any official or recommended release. These versions **were never supported** in any official capacity and were actively warned against using for real libraries.
---
### Version 6
| Used From | Format | Location |
| ------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
| [v9.5.0-pr1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr1) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
| Added in Commit | Introduced in Release | Format |
| ---------------------------------------- | ------------------------------------------------------------------------------- | ------ |
| d1b006a8978a7fa1b7f1a82243e490aca8a8355e | [v9.5.0-pr1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr1) | SQLite |
The first public version of the SQLite save file format.
@@ -73,9 +81,9 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
### Version 7
| Used From | Format | Location |
| ------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
| [v9.5.0-pr2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr2) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
| Added in Commit | Introduced in Release | Format |
| ---------------------------------------- | ------------------------------------------------------------------------------- | ------ |
| 480328b83bc1c69ab52a3eb11e2935337d3460ab | [v9.5.0-pr2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr2) | SQLite |
- ~~Repairs "Description" fields to use a TEXT_LINE key instead of a TEXT_BOX key.~~ _See [Version 200](#version-200)_
- Repairs tags that may have a disambiguation_id pointing towards a deleted tag.
@@ -84,9 +92,9 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
### Version 8
| Used From | Format | Location |
| ------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
| [v9.5.0-pr4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr4) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
| Added in Commit | Introduced in Release | Format |
| ---------------------------------------- | ------------------------------------------------------------------------------- | ------ |
| 28de21ade757aa5b80e87f77a459f4a3af21ffe0 | [v9.5.0-pr4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr4) | SQLite |
- Adds the `color_border` column to the `tag_colors` table. Used for instructing the [secondary color](colors.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)".
@@ -96,9 +104,9 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
### Version 9
| Used From | Format | Location |
| ----------------------------------------------------------------------- | ------ | ----------------------------------------------- |
| [v9.5.2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.2) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
| Added in Commit | Introduced in Release | Format |
| ---------------------------------------- | ----------------------------------------------------------------------- | ------ |
| 31833245a4d7a655dc4e66e6005a9dd78c36c04d | [v9.5.2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.2) | SQLite |
- Adds the `filename` column to the `entries` table. Used for sorting entries by filename in search results.
@@ -108,9 +116,9 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
#### Version 100
| Used From | Format | Location |
| ---------------------------------------- | ------ | ----------------------------------------------- |
| 74383e3c3c12f72be1481ab0b86c7360b95c2d85 | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
| Added in Commit | Introduced in Release | Format |
| ---------------------------------------- | --------------------- | ------ |
| 74383e3c3c12f72be1481ab0b86c7360b95c2d85 | _None_ | SQLite |
- Introduces built-in minor versioning
- The version number divided by 100 (and floored) constitutes the **major** version. Major version indicate breaking changes that prevent libraries from being opened in TagStudio versions older than the ones they were created in.
@@ -119,9 +127,9 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
#### Version 101
| Used From | Format | Location |
| ---------------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
| 12e074b71d8860282b44e49e0e1a41b7a2e4bae8/[v9.5.4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.4) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
| Added in Commit | Introduced in Release | Format |
| ---------------------------------------- | ----------------------------------------------------------------------- | ------ |
| 12e074b71d8860282b44e49e0e1a41b7a2e4bae8 | [v9.5.4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.4) | SQLite |
- Deprecates the `preferences` table, set to be removed in a [future](#version-104) TagStudio version.
- Introduces the `versions` table
@@ -133,24 +141,26 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
#### Version 102
| Used From | Format | Location |
| ---------------------------------------- | ------ | ----------------------------------------------- |
| 71d04254cf87f4200bb7ffc81656e50dfb122e4d | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
| Added in Commit | Introduced in Release | Format |
| ---------------------------------------- | ----------------------------------------------------------------------- | ------ |
| 71d04254cf87f4200bb7ffc81656e50dfb122e4d | [v9.5.5](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.5) | SQLite |
- Applies repairs to the `tag_parents` table created in [version 100](#version-100), removing rows that reference tags that have been deleted.
| Used From | Format | Location |
| ---------------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
| 88d0b47a86821ccfadba653f30a515abce5b24b0/[v9.5.7](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.7) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
#### Version 103
| Added in Commit | Introduced in Release | Format |
| ---------------------------------------- | ----------------------------------------------------------------------- | ------ |
| 88d0b47a86821ccfadba653f30a515abce5b24b0 | [v9.5.7](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.7) | SQLite |
- Adds the `is_hidden` column to the `tags` table (default `0`). Used for excluding entries tagged with hidden tags from library searches.
- Sets the `is_hidden` field on the built-in Archived tag to `1`, to match the Archived tag now being hidden by default.
#### Version 104
| Used From | Format | Location |
| ---------------------------------------- | ------ | ----------------------------------------------- |
| ad2cbbca483018d245b44348e2c4f5a0e0bb28f1 | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
| Added in Commit | Introduced in Release | Format |
| ---------------------------------------- | --------------------- | ------ |
| ad2cbbca483018d245b44348e2c4f5a0e0bb28f1 | _None_ | SQLite |
- Removes the `preferences` table, after migrating the contained extension list to the .ts_ignore file, if necessary.
@@ -160,9 +170,9 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
#### Version 200
| Used From | Format | Location |
| ---------------------------------------- | ------ | ----------------------------------------------- |
| c15e2b56eedd0a3c13391fa43571b8f8f7c7a91f | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
| Added in Commit | Introduced in Release | Format |
| ---------------------------------------- | --------------------- | ------ |
| c15e2b56eedd0a3c13391fa43571b8f8f7c7a91f | _None_ | SQLite |
- Adds `text_field_templates` and `date_field_templates` tables.
- Drops `boolean_fields` and `value_type` tables.
@@ -177,9 +187,17 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
#### Version 201
| Used From | Format | Location |
| ---------------------------------------- | ------ | ----------------------------------------------- |
| 38da7bb3a920a01d4d70fa065fd19c83ff6eecb1 | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
| Added in Commit | Introduced in Release | Format |
| ---------------------------------------- | ----------------------------------------------------------------------- | ------ |
| 38da7bb3a920a01d4d70fa065fd19c83ff6eecb1 | [v9.6.0](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.6.0) | SQLite |
- Drops `type_key` columns from `text_fields` and `datetime_fields` tables.
- Enforces column positions for `text_fields` and `datetime_fields` tables.
#### Version 202
| Added in Commit | Introduced in Release | Format |
| ---------------------------------------- | ----------------------------------------------------------------------- | ------ |
| 95e2fe7b4449951c385e35a2e13f0c1925f1f98e | [v9.6.1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.6.1) | SQLite |
- Applies repairs to the `tag_parents` table, removing rows that reference child tags that have been deleted.
+20 -19
View File
@@ -47,7 +47,7 @@ Images will generate thumbnails the first time they are viewed or since the last
### :material-movie-open: Videos
Video thumbnails will default to the closest viable frame from the middle of the video. Both thumbnail generation and video playback in the Preview Panel requires [FFmpeg](install.md#third-party-dependencies) installed on your system.
Video thumbnails will default to the closest viable frame from the middle of the video. Both thumbnail generation and video playback in the Preview Panel requires [FFmpeg](install.md#optional-dependencies) installed on your system.
| Filetype | Extensions | Dependencies |
| --------------------- | ----------------------- | :----------: |
@@ -65,7 +65,7 @@ Video thumbnails will default to the closest viable frame from the middle of the
### :material-sine-wave: Audio
Audio thumbnails will default to embedded cover art (if any) and fallback to generated waveform thumbnails. Audio file playback is supported in the Preview Panel if you have [FFmpeg](install.md#third-party-dependencies) installed on your system. Audio waveforms are currently not cached.
Audio thumbnails will default to embedded cover art (if any) and fallback to generated waveform thumbnails. Audio file playback is supported in the Preview Panel if you have [FFmpeg](install.md#optional-dependencies) installed on your system. Audio waveforms are currently not cached.
| Filetype | Extensions | Dependencies |
| ------------------- | ------------------------ | :----------: |
@@ -82,23 +82,24 @@ Audio thumbnails will default to embedded cover art (if any) and fallback to gen
Preview support for office documents or well-known project file formats varies by the format and whether or not embedded thumbnails are available to be read from. OpenDocument-based files are typically supported.
| Filetype | Extensions | Preview Type |
| ------------------------------------ | --------------------- | -------------------------------------------------------------------------- |
| Blender | `.blend`, `.blend<#>` | Embedded thumbnail :material-alert-circle:{ title="If available in file" } |
| Clip Studio Paint | `.clip` | Embedded thumbnail |
| Keynote (Apple iWork) | `.key` | Embedded thumbnail |
| Krita[^3] | `.kra`, `.krz` | Embedded thumbnail :material-alert-circle:{ title="If available in file" } |
| Mdipack (FireAlpaca, Medibang Paint) | `.mdp` | Embedded thumbnail |
| MuseScore | `.mscz` | Embedded thumbnail :material-alert-circle:{ title="If available in file" } |
| Numbers (Apple iWork) | `.numbers` | Embedded thumbnail |
| OpenDocument Presentation | `.odp`, `.fodp` | Embedded thumbnail |
| OpenDocument Spreadsheet | `.ods`, `.fods` | Embedded thumbnail |
| OpenDocument Text | `.odt`, `.fodt` | Embedded thumbnail |
| Pages (Apple iWork) | `.pages` | Embedded thumbnail |
| Paint.NET | `.pdn` | Embedded thumbnail |
| PDF | `.pdf` | First page render |
| Photoshop | `.psd` | Flattened image render |
| PowerPoint (Microsoft Office) | `.pptx`, `.ppt` | Embedded thumbnail :material-alert-circle:{ title="If available in file" } |
| Filetype | Extensions | Preview Type |
| ------------------------------------- | --------------------- | -------------------------------------------------------------------------- |
| Blender | `.blend`, `.blend<#>` | Embedded thumbnail :material-alert-circle:{ title="If available in file" } |
| Clip Studio Paint | `.clip` | Embedded thumbnail |
| Keynote (Apple iWork) | `.key` | Embedded thumbnail |
| Krita[^3] | `.kra`, `.krz` | Embedded thumbnail :material-alert-circle:{ title="If available in file" } |
| Mdipack (FireAlpaca, Medibang Paint) | `.mdp` | Embedded thumbnail |
| MuseScore | `.mscz` | Embedded thumbnail :material-alert-circle:{ title="If available in file" } |
| Numbers (Apple iWork) | `.numbers` | Embedded thumbnail |
| OpenDocument Presentation | `.odp`, `.fodp` | Embedded thumbnail |
| OpenDocument Spreadsheet | `.ods`, `.fods` | Embedded thumbnail |
| OpenDocument Text | `.odt`, `.fodt` | Embedded thumbnail |
| Pages (Apple iWork) | `.pages` | Embedded thumbnail |
| Paint.NET | `.pdn` | Embedded thumbnail |
| PDF | `.pdf` | First page render |
| Photoshop | `.psd` | Flattened image render |
| Pixelmator Pro (Apple Creator Studio) | `.pxd` | Embedded thumbnail |
| PowerPoint (Microsoft Office) | `.pptx`, `.ppt` | Embedded thumbnail :material-alert-circle:{ title="If available in file" } |
### :material-archive: Archives
+39 -41
View File
@@ -12,7 +12,7 @@ This page outlines the current and planned features required for TagStudio to be
This roadmap will update as new features are planned or completed. If there's a feature you'd like to see but is not listed on this page, please check the GitHub [Issues](https://github.com/TagStudioDev/TagStudio/issues) page and submit a feature request if one does not already exist!
## Priority Levels
## :material-chevron-triple-up: Priority Levels
Planned features and changes are assigned **priority levels** to signify how important they are to the feature-complete version of TagStudio and to serve as a general guide for what should be worked on first, along with [version estimates](#version-estimates). When features are completed, their priority level icons are removed.
@@ -22,35 +22,35 @@ Planned features and changes are assigned **priority levels** to signify how imp
- :material-chevron-double-up:{ .priority-med title="Medium Priority" } **Medium Priority** - Important, but not necessary
- :material-chevron-up:{ .priority-low title="Low Priority" } **Low Priority** - Just nice to have
## Version Estimates
## :material-map-clock: Version Estimates
Features are given rough estimations for which version they will be completed in listed next to their names (e.g. Feature **[v9.0.0]**). When the feature is completed they're linked to their respective changelog release, if applicable.
| Version Cycle | Focused Features |
| ---------------- | -------------------------------------------------------- |
| ~~Alpha v9.5.x~~ | ~~Migrate from JSON to SQLite database format~~ |
| Alpha v9.6.x | Necessary database changes for upcoming features |
| Alpha v9.7.x | Implement currently solidified features |
| Beta v9.8.x | Solidify remaining features and implementations |
| Beta v9.9.x | Make any additions and fixes from earlier release cycles |
| v10.0.x | Full release |
| Version Cycle | Goals |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| ~~**Alpha v9.5.x**~~ | ~~Migrate from JSON to SQLite database format~~ |
| **Alpha v9.6.x** | Necessary database changes for upcoming features ([fields](#entries), [file metadata](#sql-based-library-database), etc.) |
| **Alpha v9.7.x** | Implement currently solidified features ([entry groups](#entries), etc.) |
| **Beta v9.8.x** | Solidify remaining features and implementations ([component tags](#tags), etc.) |
| **Beta v9.9.x** | Make any additions and fixes from earlier release cycles |
| **v10.0.x** | Feature complete, versioning switches to [semver](https://semver.org/) |
<!-- prettier-ignore -->
!!! tip
For a more definitive and up-to-date list of features planned for near-future updates, please reference the current GitHub [Milestones](https://github.com/TagStudioDev/TagStudio/milestones)!
## Core
## :material-engine: Core
### :material-database: SQL Library Database
### :material-database: SQL-Based Library Database
An improved SQLite-based library save file format in which legacy JSON libraries are be migrated to.
A SQLite database file used as the [library](./libraries.md) save file format. Legacy JSON libraries are migrated to this improved format.
<!-- prettier-ignore -->
!!! note
See the "[Library](#library)" section for features related to the library database rather than the underlying schema.
- [x] A SQLite-based library save file format **[[v9.5.0](changelog.md#950-march-3rd-2025)]**
- [ ] Cached File Properties Table :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.0]**
- [ ] Cached File Properties Table :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.1]**
- [x] Date Entry Added to Library
- [ ] Date File Created :material-chevron-triple-up:{ .priority-high title="High Priority" }
- [ ] Date File Modified :material-chevron-triple-up:{ .priority-high title="High Priority" }
@@ -65,23 +65,23 @@ A separated, UI agnostic core library that would be used to interface with the T
- [ ] Core Library :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.9.0]**
- [ ] CLI :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.9.0]**
- [ ] MIT License :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.9.0]**
- [ ] MIT License _(Core Library + CLI)_ :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.9.0]**
### :material-clipboard-text: Format Specification
A detailed written specification for the TagStudio tag and/or library format. Intended for used by third-parties to build alternative cores or protocols that can remain interoperable.
A detailed specification written for the TagStudio tag and/or library format. Intended for used by third-parties to build alternative cores or protocols that can remain interoperable.
- [ ] Format Specification Established :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v10.0.0]**
---
## Application
## :material-application-outline: Application
### :material-button-cursor: UI/UX
- [x] Library Grid View
- [ ] Explore Filesystem in Grid View :material-chevron-double-up:{ .priority-med title="Medium Priority" }
- [x] Infinite Scrolling (No Pagination) **[[9.5.6](changelog.md#956-october-20th-2025)]**
- [x] Infinite Scrolling **[[v9.5.6](changelog.md#956-october-20th-2025)]**
- [ ] Library List View :material-chevron-double-up:{ .priority-med title="Medium Priority" }
- [ ] Explore Filesystem in List View :material-chevron-double-up:{ .priority-med title="Medium Priority" }
- [ ] Lightbox View :material-chevron-double-up:{ .priority-med title="Medium Priority" }
@@ -108,21 +108,20 @@ A detailed written specification for the TagStudio tag and/or library format. In
- [x] Toggle Mute
- [x] Timeline Scrubber
- [ ] Fullscreen Mode :material-chevron-double-up:{ .priority-med title="Medium Priority" }
- [ ] Fine-Tuned UI/UX :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
- [ ] 3D Model Thumbnails/Previews :material-chevron-triple-up:{ .priority-high title="High Priority" } _(See #1231)_
- [ ] Fine-Tuned UI/UX :material-chevron-triple-up:{ .priority-high title="High Priority" }
- [ ] 3D Model Thumbnails/Previews :material-chevron-triple-up:{ .priority-high title="High Priority" } _(See Discussion #1231)_
- [ ] STL File Support
- [ ] OBJ File Support
- [ ] Plaintext Thumbnails/Previews
- [x] Basic Support
- [ ] Full File Preview :material-chevron-triple-up:{ .priority-high title="High Priority" } **[[v9.6.x]]**
- [ ] Syntax Highlighting :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[[v9.6.x]]**
- [ ] Toggleable Persistent Tagging Panel :material-chevron-triple-up:{ .priority-high title="High Priority" } **[[v9.8.x]]**
- [ ] Full File Preview :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
- [ ] Syntax Highlighting :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.6.x]**
- [ ] Toggleable Persistent Tagging Panel :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.8.x]**
- [ ] Top Tags
- [ ] Recent Tags
- [ ] Tag Search
- [ ] Pinned Tags
- [ ] New Tabbed Tag Building UI to Support New Tag Features :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.8.x]**
- [ ] Custom Thumbnail Overrides :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.7.x]**
- [ ] Media Duration Labels :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
- [ ] Word/Line Count Labels :material-chevron-up:{ .priority-low title="Low Priority" }
- [ ] Custom Tag Badges :material-chevron-up:{ .priority-low title="Low Priority" }
@@ -144,7 +143,7 @@ A detailed written specification for the TagStudio tag and/or library format. In
---
## Library
## :material-database: Library
### :material-wrench: Library Mechanics
@@ -167,22 +166,21 @@ A detailed written specification for the TagStudio tag and/or library format. In
File or file-like [entries](entries.md) stored in the library.
- [x] File Entries **[v1.0.0]**
- [ ] Folder Entries :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
- [ ] URL Entries / Bookmarks :material-chevron-up:{ .priority-low title="Low Priority" } **[v9.7.x]**
- [ ] URL Entries / Bookmarks :material-chevron-up:{ .priority-low title="Low Priority" } **[v9.6.x]**
- [x] Fields
- [x] Text Lines
- [x] Text Boxes
- [x] Datetimes **[[v9.5.4](changelog.md#954-september-1st-2025)]**
- [ ] Numeric Fields :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
- [ ] Numeric Fields :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.6.x]**
- [ ] Optional Units (e.g. inches, cm, height notation, degrees, bytes, etc.) :material-chevron-double-up:{ .priority-med title="Medium Priority" }
- [ ] Custom Field Names :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
- [x] Removal of Deprecated Fields **[v9.6.0]**
- [x] Custom Field Names **[[v9.6.0](changelog.md#960-june-29th-2026)]**
- [x] Removal of Deprecated Fields **[[v9.6.0](changelog.md#960-june-29th-2026)]**
- [ ] Custom Thumbnail Overrides :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.7.x]**
- [ ] Entry Groups :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
- [ ] Non-exclusive; Entries can be in multiple groups :material-chevron-triple-up:{ .priority-high title="High Priority" }
- [ ] Ability to number entries within group :material-chevron-triple-up:{ .priority-high title="High Priority" }
- [ ] Ability to number entries within group (i.e. page numbers) :material-chevron-triple-up:{ .priority-high title="High Priority" }
- [ ] Ability to set sorting method for group :material-chevron-triple-up:{ .priority-high title="High Priority" }
- [ ] Ability to set custom thumbnail for group :material-chevron-triple-up:{ .priority-high title="High Priority" }
- [ ] Group is treated as entry with tags and metadata :material-chevron-double-up:{ .priority-med title="Medium Priority" }
- [ ] Ability to set custom thumbnail for group :material-chevron-double-up:{ .priority-med title="Medium Priority" }
- [ ] Nested groups :material-chevron-double-up:{ .priority-med title="Medium Priority" }
### :material-tag-text: Tags
@@ -201,15 +199,15 @@ Discrete library objects representing [attributes](<https://en.wikipedia.org/wik
- [ ] Tag Icons :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
- [ ] Small Icons :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
- [ ] Large Icons for Profiles :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.7.x]**
- [ ] Built-in Icon Packs (i.e. Boxicons) :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
- [ ] Built-in Icon Packs (e.g. Boxicons) :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
- [ ] User-Defined Icons :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
- [ ] Tint Icons with Text Color :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
- [x] [Category Property](tags.md#is-category) **[[v9.5.0](changelog.md#950-march-3rd-2025)]**
- [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
- [ ] Fine-tuned exclusion from categories :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
- [x] Hidden Property **[[v9.5.7]](changelog.md#957-may-5th-2026)**
- [x] Built-in "Archived" tag has this property by default **[[v9.5.7]](changelog.md#957-may-5th-2026)**
- [x] Checkbox near search bar to show hidden tags in search **[[v9.5.7]](changelog.md#957-may-5th-2026)**
- [x] Hidden Property **[[v9.5.7](changelog.md#957-may-5th-2026)]**
- [x] Built-in "Archived" tag has this property by default **[[v9.5.7](changelog.md#957-may-5th-2026)]**
- [x] Checkbox near search bar to show hidden tags in search **[[v9.5.7](changelog.md#957-may-5th-2026)]**
- [ ] Tag Relationships
- [x] [Parent Tags](tags.md#parent-tags) ([Inheritance](<https://en.wikipedia.org/wiki/Inheritance_(object-oriented_programming)>) Relationship) **[v9.0.0]**
- [ ] [Component Tags](tags.md#component-tags) ([Composition](https://en.wikipedia.org/wiki/Object_composition) Relationship) :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.8.x]**
@@ -224,16 +222,16 @@ Discrete library objects representing [attributes](<https://en.wikipedia.org/wik
- [x] Glob Search **[[v9.5.0](changelog.md#950-march-3rd-2025)]**
- [x] Filetype Search **[[v9.5.0](changelog.md#950-march-3rd-2025)]**
- [x] Search by Extension (e.g. ".jpg", ".png") **[[v9.5.0](changelog.md#950-march-3rd-2025)]**
- [x] Optional consolidation of extension synonyms (i.e. ".jpg" can equal ".jpeg") **[[v9.5.0](changelog.md#950-march-3rd-2025)]**
- [x] Optional consolidation of extension synonyms (e.g. ".jpg" can equal ".jpeg") **[[v9.5.0](changelog.md#950-march-3rd-2025)]**
- [x] Search by media type (e.g. "image", "video", "document") **[[v9.5.0](changelog.md#950-march-3rd-2025)]**
- [ ] Field Content Search :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
- [ ] Field Content Search :material-chevron-triple-up:{ .priority-high title="High Priority" }
- [x] [Boolean Operators](search.md) **[[v9.5.0](changelog.md#950-march-3rd-2025)]**
- [x] `AND` Operator
- [x] `OR` Operator
- [x] `NOT` Operator
- [x] Parenthesis Grouping
- [x] Character Escaping
- [ ] `HAS` Operator (for [Component Tags](tags.md#component-tags)) :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
- [ ] `HAS` Operator (for [Component Tags](tags.md#component-tags)) :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.8.x]**
- [ ] Conditional Search :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.7.x]**
- [ ] Compare Dates :material-chevron-double-up:{ .priority-med title="Medium Priority" }
- [ ] Compare Durations :material-chevron-double-up:{ .priority-med title="Medium Priority" }
@@ -245,7 +243,7 @@ Discrete library objects representing [attributes](<https://en.wikipedia.org/wik
- [x] Sort by Date Entry Added to Library **[[v9.5.2](changelog.md#952-march-31st-2025)]**
- [ ] Sort by File Creation Date :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
- [ ] Sort by File Modification Date :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
- [ ] Sort by Date Taken (Photos) :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
- [ ] Sort by Date Taken (Photos) :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.6.x]**
- [x] Random/Shuffle Sort
- [ ] OCR Search :material-chevron-up:{ .priority-low title="Low Priority" }
- [ ] Fuzzy Search :material-chevron-up:{ .priority-low title="Low Priority" }
+5
View File
@@ -144,6 +144,11 @@ h2 > .twemoji {
margin-top: 0.08rem;
}
td code {
margin-right: 0.3rem;
padding: 0 !important;
}
/* Matches the palette used by mkdocs-material */
.priority-high {
color: #f1185a;
+1 -1
View File
@@ -70,7 +70,7 @@ Lastly, when searching your files with broader categories such as `Character` or
<!-- prettier-ignore -->
!!! warning ""
**_Coming in version 9.6.x_**
**_Planned for future version_** *(See the [Roadmap](roadmap.md))*
Component tags will be built from a composition-based, or "HAS" type relationship between tags. This takes care of instances where an attribute may "have" another attribute, but doesn't inherit from it. Shrek may be an `Ogre`, he may be a `Character`, but he is NOT a `Leather Vest` - even if he's commonly seen _with_ it. Component tags, along with the upcoming "Tag Override" 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.
+8 -4
View File
@@ -8,13 +8,17 @@ icon: material/mouse
# :material-mouse: Basic Usage
## Creating/Opening a Library
## :material-database-plus: Creating/Opening a Library
With TagStudio opened, start by creating a new library or opening an existing one using File -> Open/Create Library from the menu bar. TagStudio will automatically create a new library from the chosen directory if one does not already exist. Upon creating a new library, TagStudio will automatically scan your folders for files and add those to your library (no files are moved during this process!).
To create or open a [library](libraries.md), go to **File -> Open/Create Library** in the menu bar or use <kbd>Ctrl</kbd>+<kbd>O</kbd> (<kbd>⌘ Command </kbd>+<kbd>O</kbd> on macOS) and chose a folder with file contents you'd like to use as a TagStudio library. If a `.TagStudio` folder doesn't already exist inside the directory, TagStudio will create one and automatically scan the folder for files to include. Otherwise, the pre-existing library is opened.
## Refreshing the Library
### :material-database-refresh: Refreshing Directories
Libraries under 10,000 files automatically scan for new or modified files when opened. In order to refresh the library manually, select "Refresh Directories" under the File menu.
TagStudio automatically scans for new or updated files when opening a library by default. Manually refresh by going to **File -> Refresh Directories** in the menu or by using <kbd>Ctrl</kbd>+<kbd>R</kbd> (<kbd>⌘ Command </kbd>+<kbd>R</kbd> on macOS).
<!-- prettier-ignore -->
!!! tip "TagStudio Libraries"
To learn more about how TagStudio libraries work and how to use them, visit the **[Libraries](libraries.md)** page.
## Adding Tags to File Entries
Generated
+6 -6
View File
@@ -7,11 +7,11 @@
]
},
"locked": {
"lastModified": 1763759067,
"narHash": "sha256-LlLt2Jo/gMNYAwOgdRQBrsRoOz7BPRkzvNaI/fzXi2Q=",
"lastModified": 1778716662,
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "2cccadc7357c0ba201788ae99c4dfa90728ef5e0",
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
"type": "github"
},
"original": {
@@ -22,11 +22,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1763835633,
"narHash": "sha256-HzxeGVID5MChuCPESuC0dlQL1/scDKu+MmzoVBJxulM=",
"lastModified": 1782723713,
"narHash": "sha256-oPXCU/SSUokcGaJREHibG1CBX3+s/W7orDWQOZDsEeQ=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "050e09e091117c3d7328c7b2b7b577492c43c134",
"rev": "b5aa0fbd538984f6e3d201be0005b4463d8b09f8",
"type": "github"
},
"original": {
+1 -2
View File
@@ -1,7 +1,6 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
{
description = "TagStudio";
@@ -32,7 +31,7 @@
perSystem =
{ pkgs, ... }:
let
python3 = pkgs.python312;
python3 = pkgs.python313;
in
{
packages =
+8 -9
View File
@@ -32,31 +32,30 @@ extra:
nav:
- Home:
- index.md
- Getting Started:
- Installing:
- install.md
- usage.md
- Developing:
- Developers:
- developing.md
- contributing.md
- style.md
- Help:
- help/ffmpeg.md
- Using Libraries:
- Using TagStudio:
- usage.md
- libraries.md
- entries.md
- preview-support.md
- search.md
- ignore.md
- macros.md
- Fields:
- fields.md
- Tags:
- Tags & Fields:
- tags.md
- colors.md
- fields.md
- Updates:
- changelog.md
- roadmap.md
- Schema History:
- Format History:
- library-changes.md
theme:
@@ -85,7 +84,7 @@ theme:
icon: material/lightbulb-night-outline
name: Switch to System Preference
logo: assets/icon_mono.svg
favicon: assets/icon.ico
favicon: assets/favicon.ico
font:
code: Jetbrains Mono
language: en
+4 -1
View File
@@ -1,7 +1,6 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
{
ffmpeg-headless,
lib,
@@ -84,8 +83,10 @@ python3Packages.buildPythonApplication {
"py7zr"
"pyside6"
"rarfile"
"rawpy"
"requests"
"semver"
"Send2Trash"
"structlog"
"typing-extensions"
];
@@ -95,6 +96,7 @@ python3Packages.buildPythonApplication {
dependencies =
with python3Packages;
[
audioop-lts
chardet
ffmpeg-python
humanfriendly
@@ -129,6 +131,7 @@ python3Packages.buildPythonApplication {
"test_close_library" # TODO: Look into segfault.
"test_flow_layout_happy_path"
"test_get" # TODO: Look further into, might be possible to run.
"test_github_api_unavailable"
"test_json_migration"
"test_library_migrations"
"test_update_tags"
-1
View File
@@ -1,7 +1,6 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
{
buildPythonPackage,
cmake,
-1
View File
@@ -1,7 +1,6 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
{
autoPatchelfHook,
buildPythonPackage,
+2 -2
View File
@@ -1,7 +1,6 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
{
lib,
pkgs,
@@ -78,6 +77,7 @@ pkgs.mkShellNoCC {
coreutils
uv
pyright
ruff
];
buildInputs = [
@@ -120,7 +120,7 @@ pkgs.mkShellNoCC {
if [ ! -f "''${venv}"/pyproject.toml ] || ! diff --brief pyproject.toml "''${venv}"/pyproject.toml >/dev/null; then
printf '%s\n' 'Installing dependencies, pyproject.toml changed...' >&2
uv pip install --quiet --editable '.[mkdocs,mypy,pre-commit,pytest]'
uv pip install --quiet --editable . --group docs --group extra --group test
cp pyproject.toml "''${venv}"/pyproject.toml
fi
+34 -16
View File
@@ -9,11 +9,12 @@ build-backend = "hatchling.build"
[project]
name = "TagStudio"
description = "A User-Focused Photo & File Management System."
version = "9.5.7"
version = "9.6.1"
license = "GPL-3.0-only"
readme = "README.md"
requires-python = ">=3.12,<3.13"
requires-python = ">=3.12,<3.14"
dependencies = [
"audioop-lts; python_version >= '3.13'",
"chardet~=5.2",
"ffmpeg-python~=0.2",
"humanfriendly==10.*",
@@ -23,13 +24,13 @@ dependencies = [
"Pillow>=10.2,<12",
"pillow-heif~=0.22",
"pillow-jxl-plugin~=1.3",
"py7zr==1.0.0",
"py7zr~=1.1.3",
"pydantic~=2.10",
"pydub~=0.25",
"PySide6==6.8.0.*",
"rarfile==4.2",
"rawpy~=0.27",
"Send2Trash~=1.8",
"Send2Trash>=1.8,<3",
"SQLAlchemy~=2.0",
"srctools~=2.6",
"structlog~=25.3",
@@ -41,12 +42,28 @@ dependencies = [
"semver~=3.0.4",
]
[project.optional-dependencies]
dev = ["tagstudio[mkdocs,pyright,pre-commit,pyinstaller,pytest,ruff]"]
[project.gui-scripts]
tagstudio = "tagstudio.main:main"
[dependency-groups]
all = [
{ include-group = "check" },
{ include-group = "build" },
{ include-group = "docs" },
{ include-group = "extra" },
]
check = [{ include-group = "lint" }, { include-group = "test" }]
build = [{ include-group = "pyinstaller" }]
docs = [{ include-group = "mkdocs" }]
extra = [{ include-group = "pre-commit" }]
lint = [{ include-group = "pyright" }, { include-group = "ruff" }]
test = [{ include-group = "pytest" }]
mkdocs = ["mkdocs-material[imaging]>=9.7", "mkdocs-redirects~=1.2"]
pyright = ["pyright~=1.1.409"]
pre-commit = ["pre-commit~=4.2"]
pyinstaller = ["Pyinstaller~=6.13"]
pyinstaller = ["Pyinstaller~=6.21"]
pyright = ["pyright~=1.1.409"]
pytest = [
"pytest==9.0.3",
"pytest-cov==6.1.1",
@@ -54,18 +71,18 @@ pytest = [
"pytest-qt==4.4.0",
"syrupy==5.1.0",
]
ruff = ["ruff==0.11.8"]
[project.gui-scripts]
tagstudio = "tagstudio.main:main"
ruff = ["ruff==0.15.17"]
[tool.hatch.build.targets.wheel]
packages = ["src/tagstudio"]
[tool.pytest.ini_options]
#addopts = "-m 'not qt'"
[tool.pytest]
qt_api = "pyside6"
pythonpath = ["src"]
filterwarnings = [
"ignore:DISTINCT ON is currently supported only by the PostgreSQL dialect:sqlalchemy.exc.SADeprecationWarning",
'ignore:Invoking or_\(\) without arguments is deprecated:sqlalchemy.exc.SADeprecationWarning',
]
[tool.pyright]
ignore = [
@@ -74,7 +91,8 @@ ignore = [
"src/tagstudio/qt/previews/vendored/pydub/",
]
include = ["src/tagstudio", "tests"]
# reference for the settings here: https://github.com/microsoft/pyright/blob/main/docs/configuration.md
# Reference for the settings here: https://github.com/microsoft/pyright/blob/main/docs/configuration.md
# Some rules exclusive to basedpyright: https://docs.basedpyright.com/latest/benefits-over-pyright/new-diagnostic-rules/
reportAny = false
reportIgnoreCommentWithoutRule = false
reportImplicitStringConcatenation = false
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env perl
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: CC0-1.0
use strict;
use warnings;
use feature 'say';
# From: https://regex101.com/r/vkijKf/1
if (($ARGV[0] // <STDIN>) =~ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/) {
foreach my $index (0 .. $#{^CAPTURE}) {
say ${^CAPTURE}[$index] // "";
}
}
+15 -10
View File
@@ -1,16 +1,22 @@
#!/usr/bin/env -S python -m PyInstaller
# vi: ft=python
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import argparse
import platform
from argparse import ArgumentParser
from pathlib import Path
from tomllib import load
from PyInstaller.building.api import COLLECT, EXE, PYZ
from PyInstaller.building.build_main import Analysis
from PyInstaller.building.osx import BUNDLE
from tomllib import load
parser = ArgumentParser()
# HACK: Without this, the script will fail if empty arguments are passed.
parser.add_argument("_", nargs="*", help=argparse.SUPPRESS)
parser.add_argument("--portable", action="store_true")
options = parser.parse_args()
@@ -19,23 +25,24 @@ with open("pyproject.toml", "rb") as file:
system = platform.system()
project_root = Path("..", "src/tagstudio")
name = pyproject["name"] if system == "Windows" else "tagstudio"
icon = None
if system == "Windows":
icon = "src/tagstudio/resources/icon.ico"
icon = Path(project_root, "resources/icon.ico")
elif system == "Darwin":
icon = "src/tagstudio/resources/icon.icns"
icon = Path(project_root, "resources/icon.icns")
datafiles = [
("src/tagstudio/qt/*.json", "tagstudio/qt"),
("src/tagstudio/qt/*.qrc", "tagstudio/qt"),
("src/tagstudio/resources", "tagstudio/resources"),
(f"{project_root}/qt/*.json", "tagstudio/qt"),
(f"{project_root}/qt/*.qrc", "tagstudio/qt"),
(f"{project_root}/resources", "tagstudio/resources"),
]
a = Analysis(
["src/tagstudio/main.py"],
pathex=["src"],
[Path(project_root, "main.py")],
pathex=[],
binaries=[],
datas=datafiles,
hiddenimports=[],
@@ -100,5 +107,3 @@ if system == "Darwin":
"NSPrincipalClass": "NSApplication",
},
)
# vi: ft=python
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from tagstudio.main import main
if __name__ == "__main__":
main()
+11 -5
View File
@@ -1,10 +1,19 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from importlib.metadata import version
VERSION: str = "9.5.7" # Major.Minor.Patch
VERSION: str = version("tagstudio") # Major.Minor.Patch
VERSION_BRANCH: str = "" # Usually "" or "Pre-Release"
COPYRIGHT_YEARS: str = "2021-2026"
COPYRIGHT: str = f"© {COPYRIGHT_YEARS} Travis Abendshien & TagStudio Contributors"
COPYRIGHT_COMPACT: str = f"© {COPYRIGHT_YEARS} Travis Abendshien\n& TagStudio Contributors"
GITHUB_REPO_URL = "https://github.com/TagStudioDev/TagStudio"
GITHUB_RELEASE_URL = "https://github.com/TagStudioDev/TagStudio/releases/latest"
DOCS_URL = "https://docs.tagstud.io"
FFMPEG_HELP_URL = "https://docs.tagstud.io/help/ffmpeg"
DISCORD_URL = "https://discord.com/invite/hRNnVKhF2G"
# The folder & file names where TagStudio keeps its data relative to a library.
TS_FOLDER_NAME: str = ".TagStudio"
@@ -13,9 +22,7 @@ COLLAGE_FOLDER_NAME: str = "collages"
IGNORE_NAME: str = ".ts_ignore"
THUMB_CACHE_NAME: str = "thumbs"
FONT_SAMPLE_TEXT: str = (
"""ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!?@$%(){}[]"""
)
FONT_SAMPLE_TEXT: str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!?@$%(){}[]"
FONT_SAMPLE_SIZES: list[int] = [10, 15, 20]
# NOTE: These were the field IDs used for the "Tags", "Content Tags", and "Meta Tags" fields inside
@@ -27,5 +34,4 @@ TAG_FAVORITE = 1
TAG_META = 2
RESERVED_TAG_START = 0
RESERVED_TAG_END = 999
RESERVED_NAMESPACE_PREFIX = "tagstudio"
+4 -4
View File
@@ -8,7 +8,7 @@ import structlog
from PySide6.QtCore import QSettings
from tagstudio.core.constants import TS_FOLDER_NAME
from tagstudio.core.enums import SettingItems
from tagstudio.core.enums import AppCacheItems
from tagstudio.core.library.alchemy.library import LibraryStatus
from tagstudio.qt.global_settings import GlobalSettings
@@ -30,16 +30,16 @@ class DriverMixin:
logger.error("Path does not exist.", open_path=open_path)
return LibraryStatus(success=False, message="Path does not exist.")
elif self.settings.open_last_loaded_on_startup and self.cached_values.value(
SettingItems.LAST_LIBRARY
AppCacheItems.LAST_LIBRARY
):
library_path = Path(str(self.cached_values.value(SettingItems.LAST_LIBRARY)))
library_path = Path(str(self.cached_values.value(AppCacheItems.LAST_LIBRARY)))
if not (library_path / TS_FOLDER_NAME).exists():
logger.error(
"TagStudio folder does not exist.",
library_path=library_path,
ts_folder=TS_FOLDER_NAME,
)
self.cached_values.setValue(SettingItems.LAST_LIBRARY, "")
self.cached_values.setValue(AppCacheItems.LAST_LIBRARY, "")
# dont consider this a fatal error, just skip opening the library
library_path = None
+6 -5
View File
@@ -5,14 +5,15 @@
import enum
class SettingItems(str, enum.Enum):
class AppCacheItems(enum.StrEnum):
"""List of setting item names."""
LAST_LIBRARY = "last_library"
LIBS_LIST = "libs_list"
DISMISSED_UPDATE = "dismissed_update"
class ShowFilepathOption(int, enum.Enum):
class ShowFilepathOption(enum.IntEnum):
"""Values representing the options for the "show_filenames" setting."""
SHOW_FULL_PATHS = 0
@@ -21,7 +22,7 @@ class ShowFilepathOption(int, enum.Enum):
DEFAULT = SHOW_RELATIVE_PATHS
class TagClickActionOption(int, enum.Enum):
class TagClickActionOption(enum.IntEnum):
"""Values representing the options for the "tag_click_action" setting."""
OPEN_EDIT = 0
@@ -30,7 +31,7 @@ class TagClickActionOption(int, enum.Enum):
DEFAULT = OPEN_EDIT
class Theme(str, enum.Enum):
class Theme(enum.StrEnum):
COLOR_BG_DARK = "#65000000"
COLOR_BG_LIGHT = "#22000000"
COLOR_DARK_LABEL = "#DD000000"
@@ -49,7 +50,7 @@ class OpenStatus(enum.IntEnum):
CORRUPTED = 2
class MacroID(enum.Enum):
class MacroID(enum.StrEnum):
AUTOFILL = "autofill"
SIDECAR = "sidecar"
BUILD_URL = "build_url"
@@ -9,7 +9,7 @@ JSON_FILENAME: str = "ts_library.json"
DB_VERSION_CURRENT_KEY: str = "CURRENT"
DB_VERSION_INITIAL_KEY: str = "INITIAL"
DB_VERSION: int = 201
DB_VERSION: int = 202
TAG_CHILDREN_QUERY = text("""
WITH RECURSIVE ChildTags AS (
+10 -6
View File
@@ -42,13 +42,17 @@ def make_engine(connection_string: str) -> Engine:
def make_tables(engine: Engine) -> None:
logger.info("[Library] Creating DB tables...")
Base.metadata.create_all(engine)
# tag IDs < 1000 are reserved
# create tag and delete it to bump the autoincrement sequence
# TODO - find a better way
# is this the better way?
with engine.connect() as conn:
# TODO: this should instead be migrations that create the exact tables that were added in
# the respective DB versions
Base.metadata.create_all(conn)
conn.commit()
# TODO: this needs to be a migration
# tag IDs < 1000 are reserved
# create tag and delete it to bump the autoincrement sequence
# TODO - find a better way
# is this the better way?
result = conn.execute(text("SELECT SEQ FROM sqlite_sequence WHERE name='tags'"))
autoincrement_val = result.scalar()
if not autoincrement_val or autoincrement_val <= RESERVED_TAG_END:
+1 -1
View File
@@ -66,7 +66,7 @@ class TagColorEnum(enum.IntEnum):
class ItemType(enum.Enum):
ENTRY = 0
COLLATION = 1
ENTRY_GROUP = 1
TAG_GROUP = 2
File diff suppressed because it is too large Load Diff
@@ -163,6 +163,7 @@ class Tag(Base):
def __hash__(self) -> int:
return hash(self.id)
@override
def __eq__(self, value: object) -> bool:
if not isinstance(value, Tag):
return False
@@ -80,7 +80,7 @@ class DupeFilesRegistry:
)
for i, entries in enumerate(self.groups):
yield i
remove_ids = entries[1:]
logger.info("Removing entries group", ids=remove_ids)
self.library.remove_entries([e.id for e in remove_ids])
yield i - 1 # The -1 waits for the next step to finish
@@ -4,14 +4,12 @@
from collections.abc import Iterator
from dataclasses import dataclass, field
from pathlib import Path
import structlog
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.library.ignore import Ignore
from tagstudio.core.utils.types import unwrap
logger = structlog.get_logger(__name__)
@@ -35,15 +33,14 @@ class IgnoredRegistry:
logger.info("[IgnoredRegistry] Refreshing ignored entries...")
self.ignored_entries = []
library_dir: Path = unwrap(self.lib.library_dir)
for i, entry in enumerate(self.lib.all_entries()):
yield i
if not Ignore.compiled_patterns:
# If the compiled_patterns has malfunctioned, don't consider that a false positive
yield i
elif Ignore.compiled_patterns.match(library_dir / entry.path):
elif Ignore.compiled_patterns.match(entry.path):
self.ignored_entries.append(entry)
yield i
def remove_ignored_entries(self) -> None:
self.lib.remove_entries(list(map(lambda ignored: ignored.id, self.ignored_entries)))
@@ -38,10 +38,10 @@ class UnlinkedRegistry:
self.unlinked_entries = []
for i, entry in enumerate(self.lib.all_entries()):
yield i
full_path = unwrap(self.lib.library_dir) / entry.path
if not full_path.exists() or not full_path.is_file():
self.unlinked_entries.append(entry)
yield i
def match_unlinked_file_entry(self, match_entry: Entry) -> list[Path]:
"""Try and match unlinked file entries with matching results in the library directory.
@@ -72,6 +72,7 @@ class UnlinkedRegistry:
self.files_fixed_count = 0
matched_entries: list[Entry] = []
for i, entry in enumerate(self.unlinked_entries):
yield i
item_matches = self.match_unlinked_file_entry(entry)
if len(item_matches) == 1:
logger.info(
@@ -88,7 +89,6 @@ class UnlinkedRegistry:
continue
self.files_fixed_count += 1
matched_entries.append(entry)
yield i
for entry in matched_entries:
self.unlinked_entries.remove(entry)
+13 -12
View File
@@ -2,9 +2,9 @@
# SPDX-License-Identifier: GPL-3.0-only
import enum
import mimetypes
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
import structlog
@@ -12,18 +12,18 @@ import structlog
logger = structlog.get_logger(__name__)
FILETYPE_EQUIVALENTS = [
set(["aif", "aiff", "aifc"]),
set(["html", "htm", "xhtml", "shtml", "dhtml"]),
set(["jfif", "jpeg_large", "jpeg", "jpg_large", "jpg"]),
set(["json", "jsonc", "json5"]),
set(["md", "markdown", "mkd", "rmd"]),
set(["tar.gz", "tgz"]),
set(["xml", "xul"]),
set(["yaml", "yml"]),
{"aif", "aiff", "aifc"},
{"html", "htm", "xhtml", "shtml", "dhtml"},
{"jfif", "jpeg_large", "jpeg", "jpg_large", "jpg"},
{"json", "jsonc", "json5"},
{"md", "markdown", "mkd", "rmd"},
{"tar.gz", "tgz"},
{"xml", "xul"},
{"yaml", "yml"},
]
class MediaType(str, Enum):
class MediaType(enum.StrEnum):
"""Names of media types."""
ADOBE_PHOTOSHOP = "adobe_photoshop"
@@ -75,7 +75,7 @@ class MediaCategory:
extensions (set[str]): The set of file extensions associated with this category.
Includes leading ".", all lowercase, and does not need to be unique to this category.
is_iana (bool): Represents whether or not this is an IANA registered category.
is_iana (bool): Represents whether this is an IANA registered category.
"""
media_type: MediaType
@@ -257,6 +257,7 @@ class MediaCategories:
".odt",
".pages",
".pdf",
".pxd",
".rtf",
".tex",
".wpd",
@@ -336,7 +337,7 @@ class MediaCategories:
".webp",
}
_INSTALLER_SET: set[str] = {".appx", ".msi", ".msix"}
_IWORK_SET: set[str] = {".key", ".pages", ".numbers"}
_IWORK_SET: set[str] = {".key", ".numbers", ".pages"}
_MATERIAL_SET: set[str] = {".mtl"}
_MDIPACK_SET: set[str] = {".mdp"}
_MODEL_SET: set[str] = {".3ds", ".fbx", ".obj", ".stl"}
+1 -1
View File
@@ -24,7 +24,7 @@ class ConstraintType(Enum):
"filetype": ConstraintType.FileType,
"path": ConstraintType.Path,
"special": ConstraintType.Special,
}.get(text.lower(), None)
}.get(text.lower())
class AST:
+59
View File
@@ -0,0 +1,59 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: MIT
import contextlib
from typing import override
import structlog
from tagstudio.core.utils.module_status import ModuleStatus
from tagstudio.core.utils.silent_subprocess import (
silent_run, # pyright: ignore[reportUnknownVariableType]
)
logger = structlog.get_logger(__name__)
class _FfModuleStatus(ModuleStatus):
"""A base class that implements common logic for reading FFmpeg/FFprobe version output."""
_FFMPEG = "ffmpeg"
_FFPROBE = "ffprobe"
@classmethod
def ff_version(cls, command: str):
ff_cmd = cls._which(command)
if ff_cmd:
out = silent_run([ff_cmd, "-version"], shell=False, capture_output=True, text=True)
if out.returncode == 0:
with contextlib.suppress(Exception):
return str(out.stdout).split(" ")[2]
class FfmpegStatus(_FfModuleStatus):
"""Class for getting the location and version of FFmpeg, if it exists."""
@override
@classmethod
def which(cls):
return cls._which(cls._FFMPEG)
@override
@classmethod
def _version(cls):
return cls.ff_version(cls._FFMPEG)
class FfprobeStatus(_FfModuleStatus):
"""Class for getting the location and version of FFprobe, if it exists."""
@override
@classmethod
def which(cls):
return cls._which(cls._FFPROBE)
@override
@classmethod
def _version(cls):
return cls.ff_version(cls._FFPROBE)
+75
View File
@@ -0,0 +1,75 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: MIT
import os
import platform
from shutil import which
import structlog
logger = structlog.get_logger(__name__)
user = os.environ.get("USER", None)
# NOTE: macOS does not make its PATH variable available to processes started outside the terminal.
# The following is a list of common directories to search for binaries in.
# TODO: Use a library for XDG compliance
_MACOS_BIN_LOCATIONS: list[str] = [
"", # Equates to PATH itself
# User level
"~/.local/share/bin/", # XDG-compliant user-created bin
"~/.local/bin/", # Fallback user-created bin
"~/.local/state/nix/profile/bin/", # XDG-compliant home Nix bin
"~/.nix-profile/bin/", # Fallback home Nix bin
# System level
f"/etc/profiles/per-user/{user}/bin/", # Per-user Nix bin
"/nix/var/nix/profiles/default/bin/", # Inherited Nix bin
"/opt/homebrew/bin/", # Homebrew bin
"/usr/local/bin/", # Administrator-configured bin
"/usr/bin/", # System bin
"/bin/", # Core system bin
]
class ModuleStatus:
"""An abstract base class for module status logic including the binary location and version."""
__cached_location: str | None = None
__cached_version: str | None = None
@classmethod
def which(cls) -> str | None:
raise NotImplementedError()
@classmethod
def _cache_location(cls) -> str | None:
raise NotImplementedError()
@classmethod
def version(cls) -> str | None:
if cls.__cached_version is None:
cls.__cached_version = cls._version()
return cls.__cached_version
@classmethod
def _version(cls) -> str | None:
raise NotImplementedError()
@classmethod
def _which(cls, cmd: str) -> str | None:
"""Internal method for determining the correct location for which().
Args:
cmd (str): The process command to search for.
"""
if cls.__cached_location:
return cls.__cached_location
if platform.system() == "Darwin":
for loc in _MACOS_BIN_LOCATIONS:
full_command = which(loc + cmd)
if full_command:
cls.__cached_location = full_command
return full_command
cls.__cached_location = which(cmd)
return cls.__cached_location
@@ -0,0 +1,34 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: MIT
import contextlib
from typing import override
import structlog
from tagstudio.core.utils.module_status import ModuleStatus
from tagstudio.core.utils.silent_subprocess import (
silent_run, # pyright: ignore[reportUnknownVariableType]
)
logger = structlog.get_logger(__name__)
class RipgrepStatus(ModuleStatus):
"""Class for getting the location and version of ripgrep, if it exists."""
@override
@classmethod
def which(cls):
return cls._which("rg")
@override
@classmethod
def _version(cls):
ripgrep_cmd = cls.which()
if ripgrep_cmd:
out = silent_run([ripgrep_cmd, "-V"], shell=False, capture_output=True, text=True)
if out.returncode == 0:
with contextlib.suppress(Exception):
return str(out.stdout).split(" ")[1].rstrip("\n")
@@ -49,3 +49,14 @@ def is_version_outdated(current: str, latest: str) -> bool:
return vcur.patch < vlat.patch
else:
return vcur.prerelease is not None or vcur.build is not None
def format_duration(duration: int | float) -> str:
"""Format a duration in seconds as M:SS or H:MM:SS."""
try:
seconds = int(float(duration))
hours, seconds = divmod(seconds, 3600)
minutes, seconds = divmod(seconds, 60)
return f"{hours}:{minutes:02}:{seconds:02}" if hours else f"{minutes}:{seconds:02}"
except (OverflowError, ValueError):
return "-:--"
+1 -6
View File
@@ -2,12 +2,7 @@
# SPDX-License-Identifier: GPL-3.0-only
from typing import TypeVar
T = TypeVar("T")
def unwrap(optional: T | None, default: T | None = None) -> T:
def unwrap[T](optional: T | None, default: T | None = None) -> T:
if optional is not None:
return optional
if default is not None:
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env python
#!/usr/bin/env python3
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
+3 -3
View File
@@ -12,7 +12,7 @@ import structlog
from PIL import Image
from tagstudio.core.constants import THUMB_CACHE_NAME, TS_FOLDER_NAME
from tagstudio.qt.global_settings import DEFAULT_CACHED_IMAGE_QUALITY, DEFAULT_THUMB_CACHE_SIZE
from tagstudio.qt.global_settings import DEFAULT_CACHED_THUMB_QUALITY, DEFAULT_THUMB_CACHE_SIZE
logger = structlog.get_logger(__name__)
@@ -31,7 +31,7 @@ class CacheManager:
self,
library_dir: Path,
max_size: int | float = DEFAULT_THUMB_CACHE_SIZE,
img_quality: int = DEFAULT_CACHED_IMAGE_QUALITY,
img_quality: int = DEFAULT_CACHED_THUMB_QUALITY,
):
"""A class for managing frontend caches, such as for file thumbnails.
@@ -47,7 +47,7 @@ class CacheManager:
math.floor(CacheManager.MAX_FOLDER_SIZE * CacheManager.STAT_MULTIPLIER),
)
self.img_quality = (
img_quality if img_quality >= 0 and img_quality <= 100 else DEFAULT_CACHED_IMAGE_QUALITY
img_quality if img_quality >= 0 and img_quality <= 100 else DEFAULT_CACHED_THUMB_QUALITY
)
self.folders: list[CacheFolder] = []
@@ -2,7 +2,7 @@
# SPDX-License-Identifier: GPL-3.0-only
from typing import override
from typing import Any, override
from PySide6.QtCore import Signal
from PySide6.QtGui import QMouseEvent
@@ -14,8 +14,8 @@ class ClickableLabel(QLabel):
clicked = Signal()
def __init__(self):
super().__init__()
def __init__(self, *args: Any, **kwarg: Any): # pyright: ignore[reportExplicitAny]
super().__init__(*args, **kwarg)
@override
def mousePressEvent(self, ev: QMouseEvent):
@@ -0,0 +1,115 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import structlog
from tagstudio.core.library.alchemy.fields import (
BaseFieldTemplate,
DatetimeFieldTemplate,
TextFieldTemplate,
)
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.edit_field_template_modal_view import EditFieldTemplateModalView
from tagstudio.qt.views.stylesheets.stylesheets import line_edit_style
logger = structlog.get_logger(__name__)
class EditFieldTemplateModal(EditFieldTemplateModalView):
field_type_map: dict[str, str] = {
"TextFieldTemplate": Translations["field_type.text"],
"DatetimeFieldTemplate": Translations["field_type.datetime"],
}
DEFAULT_TYPE_INDEX = 0
def __init__(self, field_template: BaseFieldTemplate | None = None) -> None:
super().__init__()
self.__field_id: int | None = field_template.id if field_template else None
self.__field_name: str = ""
self.__field_type: str | None = field_template.class_name if field_template else None
self.old_field_type: str = ""
for k, v in EditFieldTemplateModal.field_type_map.items():
self._type_combobox.addItem(v, k)
self.__connect_callbacks()
self.set_field_template(field_template)
self.__on_type_changed(EditFieldTemplateModal.DEFAULT_TYPE_INDEX)
def __connect_callbacks(self) -> None:
self.name_field.textChanged.connect(self.__on_name_changed)
self._type_combobox.currentIndexChanged.connect(self.__on_type_changed)
def set_field_template(self, field_template: BaseFieldTemplate | None = None) -> None:
"""Populate the modal with pre-existing field template values, or fallback to defaults."""
logger.info("[EditFieldTemplate] Setting Field Template", field_template=field_template)
# Indicates a new template, set default values
if field_template is None:
self.__field_name = Translations["field_template.new"]
self.__field_type = list(EditFieldTemplateModal.field_type_map.keys())[
EditFieldTemplateModal.DEFAULT_TYPE_INDEX
]
return
# Populate common values for any field type
else:
self.__field_name = field_template.name
self.__field_type = field_template.class_name
self.old_field_type = field_template.class_name # Only set on init
# Update widgets
self.name_field.setText(self.__field_name)
self._type_combobox.setCurrentIndex(
list(EditFieldTemplateModal.field_type_map.keys()).index(field_template.class_name)
)
# Populate values for specific field types
if isinstance(field_template, TextFieldTemplate):
self._multiline_checkbox.setChecked(field_template.is_multiline)
def __on_name_changed(self):
is_empty = not self.name_field.text().strip()
self.name_field.setStyleSheet(line_edit_style() if is_empty else "")
if self.panel_save_button is not None:
self.panel_save_button.setDisabled(is_empty)
def __on_type_changed(self, index: int):
old_type = self.__field_type
self.__field_type = list(EditFieldTemplateModal.field_type_map.keys())[index]
if old_type == self.__field_type:
logger.info(f"old type {old_type}, new type {self.__field_type}")
return
if old_type == "TextFieldTemplate":
self._text_field_attributes_widget.hide()
# NOTE: Future options specific to other type will go here.
if self.__field_type == "TextFieldTemplate":
self._text_field_attributes_widget.show()
def build_field_template(self) -> BaseFieldTemplate:
if self.__field_type == "TextFieldTemplate":
return TextFieldTemplate(
id=self.__field_id,
name=self.name_field.text(),
is_multiline=self._multiline_checkbox.isChecked(),
)
elif self.__field_type == "DatetimeFieldTemplate":
return DatetimeFieldTemplate(
id=self.__field_id,
name=self.name_field.text(),
)
else:
logger.warning(
"[EditFieldTemplateModal] Unknown field, falling back to TextFieldTemplate",
field_type=self.__field_type,
example=TextFieldTemplate,
)
return TextFieldTemplate(
name=self.name_field.text(),
is_multiline=self._multiline_checkbox.isChecked(),
)
@@ -0,0 +1,67 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from typing import override
import structlog
from tagstudio.qt.views.edit_text_view import EditTextView
logger = structlog.get_logger(__name__)
class EditText(EditTextView):
def __init__(self, name: str, text: str | None, is_multiline: bool = False):
super().__init__()
self.name_field.setText(name)
self.text = text
self.is_multiline: bool = is_multiline
self.multiline_checkbox.setChecked(is_multiline)
self.multiline_checkbox.clicked.connect(lambda checked: self.on_multiline_checked(checked))
if self.is_multiline:
self.text_line.hide()
self.text_line_stretch.hide()
self.text_box.setPlainText(self.text or "")
else:
self.text_box.hide()
self.text_line.setText(self.text or "")
def on_multiline_checked(self, checked: bool):
was_multiline = self.is_multiline
self.is_multiline = checked
if was_multiline:
self.text = self.text_box.toPlainText()
self.text_box.hide()
self.text_line.setText(self.text)
self.text_line.show()
self.text_line_stretch.show()
else:
self.text = self.text_line.text()
self.text_line.hide()
self.text_line_stretch.hide()
self.text_box.setPlainText(self.text)
self.text_box.show()
@override
def parent_post_init(self):
if self.is_multiline:
self.text_box.setFocus()
else:
self.text_line.setFocus()
@override
def saved_data(self) -> dict[str, str | bool]:
return {
"name": self.name_field.text(),
"value": self.text_box.toPlainText() if self.is_multiline else self.text_line.text(),
"is_multiline": self.is_multiline,
}
@override
def reset(self):
self.text_box.setPlainText(self.text or "")
@@ -1,57 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from shutil import which
import structlog
from PySide6.QtCore import Qt, QUrl
from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import QMessageBox
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
from tagstudio.qt.previews.vendored.ffmpeg import FFMPEG_CMD, FFPROBE_CMD
from tagstudio.qt.translations import Translations
logger = structlog.get_logger(__name__)
class FfmpegMissingMessageBox(QMessageBox):
"""A warning dialog for if FFmpeg is missing."""
HELP_URL = "https://docs.tagstud.io/help/ffmpeg/"
def __init__(self):
super().__init__()
ffmpeg = "FFmpeg"
ffprobe = "FFprobe"
title = Translations.format("dependency.missing.title", dependency=ffmpeg)
self.setWindowTitle(title)
self.setIcon(QMessageBox.Icon.Warning)
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setStandardButtons(
QMessageBox.StandardButton.Help
| QMessageBox.StandardButton.Ignore
| QMessageBox.StandardButton.Cancel
)
self.setDefaultButton(QMessageBox.StandardButton.Ignore)
# Enables the cancel button but hides it to allow for click X to close dialog
self.button(QMessageBox.StandardButton.Cancel).hide()
self.button(QMessageBox.StandardButton.Help).clicked.connect(
lambda: QDesktopServices.openUrl(QUrl(self.HELP_URL))
)
red = get_ui_color(ColorType.PRIMARY, UiColor.RED)
green = get_ui_color(ColorType.PRIMARY, UiColor.GREEN)
missing = f"<span style='color:{red}'>{Translations['generic.missing']}</span>"
found = f"<span style='color:{green}'>{Translations['about.module.found']}</span>"
status = Translations.format(
"ffmpeg.missing.status",
ffmpeg=ffmpeg,
ffmpeg_status=found if which(FFMPEG_CMD) else missing,
ffprobe=ffprobe,
ffprobe_status=found if which(FFPROBE_CMD) else missing,
)
self.setText(f"{Translations['ffmpeg.missing.description']}<br><br>{status}")
@@ -0,0 +1,181 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from typing import override
from warnings import catch_warnings
import structlog
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QMessageBox
from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
from tagstudio.core.library.alchemy.library import Library
from tagstudio.qt.controllers.edit_field_template_modal import EditFieldTemplateModal
from tagstudio.qt.controllers.field_template_widget_controller import FieldTemplateWidget
from tagstudio.qt.controllers.search_panel_controller import SearchPanel
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.field_template_search_panel_view import FieldTemplateSearchPanelView
from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget
logger = structlog.get_logger(__name__)
class FieldTemplateSearchModal(PanelModal):
def __init__(
self,
library: Library,
is_field_template_chooser: bool = True,
has_save: bool = False,
) -> None:
self.search_panel: FieldTemplateSearchPanel = FieldTemplateSearchPanel(
library,
is_field_template_chooser,
view=FieldTemplateSearchPanelView(is_field_template_chooser),
)
super().__init__(
self.search_panel,
Translations["field.add.plural"],
is_savable=has_save,
)
class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
field_template_chosen = Signal(object)
def __init__(
self,
library: Library,
is_field_template_chooser: bool = True,
view: FieldTemplateSearchPanelView | None = None,
) -> None:
super().__init__(
view=view or FieldTemplateSearchPanelView(is_field_template_chooser),
exclude=[],
is_chooser=is_field_template_chooser,
)
self.__lib = library
self._unlimited_limit_item_label = Translations["field_template.all_field_templates"]
self._create_and_add_button_label_key = "field_template.create_add"
@override
def _get_max_limit(self) -> int:
return len(self.__lib.field_templates)
@override
def on_item_create(self, add_to_entry: bool = False) -> None:
"""Opens panel to create a new field template and optionally add it to an entry.
Populates name field using current search query.
Args:
add_to_entry (bool): Should this item be added to currently selected entries?
"""
query: str = self.get_search_query()
logger.info("[FieldTemplateSearch] Create and Add Field Template", name=query)
panel: EditFieldTemplateModal = EditFieldTemplateModal()
modal: PanelModal = PanelModal(
panel,
Translations["field_template.new"],
Translations["field_template.new"],
is_savable=True,
)
if query.strip():
panel.name_field.setText(query)
modal.saved.connect(lambda: self.create_item(panel, choose_item=add_to_entry))
modal.show()
@override
def on_item_edit(self, item: BaseFieldTemplate) -> None:
panel: EditFieldTemplateModal = EditFieldTemplateModal(item)
modal: PanelModal = PanelModal(
panel,
item.name,
Translations["field_template.edit"],
is_savable=True,
)
modal.saved.connect(lambda: self.edit_item(panel))
modal.show()
@override
def _on_item_remove(self, item: BaseFieldTemplate) -> None:
if self.is_chooser:
return
message_box = QMessageBox(
QMessageBox.Icon.Question,
Translations["field_template.delete"],
Translations.format("field_template.confirm_delete", field_template_name=item.name),
QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel,
)
result = message_box.exec()
if result != QMessageBox.StandardButton.Ok:
return
self.__lib.remove_field_template(item)
self.update_items(self.get_search_query())
@override
def _on_item_chosen(self, item: BaseFieldTemplate) -> None:
self.field_template_chosen.emit(item)
@override
def search_items(self, query: str) -> tuple[list[BaseFieldTemplate], list[BaseFieldTemplate]]:
return self.__lib.search_field_templates(name=query, limit=self._get_limit()[1]), []
@override
def set_item_widget(self, item: BaseFieldTemplate | None, index: int) -> None:
"""Set the field template of a field template widget at a specific index."""
field_template_widget: FieldTemplateWidget = self.get_item_widget(index, self.__lib)
field_template_widget.set_field_template(item)
field_template_widget.setHidden(item is None)
if item is None:
return
field_template_widget.has_remove = not self.is_chooser
# Disconnect previous callbacks
with catch_warnings(record=True):
field_template_widget.on_edit.disconnect()
field_template_widget.on_remove.disconnect()
field_template_widget.on_click.disconnect()
# Connect callbacks
field_template_widget.on_edit.connect(lambda item_=item: self.on_item_edit(item_))
field_template_widget.on_remove.connect(lambda item_=item: self._on_item_remove(item_))
field_template_widget.on_click.connect(
lambda checked=False, item_=item: self._on_item_chosen(item_)
)
@override
def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None:
if isinstance(edit_item_panel, EditFieldTemplateModal):
template: BaseFieldTemplate = edit_item_panel.build_field_template()
self.__lib.add_field_template(template)
if choose_item:
self._on_item_chosen(template)
self.clear_search_query()
edit_item_panel.hide()
self.on_search_query_changed(self.get_search_query())
@override
def edit_item(self, edit_item_panel: PanelWidget) -> None:
if not isinstance(edit_item_panel, EditFieldTemplateModal):
return
self.__lib.update_field_template(
edit_item_panel.old_field_type, edit_item_panel.build_field_template()
)
self.update_items(self.search_field.text())
@@ -0,0 +1,50 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from typing import override
from PySide6.QtCore import QEvent, Qt
from PySide6.QtGui import QAction, QEnterEvent
from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
from tagstudio.qt.translations import FIELD_TYPE_KEYS, Translations
from tagstudio.qt.views.field_template_widget_view import FieldTemplateWidgetView
class FieldTemplateWidget(FieldTemplateWidgetView):
def __init__(self) -> None:
super().__init__()
self.__field_template: BaseFieldTemplate | None = None
self.has_remove: bool = False
# Add actions
edit_action = QAction(self)
edit_action.setText(Translations["generic.edit"])
edit_action.triggered.connect(self.on_edit.emit)
self.addAction(edit_action)
self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
def set_field_template(self, field_template: BaseFieldTemplate | None) -> None:
self.__field_template = field_template
if field_template is None:
return
field_name_key: str = FIELD_TYPE_KEYS.get(field_template.class_name, "field_type.unknown")
self._bg_button.setText(f"{field_template.name} ({Translations[field_name_key]})")
@override
def enterEvent(self, event: QEnterEvent) -> None:
if self.has_remove:
self._delete_button.setHidden(False)
self.update()
return super().enterEvent(event)
@override
def leaveEvent(self, event: QEvent) -> None:
if self.has_remove:
self._delete_button.setHidden(True)
self.update()
return super().leaveEvent(event)
@@ -13,6 +13,7 @@ from tagstudio.qt.mixed.progress_bar import ProgressWidget
from tagstudio.qt.mixed.remove_ignored_modal import RemoveIgnoredModal
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.fix_ignored_modal_view import FixIgnoredEntriesModalView
from tagstudio.qt.views.stylesheets.stylesheets import header
# Only import for type checking/autocompletion, will not be imported at runtime.
if TYPE_CHECKING:
@@ -78,7 +79,7 @@ class FixIgnoredEntriesModal(FixIgnoredEntriesModalView):
count_text: str = Translations.format(
"entries.ignored.ignored_count", count=count if count >= 0 else ""
)
self.ignored_count_label.setText(f"<h3>{count_text}</h3>")
self.ignored_count_label.setText(header(count_text, 3))
def update_driver_widgets(self):
if (
@@ -22,6 +22,7 @@ from tagstudio.core.utils.types import unwrap
from tagstudio.qt.translations import Translations
from tagstudio.qt.utils import file_opener
from tagstudio.qt.views.library_info_window_view import LibraryInfoWindowView
from tagstudio.qt.views.stylesheets.stylesheets import header
# Only import for type checking/autocompletion, will not be imported at runtime.
if TYPE_CHECKING:
@@ -61,7 +62,7 @@ class LibraryInfoWindow(LibraryInfoWindowView):
title: str = Translations.format(
"library_info.title", library_dir=self.lib.library_dir.stem
)
self.title_label.setText(f"<h2>{title}</h2>")
self.title_label.setText(header(title, 2))
def update_stats(self):
self.entry_count_label.setText(f"<b>{self.lib.entries_count}</b>")
@@ -10,6 +10,7 @@ from PySide6.QtCore import Qt
from PySide6.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget
from tagstudio.qt.controllers.paged_panel_state import PagedPanelState
from tagstudio.qt.views.stylesheets.stylesheets import header
logger = structlog.get_logger(__name__)
@@ -89,7 +90,7 @@ class PagedPanel(QWidget):
# Update Title
self.setWindowTitle(frame.title)
self.title_label.setText(f"<h1>{frame.title}</h1>")
self.title_label.setText(header(frame.title, 1))
# Update Body Widget
if self.body_layout.itemAt(0):
@@ -107,7 +108,7 @@ class PagedPanel(QWidget):
if isinstance(item, QWidget):
self.button_nav_layout.addWidget(item)
item.setHidden(False)
elif isinstance(item, int):
elif isinstance(item, int): # pyright: ignore[reportUnnecessaryIsInstance]
self.button_nav_layout.addStretch(item)
@override
@@ -5,11 +5,12 @@
import typing
from warnings import catch_warnings
from PySide6.QtWidgets import QListWidgetItem
from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
from tagstudio.core.library.alchemy.library import Library
from tagstudio.qt.mixed.add_field import AddFieldModal
from tagstudio.qt.mixed.tag_search import TagSearchModal
from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus
from tagstudio.qt.controllers.field_template_search_panel_controller import FieldTemplateSearchModal
from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchModal
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.preview_panel_view import PreviewPanelView
if typing.TYPE_CHECKING:
@@ -17,35 +18,48 @@ if typing.TYPE_CHECKING:
class PreviewPanel(PreviewPanelView):
def __init__(self, library: Library, driver: "QtDriver"):
def __init__(self, library: Library, driver: "QtDriver") -> None:
super().__init__(library, driver)
self.__add_field_modal = AddFieldModal(self.lib)
self.__add_tag_modal = TagSearchModal(self.lib, is_tag_chooser=True)
self.__add_field_modal = FieldTemplateSearchModal(self.lib, is_field_template_chooser=True)
self.__add_tag_modal = TagSearchModal(
self.lib, title=Translations["tag.add.plural"], is_tag_chooser=True
)
self.__add_tag_modal.tsp.set_driver(driver)
self._thumb.check_ffmpeg.connect(self._toggle_ffmpeg_warning)
@typing.override
def _add_field_button_callback(self):
def _add_field_button_callback(self) -> None:
self.__add_field_modal.show()
@typing.override
def _add_tag_button_callback(self):
def _add_tag_button_callback(self) -> None:
self.__add_tag_modal.show()
@typing.override
def _set_selection_callback(self):
def _set_selection_callback(self) -> None:
with catch_warnings(record=True):
self.__add_field_modal.done.disconnect()
self.__add_tag_modal.tsp.tag_chosen.disconnect()
self.__add_field_modal.search_panel.field_template_chosen.disconnect()
self.__add_tag_modal.tsp.item_chosen.disconnect()
self.__add_field_modal.done.connect(self._add_field_to_selected)
self.__add_tag_modal.tsp.tag_chosen.connect(self._add_tag_to_selected)
self.__add_field_modal.search_panel.field_template_chosen.connect(
self._add_field_to_selected
)
self.__add_tag_modal.tsp.item_chosen.connect(self._add_tag_to_selected)
def _add_field_to_selected(self, field_list: list[QListWidgetItem]):
self._fields.add_field_to_selected(field_list)
def _add_field_to_selected(self, template: BaseFieldTemplate) -> None:
self._containers.add_field_to_selected(template)
if len(self._selected) == 1:
self._fields.update_from_entry(self._selected[0])
self._containers.update_from_entry(self._selected[0])
def _add_tag_to_selected(self, tag_id: int):
self._fields.add_tags_to_selected(tag_id)
def _add_tag_to_selected(self, tag_id: int) -> None:
self._containers.add_tags_to_selected(tag_id)
if len(self._selected) == 1:
self._fields.update_from_entry(self._selected[0])
self._containers.update_from_entry(self._selected[0])
def _toggle_ffmpeg_warning(self, enable_warning: bool = True) -> None:
if enable_warning and (not FfmpegStatus.which() or not FfprobeStatus.which()):
self._ffmpeg_warning_widget.show()
return
self._ffmpeg_warning_widget.hide()
@@ -32,8 +32,6 @@ Image.MAX_IMAGE_PIXELS = None
class PreviewThumb(PreviewThumbView):
__current_file: Path
def __init__(self, library: Library, driver: "QtDriver"):
super().__init__(library, driver)
@@ -114,7 +112,7 @@ class PreviewThumb(PreviewThumbView):
def display_file(self, filepath: Path) -> FileAttributeData:
"""Render a single file preview."""
self.__current_file = filepath
self._current_file = filepath
ext = filepath.suffix.lower()
@@ -150,21 +148,26 @@ class PreviewThumb(PreviewThumbView):
@override
def _open_file_action_callback(self):
open_file(
self.__current_file, windows_start_command=self.__driver.settings.windows_start_command
)
if self._current_file:
open_file(
self._current_file,
windows_start_command=self.__driver.settings.windows_start_command,
)
@override
def _open_explorer_action_callback(self):
open_file(self.__current_file, file_manager=True)
if self._current_file:
open_file(self._current_file, file_manager=True)
@override
def _delete_action_callback(self):
if bool(self.__current_file):
self.__driver.delete_files_callback(self.__current_file)
if self._current_file:
self.__driver.delete_files_callback(self._current_file)
@override
def _button_wrapper_callback(self):
open_file(
self.__current_file, windows_start_command=self.__driver.settings.windows_start_command
)
if self._current_file:
open_file(
self._current_file,
windows_start_command=self.__driver.settings.windows_start_command,
)
@@ -0,0 +1,241 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from typing import TYPE_CHECKING, Any, override
import structlog
from PySide6 import QtCore, QtGui
from PySide6.QtCore import Signal
from PySide6.QtGui import QShowEvent
from PySide6.QtWidgets import QVBoxLayout, QWidget
from tagstudio.core.library.alchemy.library import Library
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.panel_modal import PanelWidget
from tagstudio.qt.views.search_panel_view import SearchPanelView
logger = structlog.get_logger(__name__)
# Only import for type checking/autocompletion, will not be imported at runtime.
if TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
def _item_id(item: object) -> int:
item_id: Any = getattr(item, "id") # noqa: B009 # pyright: ignore[reportExplicitAny]
if isinstance(item_id, int):
return item_id
else:
raise AttributeError()
def _item_name(item: object) -> str:
item_name: Any = getattr(item, "name") # noqa: B009 # pyright: ignore[reportExplicitAny]
if isinstance(item_name, str):
return item_name
else:
raise AttributeError()
class SearchPanel[T](PanelWidget):
item_chosen = Signal(int)
def __init__(
self,
view: SearchPanelView,
exclude: list[int] | None = None,
is_chooser: bool = True,
) -> None:
super().__init__()
self.view = view
self.is_chooser = is_chooser
self._layout = QVBoxLayout(self)
self._layout.setContentsMargins(0, 0, 0, 0)
self._layout.addWidget(self.view)
self.view.connect_callbacks(self)
self._driver: QtDriver | None = None
self.exclude: list[int] = exclude or []
# Limits
self._unlimited_limit_item_label: str = "All Items"
self.__limit_items: list[tuple[str, int]] = [
("25", 25),
("50", 50),
("100", 100),
("250", 250),
("500", 500),
(self._unlimited_limit_item_label, -1),
]
self.__default_limit_index: int = 0 # 25 Limit (Default)
self.__previous_limit_index: int = self.__default_limit_index
self.view.set_limit_items(self.__limit_items)
self.view.set_limit_index(self.__default_limit_index)
# Items
self._search_results: list[T] = []
self._create_and_add_button_label_key: str = ""
@property
def search_field(self):
return self.view.search_field
@property
def create_and_add_button(self):
return self.view.create_and_add_button
def get_search_query(self) -> str:
return self.view.get_search_query()
def clear_search_query(self) -> None:
self.view.clear_search_query()
def get_item_widget(self, index: int, library: Library):
return self.view.get_item_widget(index, library)
def set_driver(self, driver: "QtDriver") -> None:
self._driver = driver
def on_limit_changed(self, index: int) -> None:
# Method was called outside the limit_combobox callback
if index != self.view.get_limit_index():
self.view.set_limit_index(index)
if self.__previous_limit_index == index:
return
self.update_items(self.search_field.text())
def _get_limit(self) -> tuple[str, int]:
return self.__limit_items[self.view.get_limit_index()]
def _get_previous_limit(self) -> tuple[str, int]:
return self.__limit_items[self.__previous_limit_index]
def _get_max_limit(self) -> int:
raise NotImplementedError()
def on_search_query_changed(self, query: str) -> None:
self.create_and_add_button.setText(
Translations.format(self._create_and_add_button_label_key, query=query)
)
self.update_items(query)
def on_search_query_submitted(self, query: str) -> None:
# Focus search field if no query
if not query:
self.search_field.setFocus()
parent: QWidget | None = self.parentWidget()
if parent is not None: # pyright: ignore[reportUnnecessaryComparison]
parent.hide()
return
# Create and add item if no search results
if len(self._search_results) <= 0:
self.on_item_create(add_to_entry=True)
elif self.is_chooser:
self._on_item_chosen(self._search_results[0])
self.clear_search_query()
self.update_items()
def on_item_create(self, add_to_entry: bool = False) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
def on_item_edit(self, item: T) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
def _on_item_remove(self, item: T) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
def _on_item_chosen(self, item: T) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
def _is_excluded(self, item: T) -> bool:
return _item_id(item) in self.exclude
def update_items(self, query: str | None = None) -> None:
"""Update the item list given a search query."""
logger.info("[SearchPanel] Updating items", limit=self._get_limit()[1])
# Remove the "Create & Add" button if one exists
self.view.remove_create_and_add_button()
# Get results for the search query
query_lower = "" if not query else query.lower()
search_results: tuple[list[T], list[T]] = self.search_items(query_lower)
# Sort and prioritize the results
direct_results = list({item for item in search_results[0] if not self._is_excluded(item)})
direct_results.sort(key=lambda item: _item_name(item).lower())
ancestor_results = list({item for item in search_results[1] if not self._is_excluded(item)})
ancestor_results.sort(key=lambda item: _item_name(item).lower())
raw_results = list(direct_results + ancestor_results)
priority_results: set[T] = set()
if query and query.strip():
for raw_item in raw_results:
if _item_name(raw_item).lower().startswith(query_lower):
priority_results.add(raw_item)
all_results: list[T] = sorted(list(priority_results), key=lambda i: len(_item_name(i))) + [
item for item in raw_results if item not in priority_results
]
if self._get_limit()[1] > 0:
all_results = all_results[: self._get_limit()[1]]
self._search_results = all_results
logger.info("[SearchPanel] Search results", results=self._search_results)
# Update every item widget with the new search result data
previous_limit: int = (
self._get_previous_limit()[1] > 0 and self._get_previous_limit()[1]
) or self._get_max_limit()
current_limit: int = (
self._get_limit()[1] > 0 and self._get_limit()[1]
) or self._get_max_limit()
for i in range(0, max(previous_limit, current_limit)):
item: T | None = all_results[i] if i < len(all_results) else None
self.set_item_widget(item=item, index=i)
self.__previous_limit_index = self.view.get_limit_index()
# Add back the "Create & Add" button
if query and query.strip():
self.view.add_create_and_add_button()
def search_items(self, query: str) -> tuple[list[T], list[T]]: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
def set_item_widget(self, item: T | None, index: int) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
@override
def showEvent(self, event: QShowEvent) -> None: # noqa N802
self.update_items()
self.view.scroll_to(0)
self.view.clear_search_query()
return super().showEvent(event)
@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():
super().keyPressEvent(event)
else:
self.view.focus_search_box(select_all=True)
def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
def edit_item(self, edit_item_panel: PanelWidget) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
@@ -2,6 +2,7 @@
# SPDX-License-Identifier: GPL-3.0-only
from functools import partial
from typing import TYPE_CHECKING, override
import structlog
@@ -77,20 +78,19 @@ class TagBoxWidget(TagBoxWidgetView):
build_tag_panel,
self.__driver.lib.tag_display_name(tag),
"Edit Tag",
done_callback=self.on_update.emit,
has_save=True,
)
# TODO - this was update_tag()
edit_modal.saved.connect(
lambda: self.__driver.lib.update_tag(
build_tag_panel.build_tag(),
parent_ids=set(build_tag_panel.parent_ids),
alias_names=set(build_tag_panel.alias_names),
alias_ids=set(build_tag_panel.alias_ids),
)
is_savable=True,
)
edit_modal.saved.connect(partial(self._update_tag_callback, build_tag_panel))
edit_modal.show()
def _update_tag_callback(self, build_tag_panel: BuildTagPanel):
self.__driver.lib.update_tag(
build_tag_panel.build_tag(),
parent_ids=set(build_tag_panel.parent_ids),
aliases=set(build_tag_panel.aliases),
)
self.on_update.emit()
@override
def _on_search(self, tag: Tag) -> None:
self.__driver.main_window.search_field.setText(f"tag_id:{tag.id}")
@@ -0,0 +1,230 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from typing import override
from warnings import catch_warnings
import structlog
from PySide6.QtWidgets import QMessageBox
from tagstudio.core.constants import RESERVED_TAG_END, RESERVED_TAG_START
from tagstudio.core.library.alchemy.enums import BrowsingState
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Tag
from tagstudio.qt.controllers.search_panel_controller import SearchPanel
from tagstudio.qt.mixed.tag_widget import TagWidget
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget
from tagstudio.qt.views.tag_search_panel_view import TagSearchPanelView
logger = structlog.get_logger(__name__)
class TagSearchModal(PanelModal):
tsp: "TagSearchPanel"
def __init__(
self,
library: Library,
title: str,
exclude: list[int] | None = None,
is_tag_chooser: bool = True,
has_save: bool = False,
):
self.tsp = TagSearchPanel(
library,
exclude,
is_tag_chooser,
view=TagSearchPanelView(is_tag_chooser),
)
super().__init__(
widget=self.tsp,
title=title,
is_savable=has_save,
)
class TagSearchPanel(SearchPanel[Tag]):
def __init__(
self,
library: Library,
exclude: list[int] | None = None,
is_tag_chooser: bool = True,
view: TagSearchPanelView | None = None,
):
super().__init__(
view=view or TagSearchPanelView(is_tag_chooser),
exclude=exclude,
is_chooser=is_tag_chooser,
)
self.__lib = library
self._unlimited_limit_item_label = Translations["tag.all_tags"]
self._create_and_add_button_label_key = "tag.create_add"
@override
def _get_max_limit(self) -> int:
return len(self.__lib.tags)
@override
def on_item_create(self, add_to_entry: bool = False) -> None:
"""Opens panel to create a new tag and optionally add it to an entry.
Populates name field using current search query.
Args:
add_to_entry (bool): Should this item be added to currently selected entries?
"""
# TODO: Move this to a top-level import
from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports
query: str = self.get_search_query()
panel: BuildTagPanel = BuildTagPanel(self.__lib)
modal: PanelModal = PanelModal(
panel,
Translations["tag.new"],
Translations["tag.add"] if add_to_entry else Translations["tag.new"],
is_savable=True,
)
if query.strip():
panel.name_field.setText(query)
modal.saved.connect(lambda: self.create_item(panel, choose_item=add_to_entry))
modal.show()
@override
def on_item_edit(self, item: Tag) -> None:
# TODO: Move this to a top-level import
from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports
edit_tag_panel: BuildTagPanel = BuildTagPanel(self.__lib, tag=item)
edit_tag_modal: PanelModal = PanelModal(
edit_tag_panel,
self.__lib.tag_display_name(item),
Translations["tag.edit"],
is_savable=True,
)
edit_tag_modal.saved.connect(lambda: self.edit_item(edit_tag_panel))
edit_tag_modal.show()
@override
def _on_item_remove(self, item: Tag) -> None:
if self.is_chooser:
return
if item.id in range(RESERVED_TAG_START, RESERVED_TAG_END):
return
message_box = QMessageBox(
QMessageBox.Icon.Question,
Translations["tag.remove"],
Translations.format("tag.confirm_delete", tag_name=self.__lib.tag_display_name(item)),
QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel,
)
result = message_box.exec()
if result != QMessageBox.StandardButton.Ok:
return
self.__lib.remove_tag(item.id)
self.update_items(self.get_search_query())
@override
def _on_item_chosen(self, item: Tag) -> None:
self.item_chosen.emit(item.id)
@override
def search_items(self, query: str) -> tuple[list[Tag], list[Tag]]:
return self.__lib.search_tags(name=query, limit=self._get_limit()[1])
@override
def set_item_widget(self, item: Tag | None, index: int) -> None:
"""Set the tag of a tag widget at a specific index."""
tag_widget: TagWidget = self.get_item_widget(index, self.__lib)
tag_widget.set_tag(item)
tag_widget.setHidden(item is None)
if item is None:
return
assert item is not None
tag_widget.has_remove = not self.is_chooser and item.id not in range(
RESERVED_TAG_START, RESERVED_TAG_END
)
# Disconnect previous callbacks
with catch_warnings(record=True):
tag_widget.on_edit.disconnect()
tag_widget.on_remove.disconnect()
tag_widget.bg_button.clicked.disconnect()
tag_widget.search_for_tag_action.triggered.disconnect()
# Connect callbacks
tag_widget.on_edit.connect(lambda edit_tag=item: self.on_item_edit(edit_tag))
tag_widget.on_remove.connect(lambda remove_tag=item: self._on_item_remove(remove_tag))
if self.is_chooser:
tag_widget.bg_button.clicked.connect(
lambda checked=False, tag=item: self._on_item_chosen(tag)
)
else:
tag_widget.bg_button.clicked.connect(
lambda checked=False, edit_tag=item: self.on_item_edit(edit_tag)
)
# Connect search action
if self._driver is not None:
tag_widget.search_for_tag_action.triggered.connect(
lambda checked=False, tag_id=item.id: self.search_for_tag(tag_id)
)
tag_widget.search_for_tag_action.setEnabled(True)
else:
logger.warning(
"[TagSearchPanel] No driver was set for this TagSearchPanel. Was this on purpose?"
)
tag_widget.search_for_tag_action.setEnabled(False)
@override
def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None:
# TODO: Move this to a top-level import
from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports
if isinstance(edit_item_panel, BuildTagPanel):
tag: Tag = edit_item_panel.build_tag()
self.__lib.add_tag(
tag, parent_ids=edit_item_panel.parent_ids, aliases=edit_item_panel.aliases
)
if choose_item:
self._on_item_chosen(tag)
self.clear_search_query()
edit_item_panel.hide()
self.on_search_query_changed(self.get_search_query())
@override
def edit_item(self, edit_item_panel: PanelWidget) -> None:
# TODO: Move this to a top-level import
from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports
if not isinstance(edit_item_panel, BuildTagPanel):
return
self.__lib.update_tag(
tag=edit_item_panel.build_tag(),
parent_ids=edit_item_panel.parent_ids,
aliases=edit_item_panel.aliases,
)
self.update_items(self.search_field.text())
def search_for_tag(self, tag_id: int) -> None:
if self._driver is None:
return
self._driver.main_window.search_field.setText(f"tag_id:{tag_id}")
self._driver.update_browsing_state(
BrowsingState.from_tag_id(tag_id, self._driver.browsing_history.current)
)
@@ -2,36 +2,57 @@
# SPDX-License-Identifier: GPL-3.0-only
import math
from functools import partial
import structlog
from PIL import ImageQt
from PySide6.QtCore import Qt
from PySide6.QtGui import QDesktopServices, QPixmap
from PySide6.QtWidgets import QMessageBox
from tagstudio.core.constants import GITHUB_RELEASE_URL, VERSION
from tagstudio.core.ts_core import TagStudioCore
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.translations import Translations
logger = structlog.get_logger(__name__)
class OutOfDateMessageBox(QMessageBox):
class UpdateAvailableMessageBox(QMessageBox):
"""A warning dialog for if the TagStudio is not running under the latest release version."""
def __init__(self):
super().__init__()
title = Translations.format("version_modal.title")
rm = ResourceManager()
title = Translations["version_modal.title"]
self.setWindowTitle(title)
self.setIcon(QMessageBox.Icon.Warning)
pixel_ratio = self.devicePixelRatio()
icon = QPixmap.fromImage(ImageQt.ImageQt(rm.icon)).scaled(
math.floor(48 * pixel_ratio),
math.floor(48 * pixel_ratio),
Qt.AspectRatioMode.IgnoreAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
icon.setDevicePixelRatio(pixel_ratio)
self.setIconPixmap(icon)
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setStyleSheet("QPushButton {padding: 3px 8px;}")
self.setStandardButtons(
QMessageBox.StandardButton.Ignore | QMessageBox.StandardButton.Cancel
QMessageBox.StandardButton.Close
| QMessageBox.StandardButton.Ignore
| QMessageBox.StandardButton.Ok
)
self.setDefaultButton(QMessageBox.StandardButton.Ignore)
# Enables the cancel button but hides it to allow for click X to close dialog
self.button(QMessageBox.StandardButton.Cancel).hide()
self.setDefaultButton(QMessageBox.StandardButton.Ok)
self.button(QMessageBox.StandardButton.Ok).setText(Translations["update.view_update"])
self.button(QMessageBox.StandardButton.Ok).clicked.connect(
partial(QDesktopServices.openUrl, GITHUB_RELEASE_URL)
)
self.button(QMessageBox.StandardButton.Ignore).setText(Translations["generic.dont_remind"])
red = get_ui_color(ColorType.PRIMARY, UiColor.RED)
green = get_ui_color(ColorType.PRIMARY, UiColor.GREEN)
+7 -4
View File
@@ -26,8 +26,10 @@ DEFAULT_THUMB_CACHE_SIZE = 500 # Number in MiB
MIN_THUMB_CACHE_SIZE = 10 # Number in MiB
# See: https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#webp-saving
DEFAULT_CACHED_IMAGE_QUALITY = 80
DEFAULT_CACHED_IMAGE_RES = 256
DEFAULT_CACHED_THUMB_QUALITY = 80 # WebP Compression Quality
MIN_CACHED_THUMB_RES = 32 # Pixels
MAX_CACHED_THUMB_RES = 1024 # Pixels
DEFAULT_CACHED_THUMB_RES = 256 # Pixels
class Theme(IntEnum):
@@ -43,6 +45,7 @@ class Splash(StrEnum):
CLASSIC = "classic"
GOO_GEARS = "goo_gears"
NINETY_FIVE = "95"
AURORA = "aurora"
class TomlEnumEncoder(toml.TomlEncoder):
@@ -61,8 +64,8 @@ class GlobalSettings(BaseModel):
open_last_loaded_on_startup: bool = Field(default=True)
generate_thumbs: bool = Field(default=True)
thumb_cache_size: float = Field(default=DEFAULT_THUMB_CACHE_SIZE)
cached_thumb_quality: int = Field(default=DEFAULT_CACHED_IMAGE_QUALITY)
cached_thumb_resolution: int = Field(default=DEFAULT_CACHED_IMAGE_RES)
cached_thumb_quality: int = Field(default=DEFAULT_CACHED_THUMB_QUALITY)
cached_thumb_resolution: int = Field(default=DEFAULT_CACHED_THUMB_RES)
autoplay: bool = Field(default=True)
scan_files_on_open: bool = Field(default=True)
loop: bool = Field(default=True)
+31 -2
View File
@@ -16,11 +16,14 @@ _THEME_DARK_BG: str = "#000000DD"
_THEME_LIGHT_BG: str = "#FFFFFF55"
def theme_fg_overlay(image: Image.Image, use_alpha: bool = True) -> Image.Image:
"""Overlay the foreground theme color onto an image.
def auto_theme_overlay(
image: Image.Image, inverse: bool = False, use_alpha: bool = True
) -> Image.Image:
"""Overlay the current foreground theme color onto an image.
Args:
image (Image): The PIL Image object to apply an overlay to.
inverse (bool): Option inverse the overlay color relative to the current theme.
use_alpha (bool): Option to retain the base image's alpha value when applying the overlay.
"""
dark_fg: str = _THEME_DARK_FG[:-2] if not use_alpha else _THEME_DARK_FG
@@ -29,11 +32,37 @@ def theme_fg_overlay(image: Image.Image, use_alpha: bool = True) -> Image.Image:
overlay_color = (
dark_fg if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark else light_fg
)
if inverse:
overlay_color = light_fg if overlay_color == dark_fg else dark_fg
im = Image.new(mode="RGBA", size=image.size, color=overlay_color)
return _apply_overlay(image, im)
def light_overlay(image: Image.Image, use_alpha: bool = True) -> Image.Image:
"""Overlay the light theme foreground color onto an image.
Args:
image (Image): The PIL Image object to apply an overlay to.
use_alpha (bool): Option to retain the base image's alpha value when applying the overlay.
"""
overlay_color: str = _THEME_DARK_FG[:-2] if not use_alpha else _THEME_DARK_FG
im = Image.new(mode="RGBA", size=image.size, color=overlay_color)
return _apply_overlay(image, im)
def dark_overlay(image: Image.Image, use_alpha: bool = True) -> Image.Image:
"""Overlay the dark theme foreground color onto an image.
Args:
image (Image): The PIL Image object to apply an overlay to.
use_alpha (bool): Option to retain the base image's alpha value when applying the overlay.
"""
overlay_color: str = _THEME_LIGHT_FG[:-2] if not use_alpha else _THEME_LIGHT_FG
im = Image.new(mode="RGBA", size=image.size, color=overlay_color)
return _apply_overlay(image, im)
def gradient_overlay(image: Image.Image, gradient: list[str]) -> Image.Image:
"""Overlay a color gradient onto an image.

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