Compare commits

...

39 Commits

Author SHA1 Message Date
Travis Abendshien 291d4b7678 build: bump pyside version to 6.11.2 2026-08-18 14:38:08 -07:00
Travis Abendshien 8ec26dea3c fix: disallow 3.15.0 2026-08-18 14:34:41 -07:00
Travis Abendshien bcca08556e chore: formatting pass 2026-08-18 14:05:35 -07:00
Travis Abendshien 6452664b2a build: remove version condition for audioop-lts 2026-08-18 13:57:07 -07:00
Travis Abendshien bc96ba9e52 ci: bump python to 3.14, update docs 2026-08-18 13:57:07 -07:00
Travis Abendshien ea76910451 build: bump python to 3.14 2026-08-18 13:57:07 -07:00
Travis Abendshien c32f500f3c refactor!: organize project layout (#1483)
* refactor!: organize project layout

* refactor: organize tests

* fix: change main.py with __main__.py in tagstudio.spec

* refactor: rename renderer.py to file_renderer.py
2026-08-18 13:56:35 -07:00
Xarvex f89f0a6c7f feat(nix/package): build for multiple Python versions 2026-08-18 14:27:40 -05:00
Xarvex c33455b7a0 fix(dev): add reuse linter
We use this for our CI checks but for some reason not locally. Add it to
our pyproject and Nix shell, and create a pre-commit step for it.
2026-08-17 23:43:19 -05:00
Travis Abendshien 12fd4933be fix: fix json migration UI and tests (#1484)
* fix: fix json migration UI and tests

* fix: just replace the Qt stuff with the actual iterator, please

* fix: skip UI iterator logic and just run iterator in migration modal

* fix: remove unnecessary for loop in test
2026-08-16 23:12:47 -07:00
Jann Stute 7e7def5a52 refactor(migrations): better library decoupling (#1482)
* refactor: minor improvement of version check

* refactor: don't use Library._all_entries in migrations

* chore: ruff format

* refactor: get_version split

* fix: typo

* doc: update note on deprecation of preferences table
2026-08-16 23:25:41 +02:00
Travis Abendshien 17cf87a4fa fix(docs): fix changelog header level 2026-08-16 11:57:48 -07:00
Sola-ris 3fe7922642 feat: add per-tag category display options (#1470)
* feat: add per-tag display options.

* feat: bump DB_VERSION to 301, add migration.

* tests: add tests.

* docs: document cateogry display options.

* docs: document library versions 300 and 301.

* fix: resolve ruff and pyright issues.

* fix: redo migration in the new style.

* fix: Remove unnecessary @staticmethod decorators.

* fix: Bump library version to 400.

* fix: Fix color order in colored_radio_button_style.

* fix: Commit the library produced by pytest.

* test: Add test for removing a category during tag creation.

* fix: Fix removing categories during tag creation.

* test: Add test for adding another tag after removing an existing category.

* fix: Fix adding another tag after removing an existing category.

* feat: Add separators between widgets.

* docs: Fix version in library-changes.md.

* test: Test removing a category inherited both directly and indirectly.

* fix: Fix removing a category inherited both directly and indirectly.

---------

Co-authored-by: Travis Abendshien <46939827+cyanvoxel@users.noreply.github.com>
2026-08-16 11:54:04 -07:00
Travis Abendshien 555dae50d4 chore: bump version to v9.6.4 2026-08-15 21:46:46 -07:00
Travis Abendshien e6d67c26dd docs: reorder changelog to match release notes 2026-08-15 21:46:20 -07:00
Travis Abendshien 435f5ff2b6 fix(ui) fix suggest box icon and opacity edge cases 2026-08-15 14:05:46 -07:00
Travis Abendshien 3cebb211b3 docs: ammend changelog 2026-08-15 13:28:26 -07:00
Travis Abendshien 512bf623c2 fix(ui): fix accent color issues on Windows (#1477) 2026-08-15 13:24:39 -07:00
Travis Abendshien 30c81257fa fix: revert update check order 2026-08-15 13:24:24 -07:00
Travis Abendshien fcd4d67711 fix(logs): use correct name for missing optional modules 2026-08-15 12:49:22 -07:00
Travis Abendshien b44ab8f161 docs: update changelog 2026-08-15 11:08:27 -07:00
Travis Abendshien 56a0b73cbd fix: early return higher DB version, log+type hint fixes (#1476) 2026-08-15 11:03:12 -07:00
Travis Abendshien 3ce65552f7 fix: yield from all_entries() instead of returning (fixes #1447, fixes #1467) 2026-08-15 10:29:46 -07:00
Weblate (bot) 49e320618c translations: update from Hosted Weblate (#1468)
* Translated using Weblate (Japanese)

Currently translated at 100.0% (428 of 428 strings)

Co-authored-by: coolvitto <coolzdaddy55@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/ja/
Translation: TagStudio/Application Strings

* Translated using Weblate (Hungarian)

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

* Translated using Weblate (Hebrew)

Currently translated at 2.3% (10 of 423 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: random stuff <random.uselesss.stuff@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/he/
Translation: TagStudio/Application Strings

* Translated using Weblate (French)

Currently translated at 100.0% (423 of 423 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/Application Strings

---------

Co-authored-by: coolvitto <coolzdaddy55@gmail.com>
Co-authored-by: Szíjártó Levente Pál <szijartoleventepal@gmail.com>
Co-authored-by: random stuff <random.uselesss.stuff@gmail.com>
Co-authored-by: Med <45147847+kitsumed@users.noreply.github.com>
2026-08-14 21:46:34 -07:00
Travis Abendshien 102cfdf4a2 feat(ui): add library format version to about window 2026-08-14 10:29:37 -07:00
Travis Abendshien 47b123569e chore: bump version to v9.6.3 2026-08-14 10:29:06 -07:00
Travis Abendshien 09a54d5dfc fix(ui): remove blank action for field suggest box 2026-08-14 10:16:44 -07:00
Travis Abendshien 7782d2438f ui: add unique icon for applied tags in suggest box 2026-08-14 10:13:56 -07:00
Travis Abendshien 92e29635c7 ui: show edges of next tags in suggest box 2026-08-14 10:12:00 -07:00
Travis Abendshien 6e288afd8c refactor: consolidate added item detection on shift 2026-08-14 08:49:35 -07:00
Travis Abendshien 354c0a0afc fix(ui): increase spacing between color groups 2026-08-14 00:49:42 -07:00
Travis Abendshien dd00f4d4a0 fix: move add_colors() out of double session
The double session started causing a lock on the DB that prevented new colors from being added.
2026-08-14 00:22:08 -07:00
Travis Abendshien 1bfea60d7a fix(ui): remove blank action on suggest boxes 2026-08-13 22:47:17 -07:00
Travis Abendshien c015b1d4f2 fix(ui): shifting no longer resets opacity of added tags 2026-08-13 21:57:13 -07:00
Travis Abendshien 4b200a674e feat(ui): add hint icons to suggest boxes (#1475)
* feat(ui): add hint icons to suggest boxes

* docs: directly mention new icons
2026-08-13 20:22:52 -07:00
Travis Abendshien 507823bfcd feat(ui): use tab and shift+tab to navigate suggest boxes (#1474)
* fix(ui): correct suggest box content padding

* feat(ui): use tab and shift+tab to navigate suggest boxes

* fix: strip suggest box queries of whitespace

* fix(ui): fix shift opacity not targeting correct widget

* chore: cleanup comments and variable usage
2026-08-13 19:44:36 -07:00
Travis Abendshien 3def6c5239 feat(ui): translate vertical scrolling to horizontal in suggest boxes (#1473) 2026-08-13 19:44:21 -07:00
Travis Abendshien f04421fa93 feat: keep suggest boxes open by default (#1472) 2026-08-13 19:44:05 -07:00
Jann Stute b29e612e7f refactor: almost final migrations cleanup (#1456)
* fix: backup library before making any changes

* refactor: inline make_tables + minor cleanup

* refactor: remove unnecessary assurance

Bumping the auto increment value has been done since the original sql PR, so it doesn't need to be done on migrations.
See e5e7b8afc6.

* refactor: don't blindly create all tables in the beginning

The only table that has been added since DB version 6 (the earliest supported version), is the versions table in DB version 101.
This commit removes the "create all tables" statement, and instead creates the versions table in the 101 migration.
See 12e074b71d.

* refactor: don't require setting library_dir to create a backup

* refactor: move migrations to different file

* refactor: package each migration in a class

* fix: some syntax errors had slipped through

* fix: allow set_version to fail, but don't commit in that case

* refactor: condense imports

* fix: add override decorators

* refactor: move set_version to DBMigrations

* refactor: remove unnecessary assignment

* refactor: use _ instead of __

* fix: add missing field templates tables
2026-08-11 15:14:33 -07:00
182 changed files with 2626 additions and 1482 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ runs:
if: inputs.skip-setup != 'true'
uses: actions/setup-python@v6
with:
python-version: '3.12'
python-version: '3.14'
- name: Setup uv install
if: inputs.skip-setup != 'true'
+5
View File
@@ -4,6 +4,11 @@
repos:
- repo: local
hooks:
- id: reuse
name: reuse
entry: reuse lint-file
language: system
- id: pyright
name: pyright
entry: pyright
+8 -1
View File
@@ -45,6 +45,13 @@ SPDX-FileCopyrightText = "(c) 2026 Boxicons"
SPDX-License-Identifier = "MIT"
[[annotations]]
path = ["src/tagstudio/resources/qt/images/dupe_file_stat.png"]
path = [
"src/tagstudio/resources/qt/images/dupe_file_stat.png",
"src/tagstudio/resources/qt/images/hint_field_add.png",
"src/tagstudio/resources/qt/images/hint_field_create.png",
"src/tagstudio/resources/qt/images/hint_tag_add.png",
"src/tagstudio/resources/qt/images/hint_tag_added.png",
"src/tagstudio/resources/qt/images/hint_tag_create.png",
]
SPDX-FileCopyrightText = "(c) github:google/material-design-icons Contributors"
SPDX-License-Identifier = "Apache-2.0"
+1 -1
View File
@@ -5,7 +5,7 @@
"name": "TagStudio",
"type": "python",
"request": "launch",
"program": "${workspaceRoot}/src/tagstudio/main.py",
"program": "${workspaceRoot}/src/tagstudio/__main__.py",
"console": "integratedTerminal",
"justMyCode": true,
"args": ["-o", "~/Documents/Example"]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 12 KiB

+38
View File
@@ -9,6 +9,44 @@ toc_depth: 2
# :material-script-text: Changelog
## 9.6.3 <small>August 15th, 2026</small>
This update includes some critical library bugfixes along with a handful QoL tweaks and additions to the tag/field search bars. The [documentation](https://docs.tagstud.io/usage/#tagging) on this feature has been updated to include the new improvements.
### Added
- feat(ui): show library format version in "About" window by @CyanVoxel in 102cfdf4a2e12635f4b6e47259aa23ea95787421
#### Tag and Field Bars
- feat(ui): keep tag/field search bars open by default by @CyanVoxel in #1472
- feat(ui): translate vertical scrolling to horizontal in tag/field search bars by @CyanVoxel in #1473
- feat(ui): use tab and shift+tab to navigate tag/field search bars by @CyanVoxel in #1474
- feat(ui): add hint icons to tag/field search bars by @CyanVoxel in #1475
### Changed
#### Internal Changes
- refactor: almost final migrations cleanup by @Computerdores in #1456
#### Translations
- **French** updated by @kitsumed
- **Hebrew** updated by @Tatrask
- **Hungarian** updated by @smileyhead
- **Japanese** updated by @coolzdaddy55-art
### Fixed
- fix: fix being unable to create new colors by @CyanVoxel in dd00f4d4a02fd74b24d40c51b55ae3cc7db09203
- fix(ui): increase spacing between color palettes by @CyanVoxel in 354c0a0afcdaae2e490dd6c1eca5a556e7724aea
- fix: fix infinite loading in various refresh operations by @CyanVoxel in 3ce65552f7d459a2edebadb29cf1c17de6a1c742
- fix(ui): fix update popup showing after closing main window by @CyanVoxel in 30c81257fa3882376111ec03637bd6f517500908
- fix(ui): fix accent color issues on Windows by @CyanVoxel in #1477
---
## 9.6.2 <small>August 6th, 2026</small>
### Added
+3 -3
View File
@@ -16,7 +16,7 @@ If you wish to develop for TagStudio, you'll need to create a development enviro
## Installing Python
Python [3.12](https://www.python.org/downloads) is required to develop for TagStudio. Any version matching "Python 3.12.x" should work, with "x" being any number. Alternatively you can use a tool such as [pyenv](https://github.com/pyenv/pyenv) to install this version of Python without affecting any existing Python installations on your system. Tools such as [uv](#installing-with-uv) can also install Python versions.
Python [3.14](https://www.python.org/downloads) is required to develop for TagStudio. Any version matching "Python 3.14.x" should work, with "x" being any number. Alternatively you can use a tool such as [pyenv](https://github.com/pyenv/pyenv) to install this version of Python without affecting any existing Python installations on your system. Tools such as [uv](#installing-with-uv) can also install Python versions.
<!-- prettier-ignore -->
!!! info "Python Aliases"
@@ -35,8 +35,8 @@ python --version
If you choose to install Python using pyenv, please refer to the following instructions:
1. Follow pyenv's [install instructions](https://github.com/pyenv/pyenv/?tab=readme-ov-file#installation) for your system.
2. Install the appropriate Python version with pyenv by running `pyenv install 3.12` (This will **not** mess with your existing Python installation).
3. Navigate to the repository root folder in your terminal and run `pyenv local 3.12`. You could alternatively use `pyenv shell 3.12` or `pyenv global 3.12` instead to set the Python version for the current terminal session or the entire system respectively, however using `local` is recommended.
2. Install the appropriate Python version with pyenv by running `pyenv install 3.14` (This will **not** mess with your existing Python installation).
3. Navigate to the repository root folder in your terminal and run `pyenv local 3.14`. You could alternatively use `pyenv shell 3.14` or `pyenv global 3.14` instead to set the Python version for the current terminal session or the entire system respectively, however using `local` is recommended.
---
+2 -2
View File
@@ -16,11 +16,11 @@ File entry data is stored within the `ts_library.sqlite` file inside each librar
## Appearance
File entries appear as thumbnails inside the grid display. The preview panel shows a more detailed preview of the file, along with extra file stats and all attached TagStudio tags and fields.
File entries appear as thumbnails inside the grid display. The inspector shows a more detailed preview of the file, along with extra file stats and all attached TagStudio tags and fields.
## Unlinked Entries
If the file that an entry is referencing has been moved, renamed, or deleted on disk, then TagStudio will display its unlinked status with a red chain-link icon instead of its thumbnail image. Certain uncached stats such as the file size and image dimensions will also be unavailable to see in the preview panel.
If the file that an entry is referencing has been moved, renamed, or deleted on disk, then TagStudio will display its unlinked status with a red chain-link icon instead of its thumbnail image. Certain uncached stats such as the file size and image dimensions will also be unavailable to see in the inspector.
To fix file entries that have become unlinked, select the "Fix Unlinked Entries" option from the Tools menu. From there, refresh the unlinked entry count and choose whether to search and relink you files, and/or delete the file entries from your library. This will NOT delete or modify any files on disk.
+1 -1
View File
@@ -12,7 +12,7 @@ Fields are extra pieces of information you can add to [file entries](./entries.m
Unlike tags, fields are based on [templates](#templates) that contain pre-filled information such as the field type and title, and that information is _copied_ to fields when adding them to entries. Editing field information on entries **does not** modify the template it was created from.
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**.
To add a field to an entry, click the "Add Field" button at the bottom of the inspector. 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**.
<figure markdown="span">
![Fields Example](assets/fields_example.png)
+1 -1
View File
@@ -36,7 +36,7 @@ TagStudio has builds for :fontawesome-brands-windows: **Windows**, :fontawesome-
### :fontawesome-brands-python: Installing with PIP
TagStudio is installable via [PIP](https://pip.pypa.io/). Note that since we don't currently distribute on PyPI, the repository needs to be cloned and installed locally. Make sure you have Python 3.12 and PIP installed if you choose to install using this method.
TagStudio is installable via [PIP](https://pip.pypa.io/). Note that since we don't currently distribute on PyPI, the repository needs to be cloned and installed locally. Make sure you have Python 3.14 and PIP installed if you choose to install using this method.
The repository can be cloned/downloaded via `git` in your terminal, or by downloading the zip file from the "Code" button on the [repository page](https://github.com/TagStudioDev/TagStudio).
+17
View File
@@ -201,3 +201,20 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
| 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.
#### Version 300
| Added in Commit | Introduced in Release | Format |
| ---------------------------------------- |-------------------------------------------------------------------------| ------ |
| 51a9c16f50ca785d810911d2d0c83fa33eb1c0ae | [v9.6.2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.6.2) | SQLite |
- Drops `folder` columns from the `entries` table.
- Drops the unused `folders` table.
#### Version 400
| Added in Commit | Introduced in Release | Format |
|-----------------|-----------------------| ------ |
| TBD | TBD | SQLite |
- Adds the `category_exclusion` table.
+2 -2
View File
@@ -203,8 +203,8 @@ Discrete library objects representing [attributes](<https://en.wikipedia.org/wik
- [ ] 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] Property available for tags that allow the tag and any inheriting from it to be displayed separately in the inspector under a title
- [x] 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)]**
+4 -3
View File
@@ -50,7 +50,7 @@ TagStudio provides an [EditorConfig](https://editorconfig.org/#example-file) fil
### :material-tag: TagStudio Systems
- Translation keys can be accessed via bracket notation (e.g. `Translations["translation_key"]`) or with the `Translations.format()` method when a value needs to be passed to a placeholder in the translation.
- Avoid passing around the `QtDriver` class where possible. Instead, pass only the necessary components such as the `Library` and `GlobalSettings` instances.
- Avoid passing around the `QtDriver` class where possible. Instead, pass only the necessary components such as the `Library` and `AppSettings` instances.
- Use HTML-like tags inside strings over explicit stylesheets where possible. The `Style` class provides several handy methods for formatting text with these.
- Use the `format` method in the stylesheets class to format text headers.
@@ -138,7 +138,7 @@ qt/
│ # Frontend classes that aren't related to widgets, like managers
├── resource_manager.py
├── cache_manager.py
├── ts_qt.py # Qt Driver
├── qt_driver.py
└── ...
```
@@ -176,7 +176,8 @@ Observe the following key aspects of the example below:
def _connect_callbacks(self):
self.layout().button.clicked.connect(self._button_click_callback)
self.layout().color_dropdown.currentIndexChanged.connect(
lambda idx: self._color_dropdown_callback(self.color_dropdown.itemData(idx)))
lambda idx: self._color_dropdown_callback(self.color_dropdown.itemData(idx))
)
def _button_click_callback(self):
print("Button was clicked!")
+3 -1
View File
@@ -100,12 +100,14 @@ Properties are special attributes of tags that change their behavior in some way
#### Is Category
The "Is Category" property of tags determines if a tag should be treated as a category itself when being organized inside the preview panel. If this tag or any tags inheriting from this tag (i.e. tags that have this tag as a "[Parent Tag](#parent-tags)"), then these tags will appear under a separated group that's named after this tag. Tags inheriting from multiple "category tags" will still show up under any applicable category.
The "Is Category" property of tags determines if a tag should be treated as a category itself when being organized inside the inspector. If this tag or any tags inheriting from this tag (i.e. tags that have this tag as a "[Parent Tag](#parent-tags)"), then these tags will appear under a separated group that's named after this tag. Tags inheriting from multiple "category tags" will still show up under any applicable category.
This means that duplicates of tags can appear on entries if the tag inherits from multiple parent categories, however this is by design and reflects the nature of multiple inheritance. Any tags not inheriting from a category tag will simply show under a default "Tag" section.
![Tag Category Example](assets/tag_categories_example.png)
If you don't want a tag to appear in one, more, or even all the applicable categories, simply uncheck the category in the "Edit Tag" panel.
### Built-In Tags and Categories
The built-in tags "Favorite" and "Archived" inherit from the built-in "Meta Tags" category which is marked as a category by default. This behavior of default tags can be fully customized by disabling the category option and/or by adding/removing the tags' Parent Tags.
+20 -10
View File
@@ -24,9 +24,9 @@ TagStudio automatically scans for new or updated files when opening a library by
## :material-tag-text: Tagging
With one or more file entries selected, you can **search for** or **create a new tag** by clicking the "Add Tag" button at the bottom of the preview panel or by pressing <kbd>Ctrl</kbd>+<kbd>T</kbd>.
With one or more file entries selected, you can **search for** or **create a new tag** by clicking the "Add Tag" button at the bottom of the inspector or by pressing <kbd>Ctrl</kbd>+<kbd>T</kbd>.
This will open the the **"Tag Bar"**, a combination search/creation bar that is designed to allow you to quickly create and apply tags that don't exist yet, find and apply existing tags in your library, and verify if certain tags already exist and/or are applied to your selection.
This will open the the **"Tag Bar"**, a combination search/creation bar that is designed to allow you to quickly create and apply tags that don't exist yet, find and apply existing tags from your library, and verify if certain tags already exist and/or are applied to your selection.
Every step is keyboard-friendly, allowing for an efficient tagging "flow state" when it comes to creating and applying tags.
@@ -34,7 +34,11 @@ Every step is keyboard-friendly, allowing for an efficient tagging "flow state"
![Empty File Entry](assets/tag_field_bars/add_buttons_normal.png){ width=80% }
<br>
![Empty File Entry](assets/tag_field_bars/tag_bar_empty.png){ width=80% }
<figcaption>Clicking "Add Tag" or pressing <kbd>Ctrl</kbd>+<kbd>T</kbd> replaces the "Add" buttons with a tag search bar. Pressing <kbd>Esc</kbd> (or <kbd>Enter</kbd> with a blank search) will close the bar and return the "Add" buttons.</figcaption>
<figcaption>
Clicking "Add Tag" or pressing <kbd>Ctrl</kbd>+<kbd>T</kbd> replaces the "Add" buttons with a tag search bar.
<br>
You can close the search by pressing <kbd>Enter</kbd> or clicking away (with a blank search), or at any time by pressing <kbd>Esc</kbd>.
</figcaption>
</figure>
<!-- prettier-ignore -->
@@ -43,7 +47,7 @@ Every step is keyboard-friendly, allowing for an efficient tagging "flow state"
### :material-tag-plus: Tag Bar: Creating Tags
Type to search for an existing tag, or use that search term to quickly create and apply a new tag to your selection. Pressing <kbd>Enter</kbd> with **no results** will **create** a tag from your search query and apply it to the selected entries.
Type to search for an existing tag, or use that search term to quickly create and apply a new tag to your selection. Pressing <kbd>Enter</kbd> with **no results** will **create** :material-tag-plus-outline: a tag from your search query and apply it to the selected entries.
After applying a tag, the "Add Tag" button is now focused, allowing you to press <kbd>Enter</kbd> or <kbd>Space</kbd> to quickly reactivate the bar. Alternatively, you may edit your new tag by right-clicking the tag and selecting **"Edit"**.
@@ -62,9 +66,11 @@ Tags can be also viewed, created, edited, or deleted from the **Edit -> Manage T
### :material-tag-search: Tag Bar: Searching for Tags
Pressing <kbd>Enter</kbd> with **one or more results** will **apply the first tag** to your selection (assuming it's not already on your selection).
Pressing <kbd>Enter</kbd> with **one or more results** will **apply** :material-tag: the underlined tag to your selection (assuming it's not already on your selection).
Tags that are already present on your entries will be greyed out and ignored when pressing <kbd>Enter</kbd>. This lets you visually confirm that a tag already exists and has been applied to the selection.
Matching tags that are **already applied** :material-tag-check: to your entries will be greyed out placed at the end of the autofill results. This lets you visually confirm that a tag already exists and has been applied to the selection. Applying them again with <kbd>Enter</kbd> has no effect other than closing the search.
You can navigate the list of autofill suggestions using the scroll wheel, and change the selected tag by pressing <kbd>Tab</kbd> or <kbd>Shift</kbd>+<kbd>Tab</kbd> to navigate right and left, respectively.
<figure markdown="span">
![Empty File Entry](assets/tag_field_bars/tag_bar_search_match.png){ width=80% }
@@ -75,13 +81,17 @@ Tags that are already present on your entries will be greyed out and ignored whe
#### :material-tag-arrow-up: Force Create Tag
Holding <kbd>Shift</kbd> while pressing <kbd>Enter</kbd> will **force create** a tag from the search bar text, regardless of the results. This is indicated by the first tag in the results becoming greyed out. This is especially useful when you need to create a new tag with a name that partially overlaps with an existing tag's name.
Holding <kbd>Shift</kbd> while pressing <kbd>Enter</kbd> will **force create** :material-tag-plus-outline: a tag from the search bar text, regardless of the search results. This is especially useful when you need to create a new tag with a name that partially overlaps with an existing tag's name.
<figure markdown="span">
![Empty File Entry](assets/tag_field_bars/tag_bar_shift_off.png){ width=80% }
<figcaption>Pressing <kbd>Enter</kbd> will apply the underlined tag, "Cube".</figcaption>
<figcaption markdown="span">Pressing <kbd>Enter</kbd> will **apply** :material-tag: the underlined tag, "Cube".</figcaption>
![Empty File Entry](assets/tag_field_bars/tag_bar_shift_on.png){ width=80% }
<figcaption>With <kbd>Shift</kbd> held, pressing <kbd>Enter</kbd> will <b>create a new tag</b> called "Cub".</figcaption>
<figcaption markdown="span">
<kbd>Shift</kbd>+<kbd>Enter</kbd> will **create** :material-tag-plus-outline: a new tag called "Cub".
<br>
Note the the greyed-out tag, showing it will not be applied.
</figcaption>
</figure>
---
@@ -90,7 +100,7 @@ Holding <kbd>Shift</kbd> while pressing <kbd>Enter</kbd> will **force create** a
[Fields](./fields.md) are extra pieces of information you can add to file entries, such as titles, comments, notes, specific dates or times. Unlike tags, fields are based on [templates](./fields.md#field-templates) that contain pre-filled information such as the field type and title, and that information is _copied_ to fields when adding them to entries. Editing field information on entries **does not** modify the template it was created from.
Creating and adding fields to entries is extremely similar to [how it works for tags](#tagging), with a few notable differences:
Creating and adding fields to entries is extremely similar to how [tagging](#tagging) works, with a few notable differences:
- Click "Add Field" or press <kbd>Ctrl</kbd>+<kbd>L</kbd> to open the field bar.
- The same template can by applied to an entry any number of times.
+39 -20
View File
@@ -29,35 +29,54 @@
systems = import inputs.systems;
perSystem =
{ pkgs, ... }:
{ pkgs, self', ... }:
let
python3 = pkgs.python313;
python3 = builtins.head python3Versions;
python3Versions = with pkgs; [
python313
python312
];
in
{
packages =
let
python3Packages = python3.pkgs;
pythonDerivations = lib.genAttrs' python3Versions (
python3:
lib.nameValuePair python3.pythonAttr (
let
python3Packages = python3.pkgs;
pillow-jxl-plugin = python3Packages.callPackage ./nix/package/pillow-jxl-plugin.nix {
inherit (pkgs) cmake;
inherit pyexiv2;
};
pyexiv2 = python3Packages.callPackage ./nix/package/pyexiv2.nix { inherit (pkgs) exiv2; };
tagstudio = pkgs.callPackage ./nix/package {
inherit python3Packages;
inherit pillow-jxl-plugin;
};
pillow-jxl-plugin = python3Packages.callPackage ./nix/package/pillow-jxl-plugin.nix {
inherit (pkgs) cmake;
inherit pyexiv2;
};
pyexiv2 = python3Packages.callPackage ./nix/package/pyexiv2.nix { inherit (pkgs) exiv2; };
in
{
inherit tagstudio;
tagstudio-jxl = tagstudio.override { withJXLSupport = true; };
inherit pillow-jxl-plugin pyexiv2;
}
)
);
in
rec {
default = tagstudio;
tagstudio = pkgs.callPackage ./nix/package {
inherit python3Packages;
inherit pillow-jxl-plugin;
};
tagstudio-jxl = tagstudio.override { withJXLSupport = true; };
inherit pillow-jxl-plugin pyexiv2;
(lib.concatMapAttrs (
pythonAttr: lib.mapAttrs' (name: lib.nameValuePair "${pythonAttr}Packages_${name}")
) pythonDerivations)
// pythonDerivations.${python3.pythonAttr}
// {
default = self'.packages.tagstudio;
};
devShells = rec {
default = tagstudio;
devShells = {
default = self'.devShells.tagstudio;
tagstudio = import ./nix/shell.nix {
inherit
inputs
+1 -1
View File
@@ -96,7 +96,6 @@ python3Packages.buildPythonApplication {
dependencies =
with python3Packages;
[
audioop-lts
chardet_5
ffmpeg-python
humanfriendly
@@ -121,6 +120,7 @@ python3Packages.buildPythonApplication {
ujson
wcmatch
]
++ lib.optional (pythonAtLeast "3.13") audioop-lts
++ lib.optional withJXLSupport pillow-jxl-plugin;
# These tests require modifications to a library, which does not work
+1 -1
View File
@@ -118,7 +118,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 . --group docs --group extra --group test
uv pip install --quiet --editable . --group docs --group extra --group reuse --group test
cp pyproject.toml "''${venv}"/pyproject.toml
fi
+14 -9
View File
@@ -9,12 +9,12 @@ build-backend = "hatchling.build"
[project]
name = "TagStudio"
description = "A User-Focused Photo & File Management System."
version = "9.6.2"
version = "9.6.4"
license = "GPL-3.0-only"
readme = "README.md"
requires-python = ">=3.12,<3.14"
requires-python = ">=3.14,<3.15"
dependencies = [
"audioop-lts; python_version >= '3.13'",
"audioop-lts~=0.2.2",
"chardet~=5.2",
"ffmpeg-python~=0.2",
"humanfriendly==10.*",
@@ -22,12 +22,12 @@ dependencies = [
"numpy~=2.2",
"opencv_python~=4.11",
"Pillow>=10.2,<12",
"pillow-heif~=0.22",
"pillow-heif~=1.5.0",
"pillow-jxl-plugin~=1.3",
"py7zr~=1.1.3",
"pydantic~=2.10",
"pydub~=0.25",
"PySide6==6.8.0.*",
"PySide6==6.11.2",
"rarfile==4.2",
"rawpy~=0.27",
"Send2Trash>=1.8,<3",
@@ -43,7 +43,7 @@ dependencies = [
]
[project.gui-scripts]
tagstudio = "tagstudio.main:main"
tagstudio = "tagstudio.__main__:main"
[dependency-groups]
all = [
@@ -57,7 +57,11 @@ 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" }]
lint = [
{ include-group = "pyright" },
{ include-group = "reuse" },
{ include-group = "ruff" },
]
test = [{ include-group = "pytest" }]
mkdocs = [
@@ -75,6 +79,7 @@ pytest = [
"pytest-qt==4.4.0",
"syrupy==5.1.0",
]
reuse = ["reuse==6.*"]
ruff = ["ruff==0.15.17"]
[tool.hatch.build.targets.wheel]
@@ -92,7 +97,7 @@ filterwarnings = [
ignore = [
".venv/**",
"src/tagstudio/core/library/json/",
"src/tagstudio/renderers/vendored/pydub/",
"src/tagstudio/previews/vendored/pydub/",
]
include = ["src/tagstudio", "tests"]
# Reference for the settings here: https://github.com/microsoft/pyright/blob/main/docs/configuration.md
@@ -121,7 +126,7 @@ ignore = ["D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107"]
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["D", "E402"]
"src/tagstudio/renderers/vendored/**" = ["B", "E", "N", "UP", "SIM115"]
"src/tagstudio/previews/vendored/**" = ["B", "E", "N", "UP", "SIM115"]
[tool.ruff.lint.pydocstyle]
convention = "google"
+1 -1
View File
@@ -41,7 +41,7 @@ datafiles = [
]
a = Analysis(
[Path(project_root, "main.py")],
[Path(project_root, "__main__.py")],
pathex=[],
binaries=[],
datas=datafiles,
+66 -3
View File
@@ -1,9 +1,72 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-License-Identifier: MIT
from tagstudio.main import main
"""TagStudio launcher."""
import argparse
import sys
import traceback
import structlog
from tagstudio.core.constants import BUILD_TYPE, VERSION
from tagstudio.i18n.translations import Translations
from tagstudio.qt.qt_driver import QtDriver
logger = structlog.get_logger(__name__)
def main():
parser = argparse.ArgumentParser()
parser.add_argument(
"-o",
"--open",
dest="open",
type=str,
help="Path to a TagStudio Library folder to open on start.",
)
parser.add_argument(
"-s",
"--settings-file",
dest="settings_file",
type=str,
help="Path to a TagStudio .toml global settings file to use.",
)
parser.add_argument(
"-c",
"--cache-file",
dest="cache_file",
type=str,
help="Path to a TagStudio .ini or .plist cache file to use.",
)
parser.add_argument(
"--debug",
dest="debug",
action="store_true",
help="Reveals additional internal data useful for debugging.",
)
parser.add_argument(
"-v",
"--version",
action="version",
help="Displays TagStudio version information.",
version=f"TagStudio v{VERSION} {Translations[BUILD_TYPE] if BUILD_TYPE else ''}",
)
args = parser.parse_args()
driver = QtDriver(args)
ui_name = "Qt"
# Run the chosen frontend driver.
try:
driver.start()
except Exception:
traceback.print_exc()
logger.info(f"\nTagStudio Frontend ({ui_name}) Crashed! Press Enter to Continue...")
input()
if __name__ == "__main__":
main()
sys.exit(main())
+4 -4
View File
@@ -10,16 +10,16 @@ from PySide6.QtCore import QSettings
from tagstudio.core.constants import TS_FOLDER_NAME
from tagstudio.core.enums import AppCacheItems
from tagstudio.core.library.alchemy.library import LibraryStatus
from tagstudio.qt.global_settings import GlobalSettings
from tagstudio.qt.app_settings import AppSettings
logger = structlog.get_logger(__name__)
# TODO: Turn into a BaseDriver class instead of a "Mixin".
class DriverMixin:
cached_values: QSettings
# TODO: GlobalSettings has become closely tied to Qt.
# Should there be a base Settings class?
settings: GlobalSettings
# TODO: AppSettings is Qt-specific and should not be in a base driver class.
settings: AppSettings
def evaluate_path(self, open_path: str | None) -> LibraryStatus:
"""Check if the path of library is valid."""
@@ -4,19 +4,24 @@
from sqlalchemy import text
from tagstudio.core.library.alchemy.fields import (
DatetimeFieldTemplate,
TextFieldTemplate,
)
SQL_FILENAME: str = "ts_library.sqlite"
JSON_FILENAME: str = "ts_library.json"
DB_VERSION_CURRENT_KEY: str = "CURRENT"
DB_VERSION_INITIAL_KEY: str = "INITIAL"
DB_VERSION: int = 300
DB_VERSION: int = 400
TAG_CHILDREN_QUERY = text("""
WITH RECURSIVE ChildTags AS (
SELECT :tag_id AS tag_id
UNION
SELECT tp.child_id AS tag_id
FROM tag_parents tp
FROM tag_parents tp
INNER JOIN ChildTags c ON tp.parent_id = c.tag_id
)
SELECT * FROM ChildTags;
@@ -32,3 +37,15 @@ WITH RECURSIVE ChildTags AS (
)
SELECT tag_id FROM ChildTags;
""")
DEFAULT_FIELD_TEMPLATES = (
TextFieldTemplate(name="Title"),
TextFieldTemplate(name="Author"),
TextFieldTemplate(name="Artist"),
TextFieldTemplate(name="URL"),
TextFieldTemplate(name="Description", is_multiline=True),
TextFieldTemplate(name="Notes", is_multiline=True),
TextFieldTemplate(name="Comments", is_multiline=True),
DatetimeFieldTemplate(name="Date"),
)
+1 -39
View File
@@ -6,12 +6,9 @@ from pathlib import Path
from typing import override
import structlog
from sqlalchemy import Dialect, Engine, String, TypeDecorator, create_engine, text
from sqlalchemy.exc import OperationalError
from sqlalchemy import Dialect, String, TypeDecorator
from sqlalchemy.orm import DeclarativeBase
from tagstudio.core.constants import RESERVED_TAG_END
logger = structlog.getLogger(__name__)
@@ -34,38 +31,3 @@ class PathType(TypeDecorator):
class Base(DeclarativeBase):
type_annotation_map = {Path: PathType}
def make_engine(connection_string: str) -> Engine:
return create_engine(connection_string)
def make_tables(engine: Engine) -> None:
logger.info("[Library] Creating DB tables...")
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:
try:
conn.execute(
text(
"INSERT INTO tags "
"(id, name, color_namespace, color_slug, is_category, is_hidden) VALUES "
f"({RESERVED_TAG_END}, 'temp', NULL, NULL, false, false)"
)
)
conn.execute(text(f"DELETE FROM tags WHERE id = {RESERVED_TAG_END}"))
conn.commit()
except OperationalError as e:
logger.error("Could not initialize built-in tags", error=e)
conn.rollback()
+13 -15
View File
@@ -57,7 +57,7 @@ class TagColorEnum(enum.IntEnum):
OLIVE = 37
@staticmethod
def get_color_from_str(color_name: str) -> "TagColorEnum":
def get_color_from_str(color_name: str) -> TagColorEnum:
for color in TagColorEnum:
if color.name == color_name.upper().replace(" ", "_"):
return color
@@ -99,17 +99,15 @@ class BrowsingState:
return Parser(self.query).parse()
@classmethod
def show_all(cls) -> "BrowsingState":
def show_all(cls) -> BrowsingState:
return BrowsingState()
@classmethod
def from_search_query(cls, search_query: str) -> "BrowsingState":
def from_search_query(cls, search_query: str) -> BrowsingState:
return cls(query=search_query)
@classmethod
def from_tag_id(
cls, tag_id: int | str, state: "BrowsingState | None" = None
) -> "BrowsingState":
def from_tag_id(cls, tag_id: int | str, state: BrowsingState | None = None) -> BrowsingState:
"""Create and return a BrowsingState object given a tag ID.
Args:
@@ -124,35 +122,35 @@ class BrowsingState:
return cls(query=f"tag_id:{str(tag_id)}")
@classmethod
def from_path(cls, path: Path | str) -> "BrowsingState":
def from_path(cls, path: Path | str) -> BrowsingState:
return cls(query=f'path:"{str(path).strip()}"')
@classmethod
def from_mediatype(cls, mediatype: str) -> "BrowsingState":
def from_mediatype(cls, mediatype: str) -> BrowsingState:
return cls(query=f"mediatype:{mediatype}")
@classmethod
def from_filetype(cls, filetype: str) -> "BrowsingState":
def from_filetype(cls, filetype: str) -> BrowsingState:
return cls(query=f"filetype:{filetype}")
@classmethod
def from_tag_name(cls, tag_name: str) -> "BrowsingState":
def from_tag_name(cls, tag_name: str) -> BrowsingState:
return cls(query=f'tag:"{tag_name}"')
def with_page_index(self, index: int) -> "BrowsingState":
def with_page_index(self, index: int) -> BrowsingState:
return replace(self, page_index=index)
def with_sorting_mode(self, mode: SortingModeEnum) -> "BrowsingState":
def with_sorting_mode(self, mode: SortingModeEnum) -> BrowsingState:
seed = self.random_seed
if mode == SortingModeEnum.RANDOM:
seed = random.random()
return replace(self, sorting_mode=mode, random_seed=seed)
def with_sorting_direction(self, ascending: bool) -> "BrowsingState":
def with_sorting_direction(self, ascending: bool) -> BrowsingState:
return replace(self, ascending=ascending)
def with_search_query(self, search_query: str) -> "BrowsingState":
def with_search_query(self, search_query: str) -> BrowsingState:
return replace(self, query=search_query)
def with_show_hidden_entries(self, show_hidden_entries: bool) -> "BrowsingState":
def with_show_hidden_entries(self, show_hidden_entries: bool) -> BrowsingState:
return replace(self, show_hidden_entries=show_hidden_entries)
@@ -20,3 +20,10 @@ class TagEntry(Base):
tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True)
entry_id: Mapped[int] = mapped_column(ForeignKey("entries.id"), primary_key=True)
class CategoryExclusion(Base):
__tablename__ = "category_exclusions"
tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True)
category_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True)
+106 -499
View File
@@ -2,11 +2,6 @@
# SPDX-License-Identifier: GPL-3.0-only
# NOTE: This file contains necessary use of deprecated first-party code until that
# code is removed in a future version (prefs).
# pyright: reportDeprecated=false
import re
import shutil
import sys
@@ -19,9 +14,7 @@ from os import makedirs
from pathlib import Path
from typing import TYPE_CHECKING
import sqlalchemy
import structlog
import ujson
from humanfriendly import format_timespan # pyright: ignore[reportUnknownVariableType]
from sqlalchemy import (
URL,
@@ -44,7 +37,7 @@ from sqlalchemy import (
update,
)
from sqlalchemy.dialects import sqlite
from sqlalchemy.exc import IntegrityError
from sqlalchemy.exc import IntegrityError, OperationalError
from sqlalchemy.orm import (
InstanceState,
Session,
@@ -72,16 +65,13 @@ from tagstudio.core.library.alchemy.constants import (
DB_VERSION,
DB_VERSION_CURRENT_KEY,
DB_VERSION_INITIAL_KEY,
DEFAULT_FIELD_TEMPLATES,
JSON_FILENAME,
SQL_FILENAME,
TAG_CHILDREN_QUERY,
)
from tagstudio.core.library.alchemy.db import make_tables
from tagstudio.core.library.alchemy.enums import (
MAX_SQL_VARIABLES,
BrowsingState,
SortingModeEnum,
)
from tagstudio.core.library.alchemy.db import Base as ModelBase
from tagstudio.core.library.alchemy.enums import MAX_SQL_VARIABLES, BrowsingState, SortingModeEnum
from tagstudio.core.library.alchemy.fields import (
LEGACY_FIELD_MAP,
BaseField,
@@ -91,7 +81,8 @@ from tagstudio.core.library.alchemy.fields import (
TextField,
TextFieldTemplate,
)
from tagstudio.core.library.alchemy.joins import TagEntry, TagParent
from tagstudio.core.library.alchemy.joins import CategoryExclusion, TagEntry, TagParent
from tagstudio.core.library.alchemy.migrations import DBMigrations, MigrationError
from tagstudio.core.library.alchemy.models import (
Entry,
Namespace,
@@ -104,7 +95,6 @@ from tagstudio.core.library.alchemy.visitors import SQLBoolExpressionBuilder
from tagstudio.core.library.ignore import migrate_ext_list
from tagstudio.core.library.json.library import Library as JsonLibrary
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.translations import Translations
if TYPE_CHECKING:
from sqlalchemy import Select
@@ -170,20 +160,6 @@ def get_default_tags() -> tuple[Tag, ...]:
return archive_tag, favorite_tag, meta_tag
def get_default_field_templates() -> tuple[BaseFieldTemplate, ...]:
"""Return the default field templates for a new TagStudio library."""
title = TextFieldTemplate(name="Title")
author = TextFieldTemplate(name="Author")
artist = TextFieldTemplate(name="Artist")
url = TextFieldTemplate(name="URL")
description = TextFieldTemplate(name="Description", is_multiline=True)
notes = TextFieldTemplate(name="Notes", is_multiline=True)
comments = TextFieldTemplate(name="Comments", is_multiline=True)
date = DatetimeFieldTemplate(name="Date")
return title, author, artist, url, description, notes, comments, date
# The difference in the number of default JSON tags vs default tags in the current version.
DEFAULT_TAG_DIFF: int = len(get_default_tags()) - len([TAG_ARCHIVED, TAG_FAVORITE])
@@ -431,21 +407,41 @@ class Library:
self, library_dir: Path, in_memory: bool, sql_filename: str = SQL_FILENAME
) -> LibraryStatus:
self.engine = self.__get_engine(library_dir, in_memory, sql_filename)
loaded_db_version: int = 0
logger.info(
"[Library] Opening SQLite Library",
library_dir=library_dir,
)
logger.info(f"[Library] Library DB version: {loaded_db_version}")
make_tables(self.engine)
logger.info("[Library] Creating DB tables...")
with self.engine.connect() as conn:
ModelBase.metadata.create_all(conn)
conn.commit()
# TODO - find a better way
# is this the better way?
# Could we perhaps update the row we are reading from here?
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:
try:
conn.execute(
text(
"INSERT INTO tags "
"(id, name, color_namespace, color_slug, is_category, is_hidden) "
f"VALUES ({RESERVED_TAG_END}, 'temp', NULL, NULL, false, false)"
)
)
conn.execute(text(f"DELETE FROM tags WHERE id = {RESERVED_TAG_END}"))
conn.commit()
except OperationalError as e:
logger.error("Could not initialize built-in tags", error=e)
conn.rollback()
with Session(self.engine) as session:
# Add default tag color namespaces.
namespaces = default_color_groups.namespaces()
# TODO: are all of these commits necessary?
session.add_all(namespaces)
session.flush()
@@ -465,7 +461,7 @@ class Library:
session.flush()
# Add default field templates
for template in get_default_field_templates():
for template in DEFAULT_FIELD_TEMPLATES:
session.add(template)
session.flush()
@@ -506,414 +502,25 @@ class Library:
def open_sqlite_library(
self, library_dir: Path, in_memory: bool, sql_filename: str = SQL_FILENAME
) -> LibraryStatus:
logger.info("[Library] Opening SQLite Library", library_dir=library_dir)
self.engine = self.__get_engine(library_dir, in_memory, sql_filename)
loaded_db_version: int = 0
initial_db_version: int = DB_VERSION
logger.info(
"[Library] Opening SQLite Library",
library_dir=library_dir,
)
try:
migrations = DBMigrations(library_dir, self.engine)
# Don't check DB version when creating new library
loaded_db_version = self.get_version(DB_VERSION_CURRENT_KEY)
initial_db_version = self.get_version(DB_VERSION_INITIAL_KEY)
# save backup if patches will be applied
if migrations.required:
Library.save_library_backup_to_disk(library_dir)
# ======================== Library Database Version Checking =======================
# DB_VERSION 6 is the first supported SQLite DB version.
# If the DB_VERSION is >= 100, that means it's a compound major + minor version.
# - Dividing by 100 and flooring gives the major (breaking changes) version.
# - If a DB has major version higher than the current program, don't load it.
# - If only the minor version is higher, it's still allowed to load.
if loaded_db_version < 6 or (
loaded_db_version >= 100 and loaded_db_version // 100 > DB_VERSION // 100
):
mismatch_text = Translations["status.library_version_mismatch"]
found_text = Translations["status.library_version_found"]
expected_text = Translations["status.library_version_expected"]
return LibraryStatus(
success=False,
message=(
f"{mismatch_text}\n"
f"{found_text} v{loaded_db_version}, "
f"{expected_text} v{DB_VERSION}"
),
)
logger.info(f"[Library] Library DB version: {loaded_db_version}")
# TODO: this is very sketchy; blindly creating all tables the newest DB version should have
# without considering what version the DB is currently on and then doing all of the
# migrations after that seems like it could cause problems in some scenarios.
# instead only have this on creation and create new tables as part of migrations
# Note: this actually produces an error and fails to initialise built-in tags when opening
# a library that doesn't yet have the is_hidden property on the tags table
make_tables(self.engine)
# save backup if patches will be applied
if loaded_db_version < DB_VERSION:
self.library_dir = library_dir
self.save_library_backup_to_disk()
self.library_dir = None
# migrate DB step by step from one version to the next
# (migration_method, db_version, initial_db_version)
migrations = [
(self.__apply_db7_migration, 7, None), # changes: value_type, tags
(self.__apply_db8_migration, 8, None), # changes: tag_colors
(self.__apply_db9_migration, 9, None), # changes: entries
(self.__apply_db100_migration, 100, None), # changes: tag_parents
(self.__apply_db101_migration, 101, None), # changes: versions
(self.__apply_db102_migration, 102, None), # changes: tag_parents
(self.__apply_db103_migration, 103, None), # changes: tags
(self.__apply_db104_migration, 104, None), # changes: deletes preferences
(self.__apply_db200_migration, 200, None), # changes: field tables
(self.__apply_db201_migration, 201, 200), # changes: field tables
(self.__apply_db202_migration, 202, None), # changes: tag_parents
(self.__apply_db300_migration, 300, None), # changes: deletes folders
]
for migration, v, iv in migrations:
if loaded_db_version < v and (iv is None or initial_db_version < iv):
logger.info(f"[Library][Migration][{v}] Starting DB Migration")
with Session(self.engine) as session:
# any error causes transaction to rollback
migration(session, library_dir)
loaded_db_version = v
self.set_version(session, DB_VERSION_CURRENT_KEY, v)
session.commit()
logger.info(f"[Library][Migration][{v}] Completed DB Migration")
assert loaded_db_version >= DB_VERSION, (
"Ran all migrations, but the DB is still not on the newest version"
)
logger.info(f"[Library] Library migrated to DB version {DB_VERSION}")
migrations.run()
except MigrationError as e:
return LibraryStatus(success=False, message=e.args[0])
# everything is fine, set the library path
self.library_dir = library_dir
return LibraryStatus(success=True, library_path=library_dir)
def __apply_db7_migration(self, session: Session, _library_dir: Path):
"""Migrate DB from DB_VERSION 6 to 7."""
logger.info("[Library][Migration][7] Applying patches to DB_VERSION: 6 library...")
# Repair tags that may have a disambiguation_id pointing towards a deleted tag.
# TODO: combine into single sql statement
all_tag_ids = session.scalars(text("SELECT DISTINCT id FROM tags")).all()
disam_stmt = (
update(Tag)
.where(Tag.disambiguation_id.not_in(all_tag_ids))
.values(disambiguation_id=None)
)
session.execute(disam_stmt)
session.flush()
def __apply_db8_migration(self, session: Session, library_dir: Path):
"""Migrate DB from DB_VERSION 7 to 8."""
# Add the missing color_border column to the TagColorGroups table.
session.execute(
text("ALTER TABLE tag_colors ADD COLUMN color_border BOOLEAN DEFAULT FALSE NOT NULL")
)
session.flush()
logger.info("[Library][Migration][8] Added color_border column to tag_colors table")
# collect new default tag colors
tag_colors: list[TagColorGroup] = [
color
for color in default_color_groups.shades()
if color.slug in ["burgundy", "dark-teal", "dark_lavender"]
]
# Add any new default colors introduced in DB_VERSION 8
for color in tag_colors:
session.add(color)
session.flush()
logger.info(
"[Library][Migration][8] Migrated tag colors to DB_VERSION 8+",
color_name=tag_colors,
)
# Update Neon colors to use the the color_border property
for color in default_color_groups.neon():
neon_stmt = (
update(TagColorGroup)
.where(
and_(
TagColorGroup.namespace == color.namespace,
TagColorGroup.slug == color.slug,
)
)
.values(
slug=color.slug,
namespace=color.namespace,
name=color.name,
primary=color.primary,
secondary=color.secondary,
color_border=color.color_border,
)
)
session.execute(neon_stmt)
session.flush()
def __apply_db9_migration(self, session: Session, library_dir: Path):
"""Migrate DB from DB_VERSION 8 to 9."""
# Apply database schema changes
add_filename_column = text(
"ALTER TABLE entries ADD COLUMN filename TEXT NOT NULL DEFAULT ''"
)
session.execute(add_filename_column)
session.flush()
logger.info("[Library][Migration][9] Added filename column to entries table")
# Populate the new filename column.
for entry in self.__all_entries(session):
entry.filename = entry.path.name
session.merge(entry)
session.flush()
logger.info("[Library][Migration][9] Populated filename column in entries table")
def __apply_db100_migration(self, session: Session, library_dir: Path):
"""Migrate DB to DB_VERSION 100."""
# Repair parent-child tag relationships that are the wrong way around.
stmt = update(TagParent).values(
parent_id=TagParent.child_id,
child_id=TagParent.parent_id,
)
session.execute(stmt)
session.flush()
logger.info("[Library][Migration][100] Refactored TagParent table")
def __apply_db101_migration(self, session: Session, library_dir: Path):
"""Migrate DB to DB_VERSION 101."""
# Ensure version rows are present
session.add(Version(key=DB_VERSION_INITIAL_KEY, value=100))
session.flush()
def __apply_db102_migration(self, session: Session, library_dir: Path):
"""Migrate DB to DB_VERSION 102."""
# delete TagParents with a dangling parent reference
stmt = delete(TagParent).where(TagParent.parent_id.not_in(select(Tag.id).distinct()))
session.execute(stmt)
session.flush()
logger.info("[Library][Migration][102] Verified TagParent table data")
def __apply_db103_migration(self, session: Session, library_dir: Path):
"""Migrate DB from DB_VERSION 102 to 103."""
# add the new hidden column for tags
session.execute(text("ALTER TABLE tags ADD COLUMN is_hidden BOOLEAN NOT NULL DEFAULT 0"))
session.flush()
logger.info("[Library][Migration][103] Added is_hidden column to tags table")
# mark the "Archived" tag as hidden
session.query(Tag).filter(Tag.id == TAG_ARCHIVED).update({"is_hidden": True})
session.flush()
logger.info("[Library][Migration][103] Updated archived tag to be hidden")
def __apply_db104_migration(self, session: Session, library_dir: Path):
"""Migrate DB from DB_VERSION 103 to 104."""
# Convert file extension list to ts_ignore file, if a .ts_ignore file does not exist
self.__migrate_sql_to_ts_ignore(session, library_dir)
session.execute(text("DROP TABLE preferences"))
session.flush()
def __migrate_sql_to_ts_ignore(self, session: Session, library_dir: Path):
# Do not continue if existing '.ts_ignore' file is found
ts_ignore = library_dir / TS_FOLDER_NAME / IGNORE_NAME
if Path(ts_ignore).exists():
return
# Load legacy extension data
extensions: list[str] = ujson.loads(
unwrap(
session.scalar(text("SELECT value FROM preferences WHERE key = 'EXTENSION_LIST'"))
)
)
is_exclude_list: bool = unwrap(
session.scalar(text("SELECT value FROM preferences WHERE key = 'IS_EXCLUDE_LIST'"))
)
with open(ts_ignore, "w") as f:
f.write(migrate_ext_list(extensions, is_exclude_list))
def __apply_db200_migration(self, session: Session, library_dir: Path):
"""Migrate DB to DB_VERSION 200."""
# Drop unused 'boolean_fields' and 'value_type' tables
logger.info("[Library][Migration][200] Dropping boolean_fields and value_type tables...")
session.execute(text("DROP TABLE boolean_fields"))
session.execute(text("DROP TABLE value_type"))
# Add 'name' column to text_fields and datetime_fields tables
logger.info("[Library][Migration][200] Adding name columns to field tables...")
stmt = text('ALTER TABLE text_fields ADD COLUMN name VARCHAR DEFAULT ""')
session.execute(stmt)
stmt = text('ALTER TABLE datetime_fields ADD COLUMN name VARCHAR DEFAULT ""')
session.execute(stmt)
# Drop unnecessary 'position' columns
logger.info("[Library][Migration][200] Dropping position columns to field tables...")
session.execute(text("ALTER TABLE datetime_fields DROP COLUMN position"))
session.execute(text("ALTER TABLE text_fields DROP COLUMN position"))
# Add 'is_multiline' column to text_fields table
logger.info("[Library][Migration][200] Adding is_multiline column to text_fields...")
stmt = text("ALTER TABLE text_fields ADD COLUMN is_multiline BOOLEAN NOT NULL DEFAULT 0")
session.execute(stmt)
session.flush()
# Move values from old `type_key` columns into new `name` columns
logger.info("[Library][Migration][200] Moving values from type_key columns to name...")
session.execute(text("UPDATE text_fields SET name = type_key"))
session.execute(text("UPDATE datetime_fields SET name = type_key"))
session.flush()
# Change `name` values to title case
logger.info("[Library][Migration][200] Normalizing TextField names...")
for text_field in session.execute(select(TextField)).scalars():
# NOTE: The only exception to the "Title Case" conversion is the "URL" field.
text_field.name = text_field.name.title().replace("Url", "URL").replace("_", " ")
logger.info("[Library][Migration][200] Normalizing DatetimeField names...")
for datetime_field in session.execute(select(DatetimeField)).scalars():
datetime_field.name = datetime_field.name.title().replace("_", " ")
session.flush()
# Add correct `is_multiline` values to text_fields table
logger.info("[Library][Migration][200] Updating is_multiline for legacy TEXT_BOXes...")
text_boxes = [
x.get("name") for x in LEGACY_FIELD_MAP.values() if x.get("is_multiline") is True
]
update_stmt = (
update(TextField).where(TextField.name.in_(text_boxes)).values(is_multiline=True)
)
session.execute(update_stmt)
session.flush()
# Repair legacy "Description" fields to use is_multiline = True
logger.info("[Library][Migration][200] Repairing legacy Description fields...")
desc_stmt = (
update(TextField)
.where(TextField.name == "Description" and TextField.is_multiline == False) # noqa: E712
.values(is_multiline=True)
)
session.execute(desc_stmt)
# Repair legacy "Comments" fields to use is_multiline = True
logger.info("[Library][Migration][200] Repairing legacy Comment fields...")
comm_stmt = (
update(TextField)
.where(TextField.name == "Comments" and TextField.is_multiline == False) # noqa: E712
.values(is_multiline=True)
)
session.execute(comm_stmt)
# Add default field templates
logger.info("[Library][Migration][200] Adding default field templates...")
for template in get_default_field_templates():
session.add(template)
session.flush()
# DB indices for improved performance
session.execute(
text("CREATE INDEX IF NOT EXISTS idx_tags_name_shorthand ON tags (name, shorthand)")
)
session.execute(
text("CREATE INDEX IF NOT EXISTS idx_tag_parents_child_id ON tag_parents (child_id)")
)
session.execute(
text("CREATE INDEX IF NOT EXISTS idx_tag_entries_entry_id ON tag_entries (entry_id)")
)
def __apply_db201_migration(self, session: Session, library_dir: Path):
"""Migrate DB to DB_VERSION 201."""
create_text_fields_table = text("""
CREATE TABLE text_fields_new (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
name VARCHAR NOT NULL,
entry_id INTEGER NOT NULL,
value VARCHAR,
is_multiline BOOLEAN NOT NULL,
FOREIGN KEY(entry_id) REFERENCES entries (id)
)
""")
create_datetime_fields_table = text("""
CREATE TABLE datetime_fields_new (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
name VARCHAR NOT NULL,
entry_id INTEGER NOT NULL,
value VARCHAR,
FOREIGN KEY(entry_id) REFERENCES entries (id)
)
""")
logger.info("[Library][Migration][201] Dropping type_key from text_fields table...")
session.execute(create_text_fields_table)
session.flush()
session.execute(
text("""
INSERT INTO text_fields_new (id, name, entry_id, value, is_multiline)
SELECT id, name, entry_id, value, is_multiline
FROM text_fields
""")
)
session.execute(text("DROP TABLE text_fields"))
session.execute(text("ALTER TABLE text_fields_new RENAME TO text_fields"))
logger.info("[Library][Migration][201] Dropping type_key from datetime_fields table...")
session.execute(create_datetime_fields_table)
session.flush()
session.execute(
text("""
INSERT INTO datetime_fields_new (id, name, entry_id, value)
SELECT id, name, entry_id, value
FROM datetime_fields
""")
)
session.execute(text("DROP TABLE datetime_fields"))
session.execute(text("ALTER TABLE datetime_fields_new RENAME TO datetime_fields"))
session.flush()
def __apply_db202_migration(self, session: Session, library_dir: Path):
"""Migrate DB to DB_VERSION 202."""
stmt = delete(TagParent).where(TagParent.child_id.not_in(select(Tag.id).distinct()))
session.execute(stmt)
session.flush()
logger.info("[Library][Migration][202] Verified TagParent table data")
def __apply_db300_migration(self, session: Session, library_dir: Path):
## remove folder_id column from entries table
# create new table in the desired scheme (without folder_id column)
session.execute(
text("""
CREATE TABLE entries_new (
id INTEGER NOT NULL,
path VARCHAR NOT NULL,
suffix VARCHAR NOT NULL,
date_created DATETIME,
date_modified DATETIME,
date_added DATETIME,
filename TEXT NOT NULL DEFAULT '',
PRIMARY KEY (id),
UNIQUE (path)
)
""")
)
session.flush()
# transfer data to new table
session.execute(
text("""
INSERT INTO entries_new (id, path, suffix, date_created, date_modified, date_added,
filename)
SELECT id, path, suffix, date_created, date_modified, date_added, filename
FROM entries
""")
)
# delete old table
session.execute(text("DROP TABLE entries"))
# rename new table to old table
session.execute(text("ALTER TABLE entries_new RENAME TO entries"))
session.flush()
## drop table "folders"
session.execute(text("DROP TABLE folders"))
session.flush()
@property
def field_templates(self) -> Sequence[BaseFieldTemplate]:
with Session(self.engine) as session:
@@ -1061,36 +668,32 @@ class Library:
with Session(self.engine) as session:
return unwrap(session.scalar(select(func.count(Entry.id))))
def __all_entries(self, session: Session, with_joins: bool = False) -> Iterator[Entry]:
"""Load entries without joins."""
stmt = select(Entry)
if with_joins:
# load Entry with all joins and all tags
stmt = (
stmt.outerjoin(Entry.text_fields)
.outerjoin(Entry.datetime_fields)
.outerjoin(Entry.tags)
)
stmt = stmt.options(
contains_eager(Entry.text_fields),
contains_eager(Entry.datetime_fields),
contains_eager(Entry.tags),
)
stmt = stmt.distinct()
entries = session.execute(stmt).scalars()
if with_joins:
entries = entries.unique()
for entry in entries:
yield entry
session.expunge(entry)
def all_entries(self, with_joins: bool = False) -> Iterator[Entry]:
"""Load entries without joins."""
with Session(self.engine) as session:
return self.__all_entries(session, with_joins)
stmt = select(Entry)
if with_joins:
# load Entry with all joins and all tags
stmt = (
stmt.outerjoin(Entry.text_fields)
.outerjoin(Entry.datetime_fields)
.outerjoin(Entry.tags)
)
stmt = stmt.options(
contains_eager(Entry.text_fields),
contains_eager(Entry.datetime_fields),
contains_eager(Entry.tags),
)
stmt = stmt.distinct()
entries = session.execute(stmt).scalars()
if with_joins:
entries = entries.unique()
for entry in entries:
yield entry
session.expunge(entry)
@property
def tags(self) -> list[Tag]:
@@ -1717,6 +1320,7 @@ class Library:
tag: Tag,
parent_ids: list[int] | set[int] | None = None,
aliases: Iterable[TagAlias] | None = None,
exclusion_ids: list[int] | set[int] | None = None,
) -> Tag | None:
with Session(self.engine, expire_on_commit=False) as session:
try:
@@ -1733,6 +1337,9 @@ class Library:
self.update_aliases(tag, aliases, session)
session.flush()
if exclusion_ids is not None:
self._update_category_exclusion(tag, exclusion_ids, session)
session.commit()
session.expunge(tag)
return tag
@@ -1838,16 +1445,17 @@ class Library:
session.rollback()
return None
def save_library_backup_to_disk(self) -> Path:
assert isinstance(self.library_dir, Path)
makedirs(str(self.library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME), exist_ok=True)
@staticmethod
def save_library_backup_to_disk(library_dir: Path) -> Path:
assert isinstance(library_dir, Path)
makedirs(str(library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME), exist_ok=True)
filename = f"ts_library_backup_{datetime.now(UTC).strftime('%Y_%m_%d_%H%M%S')}.sqlite"
target_path = self.library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME / filename
target_path = library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME / filename
shutil.copy2(
self.library_dir / TS_FOLDER_NAME / SQL_FILENAME,
library_dir / TS_FOLDER_NAME / SQL_FILENAME,
target_path,
)
@@ -1861,6 +1469,7 @@ class Library:
selectinload(Tag.parent_tags),
selectinload(Tag.aliases),
joinedload(Tag.color),
selectinload(Tag.category_exclusions),
)
tag = session.scalar(tags_query.where(Tag.id == tag_id))
@@ -1931,7 +1540,10 @@ class Library:
statement = select(Tag).where(Tag.id.in_(all_tag_ids))
statement = statement.options(
noload(Tag.parent_tags), selectinload(Tag.aliases), joinedload(Tag.color)
noload(Tag.parent_tags),
selectinload(Tag.aliases),
selectinload(Tag.category_exclusions),
joinedload(Tag.color),
)
tags = session.scalars(statement).fetchall()
for tag in tags:
@@ -2010,9 +1622,10 @@ class Library:
tag: Tag,
parent_ids: list[int] | set[int] | None = None,
aliases: Iterable[TagAlias] | None = None,
exclusion_ids: list[int] | set[int] | None = None,
) -> None:
"""Edit a Tag in the Library."""
self.add_tag(tag, parent_ids, aliases)
self.add_tag(tag, parent_ids, aliases, exclusion_ids)
def update_color(self, old_color_group: TagColorGroup, new_color_group: TagColorGroup) -> None:
"""Update a TagColorGroup in the Library. If it doesn't already exist, create it."""
@@ -2060,8 +1673,10 @@ class Library:
)
session.execute(update_tags_stmt)
session.commit()
else:
self.add_color(new_color_group)
return
# "if not existing_color", out of the session context
self.add_color(new_color_group)
def update_aliases(self, tag: Tag, aliases: Iterable[TagAlias], session: Session) -> bool:
"""Update TagAliases for a given Tag."""
@@ -2133,6 +1748,23 @@ class Library:
)
session.add(parent_tag)
def _update_category_exclusion(
self, tag: Tag, exclusion_ids: list[int] | set[int], session: Session
):
prev_exclusions = session.scalars(
select(CategoryExclusion).where(CategoryExclusion.tag_id == tag.id)
).all()
for exclusion in prev_exclusions:
if exclusion.category_id not in exclusion_ids:
session.delete(exclusion)
else:
exclusion_ids.remove(exclusion.category_id)
for exclusion_id in exclusion_ids:
exclusion = CategoryExclusion(tag_id=tag.id, category_id=exclusion_id)
session.add(exclusion)
def get_version(self, key: str) -> int:
"""Get a version value from the DB.
@@ -2140,36 +1772,11 @@ class Library:
key(str): The key for the name of the version type to set.
"""
with Session(self.engine) as session:
engine = sqlalchemy.inspect(self.engine)
try:
# "Version" table added in DB_VERSION 101
if engine and engine.has_table("versions"):
version = session.scalar(select(Version).where(Version.key == key))
assert version
return version.value
# NOTE: The "Preferences" table has been depreciated as of TagStudio 9.5.4
# and is set to be removed in a future release.
else:
return int(
unwrap(
session.scalar(
text("SELECT value FROM preferences WHERE key == 'DB_VERSION'")
)
)
)
except Exception:
version = session.scalar(select(Version).where(Version.key == key))
if version is None:
logger.info(f"[Library] Couldn't get version of type '{key}'")
return 0
def set_version(self, session: Session, key: str, value: int) -> None:
"""Set a version value to the DB.
Args:
session(Session): The SQLAlchemy DB Session to use.
key(str): The key for the name of the version type to set.
value(int): The version value to set.
"""
# Insert if key has no value yet, otherwise update the value
session.merge(Version(key=key, value=value))
return version.value
def mirror_entry_fields(self, entries: list[Entry]) -> None:
"""Mirror fields among multiple Entry items."""
@@ -0,0 +1,618 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: MIT
from collections.abc import Callable
from pathlib import Path
from typing import override
import sqlalchemy
import structlog
import ujson
from sqlalchemy import Engine, and_, delete, select, text, update
from sqlalchemy.orm import Session
from tagstudio.core.constants import IGNORE_NAME, TAG_ARCHIVED, TS_FOLDER_NAME
from tagstudio.core.library.alchemy import default_color_groups
from tagstudio.core.library.alchemy.constants import (
DB_VERSION,
DB_VERSION_CURRENT_KEY,
DB_VERSION_INITIAL_KEY,
DEFAULT_FIELD_TEMPLATES,
)
from tagstudio.core.library.alchemy.fields import LEGACY_FIELD_MAP, DatetimeField, TextField
from tagstudio.core.library.alchemy.joins import TagParent
from tagstudio.core.library.alchemy.models import Entry, Tag, TagColorGroup, Version
from tagstudio.core.library.ignore import migrate_ext_list
from tagstudio.core.utils.types import unwrap
from tagstudio.i18n.translations import Translations
logger = structlog.get_logger(__name__)
LoggingMethod = Callable[[str], str]
class MigrationError(Exception):
pass
class DBMigration:
version: int
initial_version: int | None = None
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError
class DBMigrations:
def __init__(self, library_dir: Path, engine: Engine) -> None:
self.library_dir = library_dir
self.engine = engine
# Don't check DB version when creating new library
self.loaded_db_version = self._get_version(DB_VERSION_CURRENT_KEY)
self.initial_db_version = self._get_version(DB_VERSION_INITIAL_KEY)
# ======================== Library Database Version Checking =======================
# DB_VERSION 6 is the first supported SQLite DB version.
# If the DB_VERSION is >= 100, that means it's a compound major + minor version.
# - Dividing by 100 and flooring gives the major (breaking changes) version.
# - If a DB has major version higher than the current program, don't load it.
# - If only the minor version is higher, it's still allowed to load.
if self.loaded_db_version < 6 or (
self.loaded_db_version >= 100 and self.loaded_db_version // 100 > DB_VERSION // 100
):
mismatch_text = Translations["status.library_version_mismatch"]
found_text = Translations["status.library_version_found"]
expected_text = Translations["status.library_version_expected"]
raise MigrationError(
f"{mismatch_text}\n"
f"{found_text} v{self.loaded_db_version}, "
f"{expected_text} v{DB_VERSION}"
)
logger.info(
f"[Library][Migration] "
f"Opening Library with DB Version {self.loaded_db_version}/{DB_VERSION}"
)
@property
def required(self) -> bool:
return self.loaded_db_version < DB_VERSION
def run(self):
if not self.required:
return
# migrate DB step by step from one version to the next
# (migration_method, db_version, initial_db_version)
migrations: list[type[DBMigration]] = [
MigrationTo7, # changes: value_type, tags
MigrationTo8, # changes: tag_colors
MigrationTo9, # changes: entries
MigrationTo100, # changes: tag_parents
MigrationTo101, # changes: versions
MigrationTo102, # changes: tag_parents
MigrationTo103, # changes: tags
MigrationTo104, # changes: deletes preferences
MigrationTo200, # changes: field tables
MigrationTo201, # changes: field tables
MigrationTo202, # changes: tag_parents
MigrationTo300, # changes: deletes folders
MigrationTo400, # changes: add category_exclusions
]
with Session(self.engine) as session:
for migration in migrations:
if self.loaded_db_version < migration.version and (
migration.initial_version is None
or self.initial_db_version < migration.initial_version
):
logger.info(f"[Library][Migration][{migration.version}] Starting DB Migration")
# any error causes transaction to rollback
migration.run(
session,
self.library_dir,
lambda msg, v=migration.version: f"[Library][Migration][{v}] {msg}",
)
self.loaded_db_version = migration.version
try:
self._set_version(session, DB_VERSION_CURRENT_KEY, migration.version)
logger.info(
f"[Library][Migration][{migration.version}] Completed DB Migration"
)
except Exception as e:
logger.info(
f"[Library][Migration][{migration.version}] "
"Couldn't update version, continuing without commit",
error=e,
)
session.flush()
else:
session.commit()
assert self.loaded_db_version >= DB_VERSION, (
"Ran all migrations, but the DB is still not on the newest version"
)
def _get_version(self, key: str) -> int:
with Session(self.engine) as session:
inspector = sqlalchemy.inspect(self.engine)
try:
# "Version" table added in DB_VERSION 101
if inspector and inspector.has_table("versions"):
version = session.scalar(select(Version).where(Version.key == key))
assert version
return version.value
# "Preferences" table deprecated in TagStudio 9.5.4
else:
return int(
unwrap(
session.scalar(
text("SELECT value FROM preferences WHERE key == 'DB_VERSION'")
)
)
)
except Exception:
return 0
def _set_version(self, session: Session, key: str, value: int) -> None:
"""Set a version value to the DB.
Args:
session(Session): The SQLAlchemy DB Session to use.
key(str): The key for the name of the version type to set.
value(int): The version value to set.
"""
# Insert if key has no value yet, otherwise update the value
session.merge(Version(key=key, value=value))
class MigrationTo7(DBMigration):
version = 7
@override
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod):
"""Migrate DB from DB_VERSION 6 to 7."""
logger.info(fmt_log("Applying patches to DB_VERSION: 6 library..."))
# Repair tags that may have a disambiguation_id pointing towards a deleted tag.
# TODO: combine into single sql statement
all_tag_ids = session.scalars(text("SELECT DISTINCT id FROM tags")).all()
disam_stmt = (
update(Tag)
.where(Tag.disambiguation_id.not_in(all_tag_ids))
.values(disambiguation_id=None)
)
session.execute(disam_stmt)
session.flush()
class MigrationTo8(DBMigration):
version = 8
@override
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod):
"""Migrate DB from DB_VERSION 7 to 8."""
# Add the missing color_border column to the TagColorGroups table.
session.execute(
text("ALTER TABLE tag_colors ADD COLUMN color_border BOOLEAN DEFAULT FALSE NOT NULL")
)
session.flush()
logger.info(fmt_log("Added color_border column to tag_colors table"))
# collect new default tag colors
tag_colors: list[TagColorGroup] = [
color
for color in default_color_groups.shades()
if color.slug in ["burgundy", "dark-teal", "dark_lavender"]
]
# Add any new default colors introduced in DB_VERSION 8
for color in tag_colors:
session.add(color)
session.flush()
logger.info(
fmt_log("Migrated tag colors to DB_VERSION 8+"),
color_name=tag_colors,
)
# Update Neon colors to use the the color_border property
for color in default_color_groups.neon():
neon_stmt = (
update(TagColorGroup)
.where(
and_(
TagColorGroup.namespace == color.namespace,
TagColorGroup.slug == color.slug,
)
)
.values(
slug=color.slug,
namespace=color.namespace,
name=color.name,
primary=color.primary,
secondary=color.secondary,
color_border=color.color_border,
)
)
session.execute(neon_stmt)
session.flush()
class MigrationTo9(DBMigration):
version = 9
@override
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod):
"""Migrate DB from DB_VERSION 8 to 9."""
# Apply database schema changes
add_filename_column = text(
"ALTER TABLE entries ADD COLUMN filename TEXT NOT NULL DEFAULT ''"
)
session.execute(add_filename_column)
session.flush()
logger.info(fmt_log("Added filename column to entries table"))
# Populate the new filename column.
# TODO: this could still break in the future through changes to the definition of Entry
entries = session.execute(select(Entry).distinct()).scalars()
for entry in entries:
entry.filename = entry.path.name
session.merge(entry)
session.flush()
logger.info(fmt_log("Populated filename column in entries table"))
class MigrationTo100(DBMigration):
version = 100
@override
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod):
"""Migrate DB to DB_VERSION 100."""
# Repair parent-child tag relationships that are the wrong way around.
stmt = update(TagParent).values(
parent_id=TagParent.child_id,
child_id=TagParent.parent_id,
)
session.execute(stmt)
session.flush()
logger.info(fmt_log("Refactored TagParent table"))
class MigrationTo101(DBMigration):
version = 101
@override
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod):
"""Migrate DB to DB_VERSION 101."""
# Create versions table
session.execute(
text("""
CREATE TABLE versions (
"key" VARCHAR NOT NULL PRIMARY KEY,
value INTEGER NOT NULL
)
""")
)
session.flush()
# Ensure version rows are present
session.add(Version(key=DB_VERSION_INITIAL_KEY, value=100))
session.flush()
logger.info(fmt_log("Created versions table"))
class MigrationTo102(DBMigration):
version = 102
@override
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod):
"""Migrate DB to DB_VERSION 102."""
# delete TagParents with a dangling parent reference
stmt = delete(TagParent).where(TagParent.parent_id.not_in(select(Tag.id).distinct()))
session.execute(stmt)
session.flush()
logger.info(fmt_log("Verified TagParent table data"))
class MigrationTo103(DBMigration):
version = 103
@override
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod):
"""Migrate DB from DB_VERSION 102 to 103."""
# add the new hidden column for tags
session.execute(text("ALTER TABLE tags ADD COLUMN is_hidden BOOLEAN NOT NULL DEFAULT 0"))
session.flush()
logger.info(fmt_log("Added is_hidden column to tags table"))
# mark the "Archived" tag as hidden
session.query(Tag).filter(Tag.id == TAG_ARCHIVED).update({"is_hidden": True})
session.flush()
logger.info(fmt_log("Updated archived tag to be hidden"))
class MigrationTo104(DBMigration):
version = 104
@override
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod):
"""Migrate DB from DB_VERSION 103 to 104."""
# Convert file extension list to ts_ignore file, if a .ts_ignore file does not exist
cls.__migrate_sql_to_ts_ignore(session, library_dir)
session.execute(text("DROP TABLE preferences"))
session.flush()
@classmethod
def __migrate_sql_to_ts_ignore(cls, session: Session, library_dir: Path):
# Do not continue if existing '.ts_ignore' file is found
ts_ignore = library_dir / TS_FOLDER_NAME / IGNORE_NAME
if Path(ts_ignore).exists():
return
# Load legacy extension data
extensions: list[str] = ujson.loads(
unwrap(
session.scalar(text("SELECT value FROM preferences WHERE key = 'EXTENSION_LIST'"))
)
)
is_exclude_list: bool = unwrap(
session.scalar(text("SELECT value FROM preferences WHERE key = 'IS_EXCLUDE_LIST'"))
)
with open(ts_ignore, "w") as f:
f.write(migrate_ext_list(extensions, is_exclude_list))
class MigrationTo200(DBMigration):
version = 200
@override
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod):
"""Migrate DB to DB_VERSION 200."""
# Drop unused 'boolean_fields' and 'value_type' tables
logger.info(fmt_log("Dropping boolean_fields and value_type tables..."))
session.execute(text("DROP TABLE boolean_fields"))
session.execute(text("DROP TABLE value_type"))
# Add 'name' column to text_fields and datetime_fields tables
logger.info(fmt_log("Adding name columns to field tables..."))
stmt = text('ALTER TABLE text_fields ADD COLUMN name VARCHAR DEFAULT ""')
session.execute(stmt)
stmt = text('ALTER TABLE datetime_fields ADD COLUMN name VARCHAR DEFAULT ""')
session.execute(stmt)
# Drop unnecessary 'position' columns
logger.info(fmt_log("Dropping position columns to field tables..."))
session.execute(text("ALTER TABLE datetime_fields DROP COLUMN position"))
session.execute(text("ALTER TABLE text_fields DROP COLUMN position"))
# Add 'is_multiline' column to text_fields table
logger.info(fmt_log("Adding is_multiline column to text_fields..."))
stmt = text("ALTER TABLE text_fields ADD COLUMN is_multiline BOOLEAN NOT NULL DEFAULT 0")
session.execute(stmt)
session.flush()
# Move values from old `type_key` columns into new `name` columns
logger.info(fmt_log("Moving values from type_key columns to name..."))
session.execute(text("UPDATE text_fields SET name = type_key"))
session.execute(text("UPDATE datetime_fields SET name = type_key"))
session.flush()
# Change `name` values to title case
logger.info(fmt_log("Normalizing TextField names..."))
for text_field in session.execute(select(TextField)).scalars():
# NOTE: The only exception to the "Title Case" conversion is the "URL" field.
text_field.name = text_field.name.title().replace("Url", "URL").replace("_", " ")
logger.info(fmt_log("Normalizing DatetimeField names..."))
for datetime_field in session.execute(select(DatetimeField)).scalars():
datetime_field.name = datetime_field.name.title().replace("_", " ")
session.flush()
# Add correct `is_multiline` values to text_fields table
logger.info(fmt_log("Updating is_multiline for legacy TEXT_BOXes..."))
text_boxes = [
x.get("name") for x in LEGACY_FIELD_MAP.values() if x.get("is_multiline") is True
]
update_stmt = (
update(TextField).where(TextField.name.in_(text_boxes)).values(is_multiline=True)
)
session.execute(update_stmt)
session.flush()
# Repair legacy "Description" fields to use is_multiline = True
logger.info(fmt_log("Repairing legacy Description fields..."))
desc_stmt = (
update(TextField)
.where(TextField.name == "Description" and TextField.is_multiline == False) # noqa: E712
.values(is_multiline=True)
)
session.execute(desc_stmt)
# Repair legacy "Comments" fields to use is_multiline = True
logger.info(fmt_log("Repairing legacy Comment fields..."))
comm_stmt = (
update(TextField)
.where(TextField.name == "Comments" and TextField.is_multiline == False) # noqa: E712
.values(is_multiline=True)
)
session.execute(comm_stmt)
# Add field templates tables
session.execute(
text("""
CREATE TABLE text_field_templates (
id INTEGER NOT NULL PRIMARY KEY,
is_multiline BOOLEAN NOT NULL,
name VARCHAR NOT NULL
)
""")
)
session.execute(
text("""
CREATE TABLE datetime_field_templates (
id INTEGER NOT NULL PRIMARY KEY,
name VARCHAR NOT NULL
)
""")
)
session.flush()
# Add default field templates
logger.info(fmt_log("Adding default field templates..."))
for template in DEFAULT_FIELD_TEMPLATES:
session.add(template)
session.flush()
# DB indices for improved performance
session.execute(
text("CREATE INDEX IF NOT EXISTS idx_tags_name_shorthand ON tags (name, shorthand)")
)
session.execute(
text("CREATE INDEX IF NOT EXISTS idx_tag_parents_child_id ON tag_parents (child_id)")
)
session.execute(
text("CREATE INDEX IF NOT EXISTS idx_tag_entries_entry_id ON tag_entries (entry_id)")
)
class MigrationTo201(DBMigration):
version = 201
initial_version = 200
@override
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod):
"""Migrate DB to DB_VERSION 201."""
create_text_fields_table = text("""
CREATE TABLE text_fields_new (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
name VARCHAR NOT NULL,
entry_id INTEGER NOT NULL,
value VARCHAR,
is_multiline BOOLEAN NOT NULL,
FOREIGN KEY(entry_id) REFERENCES entries (id)
)
""")
create_datetime_fields_table = text("""
CREATE TABLE datetime_fields_new (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
name VARCHAR NOT NULL,
entry_id INTEGER NOT NULL,
value VARCHAR,
FOREIGN KEY(entry_id) REFERENCES entries (id)
)
""")
logger.info(fmt_log("Dropping type_key from text_fields table..."))
session.execute(create_text_fields_table)
session.flush()
session.execute(
text("""
INSERT INTO text_fields_new (id, name, entry_id, value, is_multiline)
SELECT id, name, entry_id, value, is_multiline
FROM text_fields
""")
)
session.execute(text("DROP TABLE text_fields"))
session.execute(text("ALTER TABLE text_fields_new RENAME TO text_fields"))
logger.info(fmt_log("Dropping type_key from datetime_fields table..."))
session.execute(create_datetime_fields_table)
session.flush()
session.execute(
text("""
INSERT INTO datetime_fields_new (id, name, entry_id, value)
SELECT id, name, entry_id, value
FROM datetime_fields
""")
)
session.execute(text("DROP TABLE datetime_fields"))
session.execute(text("ALTER TABLE datetime_fields_new RENAME TO datetime_fields"))
session.flush()
class MigrationTo202(DBMigration):
version = 202
@override
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod):
"""Migrate DB to DB_VERSION 202."""
stmt = delete(TagParent).where(TagParent.child_id.not_in(select(Tag.id).distinct()))
session.execute(stmt)
session.flush()
logger.info(fmt_log("Verified TagParent table data"))
class MigrationTo300(DBMigration):
version = 300
@override
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod):
## remove folder_id column from entries table
# create new table in the desired scheme (without folder_id column)
session.execute(
text("""
CREATE TABLE entries_new (
id INTEGER NOT NULL,
path VARCHAR NOT NULL,
suffix VARCHAR NOT NULL,
date_created DATETIME,
date_modified DATETIME,
date_added DATETIME,
filename TEXT NOT NULL DEFAULT '',
PRIMARY KEY (id),
UNIQUE (path)
)
""")
)
session.flush()
# transfer data to new table
session.execute(
text("""
INSERT INTO entries_new (id, path, suffix, date_created, date_modified, date_added,
filename)
SELECT id, path, suffix, date_created, date_modified, date_added, filename
FROM entries
""")
)
# delete old table
session.execute(text("DROP TABLE entries"))
# rename new table to old table
session.execute(text("ALTER TABLE entries_new RENAME TO entries"))
session.flush()
## drop table "folders"
session.execute(text("DROP TABLE folders"))
session.flush()
class MigrationTo400(DBMigration):
version = 400
@override
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log):
logger.info(fmt_log("Creating category_exclusions table..."))
session.execute(
text("""
CREATE TABLE category_exclusions (
tag_id INTEGER NOT NULL REFERENCES tags(id),
category_id INTEGER NOT NULL REFERENCES tags(id),
PRIMARY KEY (tag_id, category_id)
)
""")
)
session.flush()
+20 -8
View File
@@ -16,7 +16,7 @@ from tagstudio.core.library.alchemy.fields import (
DatetimeField,
TextField,
)
from tagstudio.core.library.alchemy.joins import TagParent
from tagstudio.core.library.alchemy.joins import CategoryExclusion, TagParent
class Namespace(Base):
@@ -41,7 +41,7 @@ class TagAlias(Base):
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(nullable=False)
tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id"))
tag: Mapped["Tag"] = relationship(back_populates="aliases")
tag: Mapped[Tag] = relationship(back_populates="aliases")
def __init__(self, name: str, tag_id: int | None = None):
self.name = name
@@ -97,13 +97,19 @@ class Tag(Base):
is_hidden: Mapped[bool]
icon: Mapped[str | None]
aliases: Mapped[set[TagAlias]] = relationship(back_populates="tag")
parent_tags: Mapped[set["Tag"]] = relationship(
parent_tags: Mapped[set[Tag]] = relationship(
secondary=TagParent.__tablename__,
primaryjoin="Tag.id == TagParent.child_id",
secondaryjoin="Tag.id == TagParent.parent_id",
back_populates="parent_tags",
)
disambiguation_id: Mapped[int | None]
category_exclusions: Mapped[set[Tag]] = relationship(
secondary=CategoryExclusion.__tablename__,
primaryjoin="Tag.id == CategoryExclusion.tag_id",
secondaryjoin="Tag.id == CategoryExclusion.category_id",
back_populates="category_exclusions",
)
__table_args__ = (
ForeignKeyConstraint(
@@ -124,19 +130,24 @@ class Tag(Base):
def alias_ids(self) -> list[int]:
return [tag.id for tag in self.aliases]
@property
def exclusion_ids(self) -> list[int]:
return [tag.id for tag in self.category_exclusions]
def __init__(
self,
name: str,
id: int | None = None,
shorthand: str | None = None,
aliases: set[TagAlias] | None = None,
parent_tags: set["Tag"] | None = None,
parent_tags: set[Tag] | None = None,
icon: str | None = None,
color_namespace: str | None = None,
color_slug: str | None = None,
disambiguation_id: int | None = None,
is_category: bool = False,
is_hidden: bool = False,
category_exclusions: set[Tag] | None = None,
):
self.name = name
self.aliases = aliases or set()
@@ -149,6 +160,7 @@ class Tag(Base):
self.is_category = is_category
self.is_hidden = is_hidden
self.id = id # pyright: ignore[reportAttributeAccessIssue]
self.category_exclusions = category_exclusions or set()
super().__init__()
@override
@@ -169,16 +181,16 @@ class Tag(Base):
return False
return self.id == value.id
def __lt__(self, other: "Tag") -> bool:
def __lt__(self, other: Tag) -> bool:
return self.name < other.name
def __le__(self, other: "Tag") -> bool:
def __le__(self, other: Tag) -> bool:
return self.name <= other.name
def __gt__(self, other: "Tag") -> bool:
def __gt__(self, other: Tag) -> bool:
return self.name > other.name
def __ge__(self, other: "Tag") -> bool:
def __ge__(self, other: Tag) -> bool:
return self.name >= other.name
+1 -1
View File
@@ -861,7 +861,7 @@ class Library:
self.files_not_in_library,
key=lambda t: -(self.library_dir / t).stat().st_ctime,
)
except (FileExistsError, FileNotFoundError):
except FileExistsError, FileNotFoundError:
print(
"[LIBRARY] [ERROR] Couldn't sort files, some were moved during the scanning/sorting process."
)
+4 -4
View File
@@ -16,7 +16,7 @@ class ConstraintType(Enum):
Special = 5
@staticmethod
def from_string(text: str) -> "ConstraintType | None":
def from_string(text: str) -> ConstraintType | None:
return {
"tag": ConstraintType.Tag,
"tag_id": ConstraintType.TagID,
@@ -28,7 +28,7 @@ class ConstraintType(Enum):
class AST:
parent: "AST | None" = None
parent: AST | None = None
@override
def __str__(self):
@@ -65,9 +65,9 @@ class ORList(AST):
class Constraint(AST):
type: ConstraintType
value: str
properties: list["Property"]
properties: list[Property]
def __init__(self, type: ConstraintType, value: str, properties: list["Property"]) -> None:
def __init__(self, type: ConstraintType, value: str, properties: list[Property]) -> None:
super().__init__()
for prop in properties:
prop.parent = self
+2 -2
View File
@@ -39,11 +39,11 @@ class Token:
self.end = end
@staticmethod
def from_type(type: TokenType, pos: int) -> "Token":
def from_type(type: TokenType, pos: int) -> Token:
return Token(type, None, pos, pos)
@staticmethod
def EOF(pos: int) -> "Token": # noqa: N802
def EOF(pos: int) -> Token: # noqa: N802
return Token.from_type(TokenType.EOF, pos)
@override
+2
View File
@@ -25,6 +25,8 @@ class TagStudioCore:
@lru_cache(maxsize=1)
def get_most_recent_release_version() -> str | None:
"""Get the version of the most recent GitHub release."""
logger.info("[Core] Checking for updates...")
try:
resp = requests.get(
"https://api.github.com/repos/TagStudioDev/TagStudio/releases/latest"
+1 -1
View File
@@ -58,5 +58,5 @@ def format_duration(duration: int | float) -> str:
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):
except OverflowError, ValueError:
return "-:--"
@@ -1,12 +1,12 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-License-Identifier: MIT
"""A collection of platform-dependant strings."""
import platform
from tagstudio.qt.translations import Translations
from tagstudio.i18n.translations import Translations
def open_file_str() -> str:
@@ -1,5 +1,5 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-License-Identifier: MIT
from collections import defaultdict
@@ -10,7 +10,7 @@ from typing import Any
import structlog
import ujson
from tagstudio.qt.mnemonics import remove_mnemonic_marker
from tagstudio.qt.helpers.mnemonics import remove_mnemonic_marker
logger = structlog.get_logger(__name__)
@@ -86,7 +86,7 @@ class Translator:
def __format(self, text: str, **kwargs: ...) -> str:
try:
return text.format(**kwargs)
except (KeyError, ValueError):
except KeyError, ValueError:
logger.error(
"[Translations] Error while formatting translation.",
text=text,
-80
View File
@@ -1,80 +0,0 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
"""TagStudio launcher."""
import argparse
import traceback
import structlog
from tagstudio.core.constants import BUILD_TYPE, VERSION
from tagstudio.qt.translations import Translations
from tagstudio.qt.ts_qt import QtDriver
logger = structlog.get_logger(__name__)
def main():
# appid = "cyanvoxel.tagstudio.9"
# ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(appid)
# Parse arguments.
parser = argparse.ArgumentParser()
parser.add_argument(
"-o",
"--open",
dest="open",
type=str,
help="Path to a TagStudio Library folder to open on start.",
)
parser.add_argument(
"-s",
"--settings-file",
dest="settings_file",
type=str,
help="Path to a TagStudio .toml global settings file to use.",
)
parser.add_argument(
"-c",
"--cache-file",
dest="cache_file",
type=str,
help="Path to a TagStudio .ini or .plist cache file to use.",
)
# parser.add_argument('--browse', dest='browse', action='store_true',
# help='Jumps to entry browsing on startup.')
# parser.add_argument('--external_preview', dest='external_preview', action='store_true',
# help='Outputs current preview thumbnail to a live-updating file.')
parser.add_argument(
"--debug",
dest="debug",
action="store_true",
help="Reveals additional internal data useful for debugging.",
)
parser.add_argument(
"-v",
"--version",
action="version",
help="Displays TagStudio version information.",
version=f"TagStudio v{VERSION} {Translations[BUILD_TYPE] if BUILD_TYPE else ''}",
)
args = parser.parse_args()
driver = QtDriver(args)
ui_name = "Qt"
# Run the chosen frontend driver.
try:
driver.start()
except Exception:
traceback.print_exc()
logger.info(f"\nTagStudio Frontend ({ui_name}) Crashed! Press Enter to Continue...")
input()
if __name__ == "__main__":
main()
@@ -17,37 +17,41 @@ from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.ignore import Ignore
from tagstudio.core.media_types import MediaCategories, MediaType
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.cache_manager import CacheManager
from tagstudio.qt.global_settings import (
DEFAULT_CACHED_THUMB_RES,
MAX_CACHED_THUMB_RES,
MIN_CACHED_THUMB_RES,
GlobalSettings,
Theme,
)
from tagstudio.qt.helpers.gradients import four_corner_gradient
from tagstudio.qt.models.palette import UI_COLORS, ColorType, UiColor, get_ui_color
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.renderers.archive import (
from tagstudio.previews.gradients import four_corner_gradient
from tagstudio.previews.renderers.archive import (
apple_embedded_thumb,
archive_thumb,
krita_thumb,
open_doc_thumb,
powerpoint_thumb,
)
from tagstudio.renderers.audio import audio_album_thumb, audio_waveform_thumb
from tagstudio.renderers.blender import blender_thumb
from tagstudio.renderers.clip_studio import clip_studio_thumb
from tagstudio.renderers.ebook import epub_thumb
from tagstudio.renderers.font import font_full_preview, font_small_thumb
from tagstudio.renderers.medibang_paint import medibang_paint_thumb
from tagstudio.renderers.paint_dot_net import paint_dot_net_thumb
from tagstudio.renderers.pdf import pdf_thumb
from tagstudio.renderers.raster_image import exr_image_thumb, raster_image_thumb, raw_image_thumb
from tagstudio.renderers.source_engine import vtf_thumb
from tagstudio.renderers.text import text_thumb
from tagstudio.renderers.vector_image import vector_image_thumb
from tagstudio.renderers.video import video_thumb
from tagstudio.previews.renderers.audio import audio_album_thumb, audio_waveform_thumb
from tagstudio.previews.renderers.blender import blender_thumb
from tagstudio.previews.renderers.clip_studio import clip_studio_thumb
from tagstudio.previews.renderers.ebook import epub_thumb
from tagstudio.previews.renderers.font import font_full_preview, font_small_thumb
from tagstudio.previews.renderers.medibang_paint import medibang_paint_thumb
from tagstudio.previews.renderers.paint_dot_net import paint_dot_net_thumb
from tagstudio.previews.renderers.pdf import pdf_thumb
from tagstudio.previews.renderers.raster_image import (
exr_image_thumb,
raster_image_thumb,
raw_image_thumb,
)
from tagstudio.previews.renderers.source_engine import vtf_thumb
from tagstudio.previews.renderers.text import text_thumb
from tagstudio.previews.renderers.vector_image import vector_image_thumb
from tagstudio.previews.renderers.video import video_thumb
from tagstudio.qt.app_settings import (
DEFAULT_CACHED_THUMB_RES,
MAX_CACHED_THUMB_RES,
MIN_CACHED_THUMB_RES,
AppSettings,
Theme,
)
from tagstudio.qt.cache_manager import CacheManager
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.views.styles.palette import UI_COLORS, ColorType, UiColor, get_ui_color
ImageFile.LOAD_TRUNCATED_IMAGES = True
Image.MAX_IMAGE_PIXELS = None
@@ -62,7 +66,7 @@ class FileRenderer:
rm: ResourceManager = ResourceManager()
cached_img_ext: str = ".webp"
def __init__(self, library: Library, settings: GlobalSettings) -> None:
def __init__(self, library: Library, settings: AppSettings) -> None:
super().__init__()
self.lib = library
self.settings = settings
@@ -16,7 +16,7 @@ from PIL import Image
from tagstudio.core.media_types import MediaCategories
from tagstudio.core.utils.types import unwrap
from tagstudio.renderers.raster_image import image_from_bytes
from tagstudio.previews.renderers.raster_image import image_from_bytes
logger = structlog.get_logger(__name__)
@@ -52,7 +52,7 @@ class TarFile:
def read(self, name: str) -> bytes:
return unwrap(self.tar.extractfile(name)).read()
def __enter__(self) -> "TarFile":
def __enter__(self) -> TarFile:
self.tar = tarfile.open(name=self.filepath, mode=self.mode).__enter__()
return self
@@ -13,7 +13,7 @@ from mutagen import flac, id3, mp4
from mutagen._util import MutagenError
from PIL import Image, ImageDraw
from tagstudio.renderers.vendored.pydub.audio_segment import (
from tagstudio.previews.vendored.pydub.audio_segment import (
_AudioSegment as AudioSegment, # pyright: ignore[reportPrivateUsage]
)
@@ -52,7 +52,7 @@ def audio_album_thumb(filepath: Path, ext: str) -> Image.Image | None:
image = artwork
except (
FileNotFoundError,
id3.ID3NoHeaderError, # pyright: ignore[reportPrivateImportUsage]
id3.ID3NoHeaderError,
mp4.MP4MetadataError,
mp4.MP4StreamInfoError,
MutagenError,
@@ -9,7 +9,7 @@ from PIL import Image
from PySide6.QtCore import Qt
from PySide6.QtGui import QGuiApplication
from tagstudio.renderers.vendored.blender_thumbnailer import blend_thumb
from tagstudio.previews.vendored.blender_thumbnailer import blend_thumb
logger = structlog.get_logger(__name__)
@@ -12,8 +12,8 @@ from PIL import Image
from tagstudio.core.media_types import MediaCategories
from tagstudio.core.utils.types import unwrap
from tagstudio.renderers.archive import Archive, first_image_in_archive, open_archive
from tagstudio.renderers.raster_image import image_from_bytes
from tagstudio.previews.renderers.archive import Archive, first_image_in_archive, open_archive
from tagstudio.previews.renderers.raster_image import image_from_bytes
logger = structlog.get_logger(__name__)
@@ -11,8 +11,8 @@ import structlog
from PIL import Image, ImageDraw, ImageFont
from tagstudio.core.constants import FONT_SAMPLE_SIZES, FONT_SAMPLE_TEXT
from tagstudio.qt.helpers.color_overlay import auto_theme_overlay
from tagstudio.qt.helpers.text_wrapper import wrap_full_text
from tagstudio.qt.views.styles.color_overlay import auto_theme_overlay
logger = structlog.get_logger(__name__)
@@ -14,7 +14,7 @@ from PySide6.QtCore import QBuffer, QFile, QFileDevice, QIODeviceBase, QSizeF
from PySide6.QtGui import QImage
from PySide6.QtPdf import QPdfDocument, QPdfDocumentRenderOptions
from tagstudio.qt.helpers.image_effects import replace_transparent_pixels
from tagstudio.qt.views.styles.image_effects import replace_transparent_pixels
logger = structlog.get_logger(__name__)
@@ -11,7 +11,7 @@ from cv2.typing import MatLike
from PIL import Image, UnidentifiedImageError
from PIL.Image import DecompressionBombError
from tagstudio.qt.helpers.file_tester import is_readable_video
from tagstudio.previews.video_tester import is_readable_video
logger = structlog.get_logger(__name__)
@@ -43,7 +43,7 @@ from pydub.utils import (
)
from tagstudio.core.utils.silent_subprocess import silent_popen
from tagstudio.renderers.vendored.pydub.utils import _mediainfo_json
from tagstudio.previews.vendored.pydub.utils import _mediainfo_json
basestring = str
xrange = range
@@ -8,11 +8,7 @@ import json
import re
import subprocess
from pydub.utils import (
_fd_or_path_or_tempfile,
fsdecode,
get_extra_info,
)
from pydub.utils import _fd_or_path_or_tempfile, fsdecode, get_extra_info
from tagstudio.core.utils.ffmpeg_status import FfprobeStatus
from tagstudio.core.utils.silent_subprocess import silent_popen
@@ -1,12 +1,12 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-License-Identifier: MIT
from pathlib import Path
import ffmpeg
from tagstudio.renderers.vendored.probe import probe
from tagstudio.previews.vendored.probe import probe
def is_readable_video(filepath: Path | str):
@@ -59,7 +59,7 @@ class TomlEnumEncoder(toml.TomlEncoder):
# NOTE: pydantic also has a BaseSettings class (from pydantic-settings) that allows any settings
# properties to be overwritten with environment variables. As TagStudio is not currently using
# environment variables, this was not based on that, but that may be useful in the future.
class GlobalSettings(BaseModel):
class AppSettings(BaseModel):
language: str = Field(default="en")
open_last_loaded_on_startup: bool = Field(default=True)
generate_thumbs: bool = Field(default=True)
@@ -76,6 +76,7 @@ class GlobalSettings(BaseModel):
tag_click_action: TagClickActionOption = Field(default=TagClickActionOption.DEFAULT)
edit_tag_on_create: bool = Field(default=False)
edit_field_on_add: bool = Field(default=True)
keep_suggest_boxes_open: bool = Field(default=True)
theme: Theme = Field(default=Theme.SYSTEM)
splash: Splash = Field(default=Splash.DEFAULT)
windows_start_command: bool = Field(default=False)
@@ -87,17 +88,17 @@ class GlobalSettings(BaseModel):
loaded_from: Path = Field(default=DEFAULT_GLOBAL_SETTINGS_PATH, exclude=True)
@staticmethod
def read_settings(path: Path = DEFAULT_GLOBAL_SETTINGS_PATH) -> "GlobalSettings":
def read_settings(path: Path = DEFAULT_GLOBAL_SETTINGS_PATH) -> AppSettings:
if path.exists():
with open(path) as file:
filecontents = file.read()
if len(filecontents.strip()) != 0:
logger.info("[Settings] Reading Global Settings File", path=path)
settings_data = toml.loads(filecontents)
settings = GlobalSettings(**settings_data, loaded_from=path)
settings = AppSettings(**settings_data, loaded_from=path)
return settings
return GlobalSettings(loaded_from=path)
return AppSettings(loaded_from=path)
def save(self, path: Path | None = None) -> None:
if path is None:
+1 -1
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_THUMB_QUALITY, DEFAULT_THUMB_CACHE_SIZE
from tagstudio.qt.app_settings import DEFAULT_CACHED_THUMB_QUALITY, DEFAULT_THUMB_CACHE_SIZE
logger = structlog.get_logger(__name__)
@@ -5,13 +5,10 @@ from typing import override
import structlog
from PySide6 import QtCore, QtGui
from PySide6.QtCore import Signal
from PySide6.QtWidgets import (
QLineEdit,
QWidget,
)
from PySide6.QtCore import QPoint, Signal
from PySide6.QtWidgets import QLineEdit, QMenu, QWidget
from tagstudio.qt.views.stylesheets.stylesheets import (
from tagstudio.qt.views.styles.stylesheets import (
autofill_scroll_top_focus_style,
autofill_scroll_top_style,
)
@@ -22,7 +19,8 @@ logger = structlog.get_logger(__name__)
class AutofillLineEdit(QLineEdit):
return_pressed = Signal()
shift_return_pressed = Signal()
shift_holding = Signal(bool)
holding_shift = Signal(bool)
index_updated = Signal(int)
def __init__(self, popup: QWidget) -> None:
super().__init__()
@@ -39,23 +37,44 @@ class AutofillLineEdit(QLineEdit):
return super().focusInEvent(arg__1)
@override
def keyPressEvent(self, arg__1: QtGui.QKeyEvent) -> None:
if arg__1.key() == QtCore.Qt.Key.Key_Shift:
self.shift_holding.emit(True) # noqa: FBT003
def event(self, arg__1: QtCore.QEvent) -> bool:
if arg__1.type() == QtCore.QEvent.Type.KeyPress:
assert isinstance(arg__1, QtGui.QKeyEvent)
if arg__1.key() == QtCore.Qt.Key.Key_Escape:
self.setText("")
self.clearFocus()
elif arg__1.key() == QtCore.Qt.Key.Key_Enter or arg__1.key() == QtCore.Qt.Key.Key_Return:
if arg__1.modifiers() and QtCore.Qt.KeyboardModifier.ShiftModifier:
self.shift_return_pressed.emit()
else:
self.return_pressed.emit()
if arg__1.key() == QtCore.Qt.Key.Key_Tab:
self.index_updated.emit(1)
return True
elif arg__1.key() == QtCore.Qt.Key.Key_Backtab:
self.index_updated.emit(-1)
return True
return super().keyPressEvent(arg__1)
if arg__1.key() == QtCore.Qt.Key.Key_Shift:
self.holding_shift.emit(True) # noqa: FBT003
if arg__1.key() == QtCore.Qt.Key.Key_Escape:
self.setText("")
self.clearFocus()
elif (
arg__1.key() == QtCore.Qt.Key.Key_Enter or arg__1.key() == QtCore.Qt.Key.Key_Return
):
if arg__1.modifiers() == QtCore.Qt.KeyboardModifier.ShiftModifier:
self.shift_return_pressed.emit()
else:
self.return_pressed.emit()
return super().event(arg__1)
@override
def keyReleaseEvent(self, arg__1: QtGui.QKeyEvent) -> None:
if arg__1.key() == QtCore.Qt.Key.Key_Shift:
self.shift_holding.emit(False) # noqa: FBT003
self.holding_shift.emit(False) # noqa: FBT003
return super().keyReleaseEvent(arg__1)
def show_action_menu(self, pos: QPoint) -> None:
"""Show a context menu of actions."""
menu = QMenu(self)
for action in self.actions():
# Filter out icon action(s)
if action.text():
menu.addAction(action)
menu.exec(self.mapToGlobal(pos))
@@ -2,7 +2,7 @@
# SPDX-License-Identifier: GPL-3.0-only
from typing import override
from typing import Any, override
import structlog
from PySide6.QtGui import QMouseEvent
@@ -18,7 +18,7 @@ class ClickableSlider(QSlider):
based on click events.
"""
def __init__(self, *args, **kwargs):
def __init__(self, *args: Any, **kwargs: Any): # pyright: ignore[reportExplicitAny]
super().__init__(*args, **kwargs)
@override
@@ -9,13 +9,14 @@ from tagstudio.core.library.alchemy.fields import (
DatetimeFieldTemplate,
TextFieldTemplate,
)
from tagstudio.qt.translations import Translations
from tagstudio.i18n.translations import Translations
from tagstudio.qt.views.edit_field_template_modal_view import EditFieldTemplateModalView
from tagstudio.qt.views.stylesheets.stylesheets import line_edit_style
from tagstudio.qt.views.styles.stylesheets import line_edit_style
logger = structlog.get_logger(__name__)
# TODO: Use newer MVC style guidelines
class EditFieldTemplateModal(EditFieldTemplateModalView):
field_type_map: dict[str, str] = {
"TextFieldTemplate": Translations["field_type.text"],
@@ -11,6 +11,7 @@ from tagstudio.qt.views.edit_text_view import EditTextView
logger = structlog.get_logger(__name__)
# TODO: Use newer MVC style guidelines
class EditText(EditTextView):
def __init__(self, name: str, text: str | None, is_multiline: bool = False):
super().__init__()
@@ -6,40 +6,39 @@ from typing import override
from warnings import catch_warnings
import structlog
from PySide6.QtGui import QAction, Qt
from PySide6.QtGui import QAction
from PySide6.QtWidgets import QWidget
from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.utils.types import unwrap
from tagstudio.i18n.translations import Translations
from tagstudio.qt.app_settings import AppSettings
from tagstudio.qt.controllers.edit_field_template_modal import EditFieldTemplateModal
from tagstudio.qt.controllers.field_template_widget_controller import FieldTemplateWidget
from tagstudio.qt.controllers.field_template_widget import FieldTemplateWidget
from tagstudio.qt.controllers.modal import Modal
from tagstudio.qt.controllers.modal_content import ModalContent
from tagstudio.qt.controllers.suggest_box import SuggestBox
from tagstudio.qt.controllers.underlined_widget import UnderlinedWidget
from tagstudio.qt.global_settings import GlobalSettings
from tagstudio.qt.translations import Translations
logger = structlog.get_logger(__name__)
class FieldSuggestBox(SuggestBox[BaseFieldTemplate]):
def __init__(self, library: Library, settings: GlobalSettings, placeholder_text: str = ""):
def __init__(self, library: Library, settings: AppSettings, placeholder_text: str = ""):
super().__init__(library, settings, placeholder_text)
# Context Menu Actions
edit_field_on_add_action = QAction(Translations["settings.edit_field_on_add"], self)
edit_field_on_add_action.setCheckable(True)
self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
self.addAction(edit_field_on_add_action)
self.layout().search_field.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
self.layout().search_field.addAction(edit_field_on_add_action)
edit_field_on_add_action.setChecked(self._settings.edit_field_on_add)
edit_field_on_add_action.triggered.connect(
lambda checked: self.toggle_edit_on_field_add(checked)
lambda checked: self._toggle_edit_on_field_add(checked)
)
def toggle_edit_on_field_add(self, checked: bool) -> None:
def _toggle_edit_on_field_add(self, checked: bool) -> None:
"""Toggle the setting for opening the edit window after adding a field."""
self._settings.edit_field_on_add = checked
self._settings.save()
@@ -77,7 +76,7 @@ class FieldSuggestBox(SuggestBox[BaseFieldTemplate]):
@override
def _on_item_chosen(self, item: BaseFieldTemplate) -> None:
self.item_chosen.emit(item)
self.done.emit()
self.done.emit("*")
@override
def _search_items(self, query: str) -> tuple[list[BaseFieldTemplate], list[BaseFieldTemplate]]:
@@ -86,6 +85,17 @@ class FieldSuggestBox(SuggestBox[BaseFieldTemplate]):
else:
return ([], [])
@override
def _update_hint_icon(self) -> None:
if self._is_shift_held:
self.set_hint_icon(self._rm.hint_field_create)
elif self.layout().search_field.text() and len(self._search_results) > 0:
self.set_hint_icon(self._rm.hint_field_add)
elif self.layout().search_field.text():
self.set_hint_icon(self._rm.hint_field_create)
else:
self.set_hint_icon(None)
@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."""
@@ -146,6 +156,7 @@ class FieldSuggestBox(SuggestBox[BaseFieldTemplate]):
widget.setHidden(True)
self.layout().content_layout.addWidget(widget)
widget_: QWidget = self.layout().content_layout.itemAt(index).widget()
item = unwrap(self.layout().content_layout.itemAt(index))
widget_: QWidget = unwrap(item.widget())
assert isinstance(widget_, UnderlinedWidget)
return widget_
@@ -11,12 +11,13 @@ from PySide6.QtWidgets import QMessageBox, QWidget
from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.utils.types import unwrap
from tagstudio.i18n.translations import Translations
from tagstudio.qt.controllers.edit_field_template_modal import EditFieldTemplateModal
from tagstudio.qt.controllers.field_template_widget_controller import FieldTemplateWidget
from tagstudio.qt.controllers.field_template_widget import FieldTemplateWidget
from tagstudio.qt.controllers.modal import Modal
from tagstudio.qt.controllers.modal_content import ModalContent
from tagstudio.qt.controllers.search_panel_controller import SearchPanel
from tagstudio.qt.translations import Translations
from tagstudio.qt.controllers.search_panel import SearchPanel
from tagstudio.qt.views.search_panel_view import SearchPanelView
logger = structlog.get_logger(__name__)
@@ -173,6 +174,7 @@ class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
pad_field_template_widget.setHidden(True)
self.layout().scroll_layout.addWidget(pad_field_template_widget)
field_template_widget: QWidget = self.layout().scroll_layout.itemAt(index).widget()
item = unwrap(self.layout().scroll_layout.itemAt(index))
field_template_widget: QWidget = unwrap(item.widget())
assert isinstance(field_template_widget, FieldTemplateWidget)
return field_template_widget
@@ -7,10 +7,11 @@ 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.i18n.translations import FIELD_TYPE_KEYS, Translations
from tagstudio.qt.views.field_template_widget_view import FieldTemplateWidgetView
# TODO: Use newer MVC style guidelines
class FieldTemplateWidget(FieldTemplateWidgetView):
def __init__(self) -> None:
super().__init__()
@@ -0,0 +1,55 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from pathlib import Path
from typing import override
import structlog
from PySide6.QtCore import Qt
from PySide6.QtGui import QMouseEvent
from PySide6.QtWidgets import QLabel, QWidget
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.utils.file_opener import FileOpenerHelper
logger = structlog.get_logger(__name__)
class FileOpenerLabel(QLabel):
def __init__(self, parent: QWidget | None = None) -> None:
"""Initialize the FileOpenerLabel.
Args:
parent (QWidget, optional): The parent widget. Defaults to None.
"""
self.filepath: Path | None = None
super().__init__(parent)
def set_file_path(self, filepath: Path) -> None:
"""Set the filepath to open.
Args:
filepath (Path): The path to the file to open.
"""
self.filepath = filepath
@override
def mousePressEvent(self, ev: QMouseEvent) -> None:
"""Handle mouse press events.
On a left click, open the file in the default file explorer.
On a right click, show a context menu.
Args:
ev (QMouseEvent): The mouse press event.
"""
if ev.button() == Qt.MouseButton.LeftButton:
opener = FileOpenerHelper(unwrap(self.filepath))
opener.open_explorer()
elif ev.button() == Qt.MouseButton.RightButton:
# Show context menu
pass
else:
super().mousePressEvent(ev)
@@ -9,21 +9,22 @@ from PySide6 import QtGui
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.registries.ignored_registry import IgnoredRegistry
from tagstudio.qt.mixed.progress_bar import ProgressWidget
from tagstudio.i18n.translations import Translations
from tagstudio.qt.controllers.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
from tagstudio.qt.views.styles.stylesheets import header
# Only import for type checking/autocompletion, will not be imported at runtime.
if TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
from tagstudio.qt.qt_driver import QtDriver
logger = structlog.get_logger(__name__)
# TODO: Use newer MVC style guidelines
class FixIgnoredEntriesModal(FixIgnoredEntriesModalView):
def __init__(self, library: "Library", driver: "QtDriver"):
def __init__(self, library: Library, driver: QtDriver):
super().__init__(library, driver)
self.tracker = IgnoredRegistry(self.lib)
@@ -0,0 +1,35 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from typing import override
import structlog
from PySide6 import QtCore, QtGui
from PySide6.QtWidgets import QScrollArea
logger = structlog.get_logger(__name__)
class HorizontalScrollArea(QScrollArea):
"""A QScrollArea that translates vertical scrolling to horizontal movement."""
@override
def wheelEvent(self, arg__1: QtGui.QWheelEvent) -> None:
angle_y = arg__1.angleDelta().y()
pixel_y = arg__1.pixelDelta().y()
if angle_y != 0 or pixel_y != 0:
translated_event = QtGui.QWheelEvent(
arg__1.position(),
arg__1.globalPosition(),
QtCore.QPoint(pixel_y * -1, 0),
QtCore.QPoint(angle_y * -1, 0),
arg__1.buttons(),
arg__1.modifiers(),
arg__1.phase(),
arg__1.inverted(),
)
arg__1.accept()
return super().wheelEvent(translated_event)
else:
return super().wheelEvent(arg__1)
@@ -19,6 +19,7 @@ from tagstudio.qt.views.ignore_modal_view import IgnoreModalView
logger = structlog.get_logger(__name__)
# TODO: Use newer MVC style guidelines
class IgnoreModal(IgnoreModalView):
on_edit = Signal(Tag)
@@ -25,16 +25,16 @@ from tagstudio.core.library.alchemy.fields import (
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.controllers.edit_text_controller import EditText
from tagstudio.i18n.translations import FIELD_TYPE_KEYS, Translations
from tagstudio.qt.controllers.edit_text import EditText
from tagstudio.qt.controllers.modal import Modal
from tagstudio.qt.mixed.datetime_picker import DatetimePicker
from tagstudio.qt.mixed.field_containers import FieldContainers
from tagstudio.qt.mixed.file_attributes import FileAttributeData
from tagstudio.qt.translations import FIELD_TYPE_KEYS, Translations
from tagstudio.qt.views.preview_panel_view import PreviewPanelView
from tagstudio.qt.views.inspector_view import InspectorView
if typing.TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
from tagstudio.qt.qt_driver import QtDriver
logger = structlog.get_logger(__name__)
@@ -44,11 +44,12 @@ class _ItemMode(IntEnum):
FIELD = 2
class PreviewPanel(QWidget):
def __init__(self, driver: "QtDriver") -> None:
class Inspector(QWidget):
def __init__(self, driver: QtDriver) -> None:
super().__init__()
self._driver = driver
self._lib = self._driver.lib
self._settings = self._driver.settings
self._selected: list[int]
self._current_stats: FileAttributeData | None = None
@@ -67,7 +68,7 @@ class PreviewPanel(QWidget):
self,
)
self.setLayout(PreviewPanelView(driver=driver, pixel_ratio=self.devicePixelRatio()))
self.setLayout(InspectorView(driver=driver, pixel_ratio=self.devicePixelRatio()))
self._set_item_mode(None)
self._connect_callbacks()
@@ -75,13 +76,13 @@ class PreviewPanel(QWidget):
# Tag Search
self.layout().add_tag_button.clicked.connect(lambda: self._set_item_mode(_ItemMode.TAG))
self._open_tag_search_action.activated.connect(self._open_tag_search_callback)
self.layout().tag_search_box.done.connect(self._tag_added_callback)
self.layout().tag_search_box.done.connect(partial(self._tag_added_callback))
self.layout().containers.on_tags_update.connect(self._update_added_callback)
# Field Search
self.layout().add_field_button.clicked.connect(lambda: self._set_item_mode(_ItemMode.FIELD))
self._open_field_search_action.activated.connect(self._open_field_search_callback)
self.layout().field_search_box.done.connect(self._field_added_callback)
self.layout().field_search_box.done.connect(partial(self._field_added_callback))
# Previews
self.layout().preview_thumb.stats_updated.connect(self._thumb_stats_updated_callback)
@@ -122,13 +123,20 @@ class PreviewPanel(QWidget):
self.layout().add_field_button.setFocus()
self.layout().add_field_button.click()
def _tag_added_callback(self):
self._set_item_mode(None)
self.layout().add_tag_button.setFocus()
def _tag_added_callback(self, query: str):
if not query or not self._settings.keep_suggest_boxes_open:
self._set_item_mode(None)
self.layout().add_tag_button.setFocus()
else:
self._update_added_callback()
self.layout().tag_search_box.layout().search_field.setFocus()
def _field_added_callback(self):
self._set_item_mode(None)
self.layout().add_field_button.setFocus()
def _field_added_callback(self, query: str):
if not query or not self._settings.keep_suggest_boxes_open:
self._set_item_mode(None)
self.layout().add_field_button.setFocus()
else:
self.layout().field_search_box.layout().search_field.setFocus()
def _update_added_callback(self):
self.layout().tag_search_box.added = self.layout().containers.tags
@@ -268,7 +276,7 @@ class PreviewPanel(QWidget):
logger.error("[Preview Panel] Error updating selection", error=e)
def stop_media_playback(self) -> None:
"""Stop any media playback in the preview panel."""
"""Stop any media playback in the inspector."""
self.layout().preview_thumb.media_player.stop()
@property
@@ -276,5 +284,5 @@ class PreviewPanel(QWidget):
return self.layout().containers
@override
def layout(self) -> PreviewPanelView:
def layout(self) -> InspectorView:
return super().layout() # pyright: ignore[reportReturnType]
@@ -19,20 +19,21 @@ from tagstudio.core.library.alchemy.constants import (
)
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.translations import Translations
from tagstudio.i18n.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
from tagstudio.qt.views.styles.stylesheets import header
# Only import for type checking/autocompletion, will not be imported at runtime.
if TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
from tagstudio.qt.qt_driver import QtDriver
logger = structlog.get_logger(__name__)
# TODO: Use newer MVC style guidelines
class LibraryInfoWindow(LibraryInfoWindowView):
def __init__(self, library: "Library", driver: "QtDriver"):
def __init__(self, library: Library, driver: QtDriver):
super().__init__(library, driver)
# Statistics Buttons
@@ -1,6 +1,7 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# TODO: Split view and logic, split classes into individual files
import typing
from collections.abc import Callable
@@ -37,20 +38,20 @@ from PySide6.QtWidgets import (
from tagstudio.core.enums import ShowFilepathOption
from tagstudio.core.library.alchemy.enums import SortingModeEnum
from tagstudio.qt.controllers.preview_panel_controller import PreviewPanel
from tagstudio.qt.helpers.color_overlay import auto_theme_overlay
from tagstudio.i18n.platform_strings import trash_term
from tagstudio.i18n.translations import Translations
from tagstudio.qt.controllers.inspector import Inspector
from tagstudio.qt.helpers.mnemonics import assign_mnemonics
from tagstudio.qt.mixed.landing import LandingWidget
from tagstudio.qt.mixed.pagination import Pagination
from tagstudio.qt.mnemonics import assign_mnemonics
from tagstudio.qt.platform_strings import trash_term
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.thumb_grid_layout import ThumbGridLayout
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.stylesheets.stylesheets import checkbox_style
from tagstudio.qt.views.layouts.thumb_grid_layout import ThumbGridLayout
from tagstudio.qt.views.styles.color_overlay import auto_theme_overlay
from tagstudio.qt.views.styles.stylesheets import checkbox_style
# Only import for type checking/autocompletion, will not be imported at runtime.
if typing.TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
from tagstudio.qt.qt_driver import QtDriver
logger = structlog.get_logger(__name__)
@@ -457,7 +458,7 @@ class MainWindow(QMainWindow):
(Translations["home.thumbnail_size.mini"], 76),
]
def __init__(self, driver: "QtDriver", parent: QWidget | None = None) -> None:
def __init__(self, driver: QtDriver, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.rm = ResourceManager()
@@ -491,7 +492,7 @@ class MainWindow(QMainWindow):
self.pagination: Pagination
# initialized in setup_preview_panel
self.preview_panel: PreviewPanel
self.preview_panel: Inspector
# endregion
if not self.objectName():
@@ -528,7 +529,7 @@ class MainWindow(QMainWindow):
# endregion
def setup_central_widget(self, driver: "QtDriver"):
def setup_central_widget(self, driver: QtDriver):
self.central_widget = QWidget(self)
self.central_widget.setObjectName("central_widget")
self.central_layout = QGridLayout(self.central_widget)
@@ -650,7 +651,7 @@ class MainWindow(QMainWindow):
self.central_layout.addLayout(self.extra_input_layout, 5, 0, 1, 1)
def setup_content(self, driver: "QtDriver"):
def setup_content(self, driver: QtDriver):
self.content_layout = QHBoxLayout()
self.content_layout.setObjectName("content_layout")
@@ -666,7 +667,7 @@ class MainWindow(QMainWindow):
self.central_layout.addLayout(self.content_layout, 10, 0, 1, 1)
def setup_entry_list(self, driver: "QtDriver"):
def setup_entry_list(self, driver: QtDriver):
self.entry_list_container = QWidget()
self.entry_list_layout = QVBoxLayout(self.entry_list_container)
self.entry_list_layout.setSpacing(0)
@@ -699,8 +700,8 @@ class MainWindow(QMainWindow):
self.entry_list_layout.addWidget(self.pagination)
self.content_splitter.addWidget(self.entry_list_container)
def setup_preview_panel(self, driver: "QtDriver"):
self.preview_panel = PreviewPanel(driver)
def setup_preview_panel(self, driver: QtDriver):
self.preview_panel = Inspector(driver)
self.content_splitter.addWidget(self.preview_panel)
def setup_status_bar(self):
@@ -8,18 +8,17 @@ from PySide6.QtCore import QObject, Signal
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.registries.dupe_files_registry import DupeFilesRegistry
from tagstudio.qt.mixed.progress_bar import ProgressWidget
from tagstudio.qt.translations import Translations
from tagstudio.i18n.translations import Translations
from tagstudio.qt.controllers.progress_bar import ProgressWidget
# Only import for type checking/autocompletion, will not be imported at runtime.
if typing.TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
from tagstudio.qt.qt_driver import QtDriver
class MergeDuplicateEntries(QObject):
class MergeDuplicateEntriesProgress(QObject):
done = Signal()
def __init__(self, library: "Library", driver: "QtDriver"):
def __init__(self, library: Library, driver: QtDriver):
super().__init__()
self.lib = library
self.driver = driver
@@ -19,20 +19,21 @@ from rawpy import (
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.media_types import MediaCategories
from tagstudio.qt.helpers.file_tester import is_readable_video
from tagstudio.previews.video_tester import is_readable_video
from tagstudio.qt.mixed.file_attributes import FileAttributeData
from tagstudio.qt.utils.file_opener import open_file
from tagstudio.qt.views.preview_thumb_view import PreviewThumbView
if TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
from tagstudio.qt.qt_driver import QtDriver
logger = structlog.get_logger(__name__)
Image.MAX_IMAGE_PIXELS = None
# TODO: Use newer MVC style guidelines
class PreviewThumb(PreviewThumbView):
def __init__(self, library: Library, driver: "QtDriver"):
def __init__(self, library: Library, driver: QtDriver):
super().__init__(library, driver)
self.__driver: QtDriver = driver
@@ -11,6 +11,7 @@ from tagstudio.qt.utils.custom_runnable import CustomRunnable
from tagstudio.qt.utils.function_iterator import FunctionIterator
# TODO: Split to use MVC guidelines.
class ProgressWidget(QWidget):
"""Prebuilt thread-safe progress bar widget."""
@@ -26,10 +27,10 @@ class ProgressWidget(QWidget):
super().__init__()
self.root = QVBoxLayout(self)
self.pb = QProgressDialog(
labelText=label_text,
minimum=minimum,
cancelButtonText=cancel_button_text, # pyright: ignore[reportArgumentType]
maximum=maximum,
label_text,
cancel_button_text or "",
minimum,
maximum,
)
self.root.addWidget(self.pb)
self.setFixedSize(432, 112)
@@ -5,11 +5,11 @@
from PySide6.QtCore import QObject, Signal
from tagstudio.core.library.alchemy.registries.unlinked_registry import UnlinkedRegistry
from tagstudio.qt.mixed.progress_bar import ProgressWidget
from tagstudio.qt.translations import Translations
from tagstudio.i18n.translations import Translations
from tagstudio.qt.controllers.progress_bar import ProgressWidget
class RelinkUnlinkedEntries(QObject):
class RelinkUnlinkedEntriesProgress(QObject):
done = Signal()
def __init__(self, tracker: UnlinkedRegistry):
@@ -11,15 +11,15 @@ from PySide6.QtGui import QShowEvent
from PySide6.QtWidgets import QWidget
from tagstudio.core.library.alchemy.library import Library
from tagstudio.i18n.translations import Translations
from tagstudio.qt.controllers.modal_content import ModalContent
from tagstudio.qt.translations import Translations
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
from tagstudio.qt.qt_driver import QtDriver
def _item_id(item: object) -> int:
@@ -78,7 +78,7 @@ class SearchPanel[T](ModalContent):
self.setMinimumSize(300, 400)
self.connect_callbacks(self)
def connect_callbacks(self, controller: "SearchPanel[Any]") -> None: # pyright: ignore[reportExplicitAny]
def connect_callbacks(self, controller: SearchPanel[Any]) -> None: # pyright: ignore[reportExplicitAny]
self.layout().limit_combobox.currentIndexChanged.connect(controller.on_limit_changed)
self.layout().search_field.textChanged.connect(controller.on_search_query_changed)
self.layout().search_field.returnPressed.connect(
@@ -166,7 +166,7 @@ class SearchPanel[T](ModalContent):
if not query:
self.layout().search_field.setFocus()
parent: QWidget | None = self.parentWidget()
if parent is not None: # pyright: ignore[reportUnnecessaryComparison]
if parent is not None:
parent.hide()
return
@@ -11,13 +11,14 @@ from PySide6.QtGui import QColor, QFont, QPainter, QPen, QPixmap
from PySide6.QtWidgets import QSplashScreen, QWidget
from tagstudio.core.constants import BUILD_TYPE, COPYRIGHT, COPYRIGHT_COMPACT, VERSION
from tagstudio.qt.global_settings import Splash
from tagstudio.i18n.translations import Translations
from tagstudio.qt.app_settings import Splash
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.translations import Translations
logger = structlog.get_logger(__name__)
# TODO: Split view and logic.
class SplashScreen:
"""The custom splash screen widget for TagStudio."""
+117 -36
View File
@@ -1,19 +1,25 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from functools import partial
from typing import Any, override
import structlog
from PIL import Image, ImageQt
from PySide6.QtCore import Signal
from PySide6.QtGui import QShowEvent
from PySide6.QtGui import QAction, QPixmap, QShowEvent, Qt
from PySide6.QtWidgets import QGraphicsOpacityEffect, QWidget
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.utils.types import unwrap
from tagstudio.i18n.translations import Translations
from tagstudio.qt.app_settings import AppSettings
from tagstudio.qt.controllers.autofill_line_edit import QtCore, QtGui
from tagstudio.qt.controllers.modal_content import ModalContent
from tagstudio.qt.controllers.underlined_widget import UnderlinedWidget
from tagstudio.qt.global_settings import GlobalSettings
from tagstudio.qt.views.stylesheets.stylesheets import (
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.views.styles.color_overlay import auto_theme_overlay
from tagstudio.qt.views.styles.stylesheets import (
autofill_line_edit_style,
autofill_line_edit_top_style,
)
@@ -42,23 +48,36 @@ def _item_name(item: object) -> str:
class SuggestBox[T](QWidget):
item_chosen = Signal(object)
done = Signal()
done = Signal(str) # Query
def __init__(
self, library: Library, settings: GlobalSettings, placeholder_text: str = ""
) -> None:
def __init__(self, library: Library, settings: AppSettings, placeholder_text: str = "") -> None:
super().__init__()
self._lib = library
self._settings = settings
self._limit = 5
self._rm = ResourceManager()
self._limit = 25
self._is_shift_held = False
self._search_results: list[T] = []
self._selection_index = 0
self.added: list[int] = []
self.excluded: list[int] = []
self.setLayout(SuggestBoxView(placeholder_text))
self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
self._connect_callbacks()
self._keep_box_open_action = QAction(Translations["settings.keep_suggest_boxes_open"], self)
self._keep_box_open_action.setCheckable(True)
self.addAction(self._keep_box_open_action)
self.layout().search_field.addAction(self._keep_box_open_action)
self._keep_box_open_action.triggered.connect(
lambda checked: self._toggle_keep_open(checked)
)
def _toggle_keep_open(self, checked: bool) -> None:
self._settings.keep_suggest_boxes_open = checked
self._settings.save()
def set_placeholder_text(self, text: str) -> None:
self.layout().search_field.setPlaceholderText(text)
@@ -79,23 +98,70 @@ class SuggestBox[T](QWidget):
)
)
self.layout().search_field.shift_holding.connect(lambda held: self._on_shift_held(held))
self.layout().search_field.holding_shift.connect(partial(self._on_shift_held))
self.layout().search_field.index_updated.connect(partial(self._on_index_updated))
def set_hint_icon(self, icon: Image.Image | None) -> None:
if icon:
pixmap = QPixmap.fromImage(ImageQt.ImageQt(auto_theme_overlay(icon)))
self.layout().hint_icon_action.setIcon(pixmap)
else:
self.layout().hint_icon_action.setIcon(QPixmap())
def _on_shift_held(self, held: bool) -> None:
if held:
self._is_shift_held = True
opacity_effect = QGraphicsOpacityEffect(self)
opacity_effect.setOpacity(0.3)
if self.layout().content_layout.count() > 0:
underlined_widget = self.layout().content_layout.itemAt(0).widget()
assert isinstance(underlined_widget, UnderlinedWidget)
self._is_shift_held = held
for i in range(0, self.layout().content_layout.count()):
underlined_widget = self.layout().content_layout.itemAt(i).widget() # pyright: ignore
assert isinstance(underlined_widget, UnderlinedWidget)
if held and i == self._selection_index:
opacity_effect = QGraphicsOpacityEffect(self)
opacity_effect.setOpacity(0.3)
underlined_widget.widget.setGraphicsEffect(opacity_effect)
else:
self._is_shift_held = False
if self.layout().content_layout.count() > 0:
underlined_widget = self.layout().content_layout.itemAt(0).widget()
assert isinstance(underlined_widget, UnderlinedWidget)
elif (
len(self._search_results) > i
and _item_id(self._search_results[i]) not in self.added
):
underlined_widget.widget.setGraphicsEffect(None) # pyright: ignore[reportArgumentType]
self._update_hint_icon()
def _on_index_updated(self, delta: int) -> None:
# Initialize the widget count (non-hidden)
widget_count = 0
for i in range(0, self.layout().content_layout.count()):
widget = unwrap(self.layout().content_layout.itemAt(i).widget()) # pyright: ignore
if not widget.isHidden():
widget_count += 1
# Update the index
old_idx = self._selection_index
max_idx = widget_count - 1
if self._selection_index + delta < 0:
# Can't move further left
self._selection_index = 0
elif self._selection_index + delta > max_idx:
self._selection_index = max_idx
else:
self._selection_index = self._selection_index + delta
# Don't update the UI if there's no index change
if old_idx == self._selection_index:
return
# Draw the correct underline for the selected widget
for i in range(0, widget_count):
underlined_widget = self.layout().content_layout.itemAt(i).widget() # pyright: ignore
assert isinstance(underlined_widget, UnderlinedWidget)
if i == self._selection_index:
underlined_widget.toggle_underline(is_hidden=False)
self.layout().scroll_area.ensureWidgetVisible(
underlined_widget, xmargin=16, ymargin=0
)
else:
underlined_widget.toggle_underline(is_hidden=True)
self._on_shift_held(self._is_shift_held)
self._update_hint_icon()
def _clear_search_query(self) -> None:
self.layout().search_field.setText("")
@@ -104,14 +170,13 @@ class SuggestBox[T](QWidget):
raise NotImplementedError()
def _on_search_query_changed(self, query: str) -> None:
self._update_items(query)
self._update_items(query.strip())
self._update_hint_icon()
def _on_search_query_submitted(self, query: str, always_create: bool = False) -> None:
# Focus search field if no query
logger.info("Query submitted")
if not query:
self.done.emit()
self.hide_and_reset()
self.done.emit(query)
return
elif not self.isHidden():
self.layout().search_field.setFocus()
@@ -120,7 +185,7 @@ class SuggestBox[T](QWidget):
if (len(self._search_results) <= 0) or always_create:
self._on_item_create()
else:
self._on_item_chosen(self._search_results[0])
self._on_item_chosen(self._search_results[self._selection_index])
self._clear_search_query()
self._update_items()
@@ -137,9 +202,25 @@ class SuggestBox[T](QWidget):
def _is_excluded(self, item: T) -> bool:
return _item_id(item) in self.excluded
def _is_selected_item_added(self) -> bool:
return bool(
len(self._search_results) > self._selection_index
and _item_id(self._search_results[self._selection_index]) in self.added
)
def _update_hint_icon(self) -> None:
raise NotImplementedError()
def _update_items(self, query: str | None = None) -> None:
"""Update the item list given a search query."""
logger.info("[SearchPanel] Updating items", limit=self._limit)
self._selection_index = 0
if self.layout().content_layout.count() > 0:
self.layout().scroll_area.ensureWidgetVisible(
self.layout().content_layout.itemAt(0).widget(), # pyright: ignore
xmargin=16,
ymargin=0,
)
# Get results for the search query
query_lower = "" if not query else query.lower()
@@ -187,7 +268,7 @@ class SuggestBox[T](QWidget):
self.layout().search_field.setStyleSheet(autofill_line_edit_style())
else:
self.layout().scroll_area.setHidden(False)
self.layout().content_layout.setContentsMargins(6, 6, 6, 6)
self.layout().content_layout.setContentsMargins(4, 6, 4, 6)
self.layout().search_field.setStyleSheet(autofill_line_edit_top_style())
def _search_items(self, query: str) -> tuple[list[T], list[T]]: # pyright: ignore[reportUnusedParameter]
@@ -197,9 +278,10 @@ class SuggestBox[T](QWidget):
raise NotImplementedError()
def _editing_finished_callback(self) -> None:
if self.layout().search_field.text() == "":
self.done.emit()
self.hide_and_reset()
# Only trigger when the search field is clicked off of and there's no query.
# NOTE: The search field is already cleared by this point when pressing enter.
if self.layout().search_field.text() == "" and not self.layout().search_field.hasFocus():
self.done.emit("")
def _create_item_from_modal(self, edit_item_panel: ModalContent) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
@@ -213,6 +295,7 @@ class SuggestBox[T](QWidget):
self._on_shift_held(held=False)
self.layout().search_field.setDisabled(False)
self._clear_search_query()
self._keep_box_open_action.setChecked(self._settings.keep_suggest_boxes_open)
return super().showEvent(event)
@override
@@ -222,9 +305,7 @@ class SuggestBox[T](QWidget):
@override
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None:
# When Escape is pressed, focus back on the search box.
if event.key() in {
QtCore.Qt.Key.Key_Escape,
QtCore.Qt.Key.Key_Enter,
QtCore.Qt.Key.Key_Return,
}:
self.hide_and_reset()
if event.key() in {QtCore.Qt.Key.Key_Enter, QtCore.Qt.Key.Key_Return}:
self.done.emit("*")
elif event.key() == QtCore.Qt.Key.Key_Escape:
self.done.emit("")
@@ -17,17 +17,18 @@ from tagstudio.qt.mixed.build_tag import BuildTagPanel
from tagstudio.qt.views.tag_box_view import TagBoxWidgetView
if TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
from tagstudio.qt.qt_driver import QtDriver
logger = structlog.get_logger(__name__)
# TODO: Use newer MVC style guidelines
class TagBoxWidget(TagBoxWidgetView):
on_update = Signal()
__entries: list[int] = []
def __init__(self, title: str, driver: "QtDriver"):
def __init__(self, title: str, driver: QtDriver):
super().__init__(title, driver)
self.__driver = driver
@@ -88,6 +89,7 @@ class TagBoxWidget(TagBoxWidgetView):
build_tag_panel.build_tag(),
parent_ids=set(build_tag_panel.parent_ids),
aliases=set(build_tag_panel.aliases),
exclusion_ids=set(build_tag_panel.exclusion_ids),
)
self.on_update.emit()
@@ -12,11 +12,12 @@ from PySide6.QtWidgets import QMessageBox, QWidget
from tagstudio.core.constants import RESERVED_TAG_END, RESERVED_TAG_START
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Tag
from tagstudio.core.utils.types import unwrap
from tagstudio.i18n.translations import Translations
from tagstudio.qt.controllers.modal import Modal
from tagstudio.qt.controllers.modal_content import ModalContent
from tagstudio.qt.controllers.search_panel_controller import SearchPanel
from tagstudio.qt.controllers.search_panel import SearchPanel
from tagstudio.qt.mixed.tag_widget import TagWidget
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.search_panel_view import SearchPanelView
logger = structlog.get_logger(__name__)
@@ -166,7 +167,10 @@ class TagSearchPanel(SearchPanel[Tag]):
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
tag,
parent_ids=edit_item_panel.parent_ids,
aliases=edit_item_panel.aliases,
exclusion_ids=edit_item_panel.exclusion_ids,
)
if choose_item:
@@ -188,6 +192,7 @@ class TagSearchPanel(SearchPanel[Tag]):
tag=edit_item_panel.build_tag(),
parent_ids=edit_item_panel.parent_ids,
aliases=edit_item_panel.aliases,
exclusion_ids=edit_item_panel.exclusion_ids,
)
self.update_items(self.layout().search_field.text())
@@ -203,6 +208,7 @@ class TagSearchPanel(SearchPanel[Tag]):
pad_tag_widget.setHidden(True)
self.layout().scroll_layout.addWidget(pad_tag_widget)
tag_widget: QWidget = self.layout().scroll_layout.itemAt(index).widget()
item = unwrap(self.layout().scroll_layout.itemAt(index))
tag_widget: QWidget = unwrap(item.widget())
assert isinstance(tag_widget, TagWidget)
return tag_widget
+35 -15
View File
@@ -7,19 +7,20 @@ from warnings import catch_warnings
import structlog
from PySide6.QtCore import Signal
from PySide6.QtGui import QAction, Qt
from PySide6.QtGui import QAction
from PySide6.QtWidgets import QGraphicsOpacityEffect, QWidget
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Tag
from tagstudio.core.utils.types import unwrap
from tagstudio.i18n.translations import Translations
from tagstudio.qt.app_settings import AppSettings
from tagstudio.qt.controllers.modal import Modal
from tagstudio.qt.controllers.modal_content import ModalContent
from tagstudio.qt.controllers.suggest_box import SuggestBox
from tagstudio.qt.controllers.underlined_widget import UnderlinedWidget
from tagstudio.qt.global_settings import GlobalSettings
from tagstudio.qt.mixed.build_tag import BuildTagPanel
from tagstudio.qt.mixed.tag_widget import TagWidget
from tagstudio.qt.translations import Translations
logger = structlog.get_logger(__name__)
@@ -27,24 +28,21 @@ logger = structlog.get_logger(__name__)
class TagSuggestBox(SuggestBox[Tag]):
search_for_tag = Signal(int)
def __init__(
self, library: Library, settings: GlobalSettings, placeholder_text: str = ""
) -> None:
def __init__(self, library: Library, settings: AppSettings, placeholder_text: str = "") -> None:
super().__init__(library, settings, placeholder_text)
# Context Menu Actions
edit_tag_on_create_action = QAction(Translations["settings.edit_tag_on_create"], self)
edit_tag_on_create_action.setCheckable(True)
self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
self.addAction(edit_tag_on_create_action)
self.layout().search_field.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
self.layout().search_field.addAction(edit_tag_on_create_action)
edit_tag_on_create_action.setChecked(self._settings.edit_tag_on_create)
edit_tag_on_create_action.triggered.connect(
lambda checked: self.toggle_edit_on_tag_create(checked)
lambda checked: self._toggle_edit_on_tag_create(checked)
)
def toggle_edit_on_tag_create(self, checked: bool) -> None:
def _toggle_edit_on_tag_create(self, checked: bool) -> None:
"""Toggle the setting for opening the edit window after creating a tag."""
self._settings.edit_tag_on_create = checked
self._settings.save()
@@ -89,7 +87,25 @@ class TagSuggestBox(SuggestBox[Tag]):
@override
def _on_item_chosen(self, item: Tag) -> None:
self.item_chosen.emit(item.id)
self.done.emit()
self._clear_search_query()
self.done.emit("*") # The query does not matter
@override
def _update_hint_icon(self) -> None:
results = bool(len(self._search_results) > 0)
if not self._is_shift_held and self._is_selected_item_added():
self.set_hint_icon(self._rm.hint_tag_added)
elif self._is_shift_held:
self.set_hint_icon(self._rm.hint_tag_create)
elif results and self._is_selected_item_added():
self.set_hint_icon(self._rm.hint_tag_added)
elif results:
self.set_hint_icon(self._rm.hint_tag_add)
elif self.layout().search_field.text():
self.set_hint_icon(self._rm.hint_tag_create)
else:
self.set_hint_icon(None)
@override
def _search_items(self, query: str) -> tuple[list[Tag], list[Tag]]:
@@ -117,7 +133,7 @@ class TagSuggestBox(SuggestBox[Tag]):
if item is None:
return
# TODO: Add tabbing to different items, and use underline to indicate which will be added
# Select first item
underlined_widget.toggle_underline(index != 0)
# Disconnect previous callbacks
@@ -141,10 +157,12 @@ class TagSuggestBox(SuggestBox[Tag]):
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
tag,
parent_ids=edit_item_panel.parent_ids,
aliases=edit_item_panel.aliases,
exclusion_ids=edit_item_panel.exclusion_ids,
)
self._on_item_chosen(tag)
self._clear_search_query()
edit_item_panel.hide()
self._on_search_query_changed(self.layout().search_field.text())
@@ -158,6 +176,7 @@ class TagSuggestBox(SuggestBox[Tag]):
tag=edit_item_panel.build_tag(),
parent_ids=edit_item_panel.parent_ids,
aliases=edit_item_panel.aliases,
exclusion_ids=edit_item_panel.exclusion_ids,
)
self._update_items(self.layout().search_field.text())
@@ -173,6 +192,7 @@ class TagSuggestBox(SuggestBox[Tag]):
widget.setHidden(True)
self.layout().content_layout.addWidget(widget)
widget_: QWidget = self.layout().content_layout.itemAt(index).widget()
item = unwrap(self.layout().content_layout.itemAt(index))
widget_: QWidget = unwrap(item.widget())
assert isinstance(widget_, UnderlinedWidget)
return widget_
@@ -6,10 +6,13 @@ from typing import override
from PySide6.QtWidgets import QWidget
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.views.underlined_widget_view import UnderlinedWidgetView
class UnderlinedWidget(QWidget):
"""A container for a widget and an underline indicator."""
def __init__(self, widget: QWidget) -> None:
super().__init__()
self.setLayout(UnderlinedWidgetView(widget))
@@ -19,7 +22,7 @@ class UnderlinedWidget(QWidget):
@property
def widget(self) -> QWidget:
return self.layout().itemAt(0).widget()
return unwrap(unwrap(self.layout().itemAt(0)).widget())
@override
def layout(self) -> UnderlinedWidgetView:
@@ -14,15 +14,16 @@ 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.i18n.translations import Translations
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.styles.palette import ColorType, UiColor, get_ui_color
logger = structlog.get_logger(__name__)
# TODO: Split view into UpdateAvailableMessageBoxView
class UpdateAvailableMessageBox(QMessageBox):
"""A warning dialog for if the TagStudio is not running under the latest release version."""
"""A message box notifying the user if there is a new version of TagStudio available."""
def __init__(self):
super().__init__()
@@ -35,7 +35,7 @@ def get_wanted_mnemonics(text: str) -> list[str]:
return matches
def sanitise_mnemonics(actions: list[QAction]) -> None:
def sanitize_mnemonics(actions: list[QAction]) -> None:
previous = []
for action in actions:
text = action.text()
@@ -127,8 +127,8 @@ def assign_mnemonics(menu: QMenu):
# Collect actions
actions = [a for a in menu.actions() if not a.isSeparator()]
# sanitise mnemonics to prevent deadlocks
sanitise_mnemonics(actions)
# sanitize mnemonics to prevent deadlocks
sanitize_mnemonics(actions)
# Sequence map: mnemonic key -> QAction
sequence_to_action: dict[str, QAction] = {}
+23 -15
View File
@@ -26,19 +26,21 @@ from tagstudio.core.constants import (
GITHUB_REPO_URL,
VERSION,
)
from tagstudio.core.library.alchemy.constants import DB_VERSION
from tagstudio.core.ts_core import TagStudioCore
from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus
from tagstudio.core.utils.ripgrep_status import RipgrepStatus
from tagstudio.core.utils.str_formatting import is_version_outdated
from tagstudio.core.utils.types import unwrap
from tagstudio.i18n.translations import Translations
from tagstudio.qt.controllers.clickable_label import ClickableLabel
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.translations import Translations
from tagstudio.qt.utils.file_opener import open_file
from tagstudio.qt.views.stylesheets.stylesheets import form_content_style, header
from tagstudio.qt.views.styles.palette import ColorType, UiColor, get_ui_color
from tagstudio.qt.views.styles.stylesheets import form_content_style, header
# TODO: Split to use MVC guidelines.
class AboutModal(QWidget):
"""Modal window showing information about the TagStudio application."""
@@ -109,22 +111,28 @@ class AboutModal(QWidget):
self.system_info_layout.setSpacing(4)
self.system_info_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
# Version
version_title = QLabel(Translations["about.version"])
latest_version = unwrap(TagStudioCore.get_most_recent_release_version(), "0.0.0")
version_content_style = form_content_style()
if not is_version_outdated(VERSION, latest_version):
version_content = QLabel(f"{VERSION}")
# TagStudio Version
ts_version_title = QLabel(Translations["about.version"])
latest_ts_version = unwrap(TagStudioCore.get_most_recent_release_version(), "0.0.0")
ts_version_content_style = form_content_style()
if not is_version_outdated(VERSION, latest_ts_version):
ts_version_content = QLabel(f"{VERSION}")
else:
version_content = QLabel(
ts_version_content = QLabel(
Translations.format(
"about.version.latest", built_version=VERSION, latest_version=latest_version
"about.version.latest", built_version=VERSION, latest_version=latest_ts_version
)
)
version_content_style += f"color: {red};"
version_content.setStyleSheet(version_content_style)
self.system_info_layout.addRow(version_title, version_content)
version_content.setMaximumWidth(version_content.sizeHint().width())
ts_version_content_style += f"color: {red};"
ts_version_content.setStyleSheet(ts_version_content_style)
self.system_info_layout.addRow(ts_version_title, ts_version_content)
ts_version_content.setMaximumWidth(ts_version_content.sizeHint().width())
# Library DB Version
db_version_title = QLabel(Translations["about.library_version"])
db_version_content = QLabel(f"{DB_VERSION}")
db_version_content.setStyleSheet(form_content_style())
self.system_info_layout.addRow(db_version_title, db_version_content)
# Config Path
config_path_title = QLabel(f"{Translations['about.config_path']}")
+4 -3
View File
@@ -24,11 +24,11 @@ from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.core.library.alchemy.library import Library, slugify
from tagstudio.core.library.alchemy.models import TagColorGroup
from tagstudio.core.utils.types import unwrap
from tagstudio.i18n.translations import Translations
from tagstudio.qt.controllers.modal_content import ModalContent
from tagstudio.qt.mixed.tag_color_preview import TagColorPreview
from tagstudio.qt.models.palette import ColorType, get_tag_color
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.stylesheets.stylesheets import (
from tagstudio.qt.views.styles.palette import ColorType, get_tag_color
from tagstudio.qt.views.styles.stylesheets import (
checkbox_style,
line_edit_style,
list_button_style,
@@ -37,6 +37,7 @@ from tagstudio.qt.views.stylesheets.stylesheets import (
logger = structlog.get_logger(__name__)
# TODO: Split to use MVC guidelines.
class BuildColorPanel(ModalContent):
on_edit = Signal(TagColorGroup)
+3 -2
View File
@@ -13,13 +13,14 @@ from PySide6.QtWidgets import QLabel, QLineEdit, QVBoxLayout, QWidget
from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX
from tagstudio.core.library.alchemy.library import Library, ReservedNamespaceError, slugify
from tagstudio.core.library.alchemy.models import Namespace
from tagstudio.i18n.translations import Translations
from tagstudio.qt.controllers.modal_content import ModalContent
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.stylesheets.stylesheets import line_edit_style
from tagstudio.qt.views.styles.stylesheets import line_edit_style
logger = structlog.get_logger(__name__)
# TODO: Split to use MVC guidelines.
class BuildNamespacePanel(ModalContent):
on_edit = Signal(Namespace)
+186 -38
View File
@@ -14,6 +14,7 @@ from PySide6.QtWidgets import (
QButtonGroup,
QCheckBox,
QFrame,
QGraphicsOpacityEffect,
QHBoxLayout,
QLabel,
QLineEdit,
@@ -28,16 +29,17 @@ from PySide6.QtWidgets import (
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Tag, TagAlias, TagColorGroup
from tagstudio.core.utils.types import unwrap
from tagstudio.i18n.translations import Translations
from tagstudio.qt.controllers.modal import Modal
from tagstudio.qt.controllers.modal_content import ModalContent
from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchPanel
from tagstudio.qt.controllers.tag_search_panel import TagSearchPanel
from tagstudio.qt.mixed.tag_color_preview import TagColorPreview
from tagstudio.qt.mixed.tag_color_selection import TagColorSelection
from tagstudio.qt.mixed.tag_widget import TagWidget
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.search_panel_view import SearchPanelView
from tagstudio.qt.views.stylesheets.stylesheets import (
from tagstudio.qt.views.styles.stylesheets import (
checkbox_style,
colored_checkbox_style,
colored_radio_button_style,
get_tag_border_color,
get_tag_highlight_color,
@@ -75,6 +77,7 @@ class CustomTableItem(QLineEdit):
super().keyPressEvent(arg__1)
# TODO: Split to use MVC guidelines.
class BuildTagPanel(ModalContent):
on_edit = Signal(Tag)
@@ -86,9 +89,10 @@ class BuildTagPanel(ModalContent):
self.tag_color_slug: str | None
self.disambiguation_id: int | None
self.parent_ids: set[int] = set()
self.exclusion_ids: set[int] = set()
self.aliases: list[TagAlias] = []
self.setMinimumSize(300, 460)
self.setMinimumSize(300, 640)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 0, 6, 0)
self.root_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
@@ -96,7 +100,6 @@ class BuildTagPanel(ModalContent):
# Name -----------------------------------------------------------------
self.name_widget = QWidget()
self.name_layout = QVBoxLayout(self.name_widget)
self.name_layout.setStretch(1, 1)
self.name_layout.setContentsMargins(0, 0, 0, 0)
self.name_layout.setSpacing(0)
self.name_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
@@ -111,7 +114,6 @@ class BuildTagPanel(ModalContent):
# Shorthand ------------------------------------------------------------
self.shorthand_widget = QWidget()
self.shorthand_layout = QVBoxLayout(self.shorthand_widget)
self.shorthand_layout.setStretch(1, 1)
self.shorthand_layout.setContentsMargins(0, 0, 0, 0)
self.shorthand_layout.setSpacing(0)
self.shorthand_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
@@ -123,7 +125,6 @@ class BuildTagPanel(ModalContent):
# Aliases --------------------------------------------------------------
self.aliases_widget = QWidget()
self.aliases_layout = QVBoxLayout(self.aliases_widget)
self.aliases_layout.setStretch(1, 1)
self.aliases_layout.setContentsMargins(0, 0, 0, 0)
self.aliases_layout.setSpacing(0)
self.aliases_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
@@ -144,16 +145,14 @@ class BuildTagPanel(ModalContent):
# Parent Tags ----------------------------------------------------------
self.parent_tags_widget = QWidget()
self.parent_tags_widget.setMinimumHeight(128)
self.parent_tags_layout = QVBoxLayout(self.parent_tags_widget)
self.parent_tags_layout.setStretch(1, 1)
self.parent_tags_layout.setContentsMargins(0, 0, 0, 0)
self.parent_tags_layout.setSpacing(0)
self.parent_tags_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.disam_button_group = QButtonGroup(self)
self.disam_button_group.setExclusive(False)
self.parent_tags_title = QLabel(Translations["tag.parent_tags"])
self.parent_tags_title = QLabel(header(Translations["tag.parent_tags"], 3))
self.parent_tags_layout.addWidget(self.parent_tags_title)
self.scroll_contents = QWidget()
self.parent_tags_scroll_layout = QVBoxLayout(self.scroll_contents)
@@ -184,14 +183,40 @@ class BuildTagPanel(ModalContent):
self.parent_tags_add_button.clicked.connect(self.add_tag_modal.show)
# Categories -----------------------------------------------------------
self.category_widget = QWidget()
self.category_layout = QVBoxLayout(self.category_widget)
self.category_layout.setContentsMargins(0, 0, 0, 0)
self.category_layout.setSpacing(0)
self.category_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.category_layout.addWidget(QLabel(header(Translations["tag.categories"], 3)))
category_subtitle = QLabel(Translations["tag.categories.subtitle"])
opacity_effect = QGraphicsOpacityEffect(self)
opacity_effect.setOpacity(0.5)
category_subtitle.setGraphicsEffect(opacity_effect)
self.category_layout.addWidget(category_subtitle)
self.category_scroll_contents = QWidget()
self.category_scroll_layout = QVBoxLayout(self.category_scroll_contents)
self.category_scroll_layout.setContentsMargins(6, 6, 6, 0)
self.category_scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.category_scroll_area = QScrollArea()
self.category_scroll_area.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.category_scroll_area.setWidgetResizable(True)
self.category_scroll_area.setFrameShadow(QFrame.Shadow.Plain)
self.category_scroll_area.setFrameShape(QFrame.Shape.NoFrame)
self.category_scroll_area.setWidget(self.category_scroll_contents)
self.category_layout.addWidget(self.category_scroll_area)
# Color ----------------------------------------------------------------
self.color_widget = QWidget()
self.color_layout = QVBoxLayout(self.color_widget)
self.color_layout.setStretch(1, 1)
self.color_layout.setContentsMargins(0, 0, 0, 6)
self.color_layout.setSpacing(6)
self.color_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.color_title = QLabel(Translations["tag.color"])
self.color_title = QLabel(header(Translations["tag.color"], 3))
self.color_layout.addWidget(self.color_title)
self.color_button: TagColorPreview
try:
@@ -215,7 +240,6 @@ class BuildTagPanel(ModalContent):
# Category -------------------------------------------------------------
self.cat_widget = QWidget()
self.cat_layout = QHBoxLayout(self.cat_widget)
self.cat_layout.setStretch(1, 1)
self.cat_layout.setContentsMargins(0, 0, 0, 0)
self.cat_layout.setSpacing(6)
self.cat_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
@@ -229,7 +253,6 @@ class BuildTagPanel(ModalContent):
# Hidden ---------------------------------------------------------------
self.hidden_widget = QWidget()
self.hidden_layout = QHBoxLayout(self.hidden_widget)
self.hidden_layout.setStretch(1, 1)
self.hidden_layout.setContentsMargins(0, 0, 0, 0)
self.hidden_layout.setSpacing(6)
self.hidden_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
@@ -244,16 +267,33 @@ class BuildTagPanel(ModalContent):
self.root_layout.addWidget(self.name_widget)
self.root_layout.addWidget(self.shorthand_widget)
self.root_layout.addWidget(self.aliases_widget)
self.root_layout.addWidget(self.aliases_table)
self.root_layout.addWidget(self.aliases_table, stretch=1)
self.root_layout.addWidget(self.aliases_add_button)
self.root_layout.addWidget(self.parent_tags_widget)
self._add_spaced_separator()
self.root_layout.addWidget(self.parent_tags_widget, stretch=1)
self._add_spaced_separator()
self.root_layout.addWidget(self.category_widget)
self._add_spaced_separator()
self.root_layout.addWidget(self.color_widget)
self._add_spaced_separator()
self.root_layout.addWidget(QLabel(header(Translations["tag.properties"], 3)))
self.root_layout.addWidget(self.cat_widget)
self.root_layout.addWidget(self.hidden_widget)
self.set_tag(tag or Tag(name=Translations["tag.new"]))
def _add_spaced_separator(self) -> None:
sep = QFrame()
sep.setFrameShape(QFrame.Shape.HLine)
sep.setFrameShadow(QFrame.Shadow.Plain)
opacity_effect = QGraphicsOpacityEffect(self)
opacity_effect.setOpacity(0.1)
sep.setGraphicsEffect(opacity_effect)
self.root_layout.addSpacing(6)
self.root_layout.addWidget(sep)
self.root_layout.addSpacing(6)
def backspace(self):
focused_widget = QApplication.focusWidget()
row = self.aliases_table.rowCount()
@@ -285,10 +325,12 @@ class BuildTagPanel(ModalContent):
def _add_parent_tag_callback(self, tag_id: int):
self.parent_ids.add(tag_id)
self.set_parent_tags()
self.set_categories(added_parent_id=tag_id)
def _remove_parent_tag_callback(self, tag_id: int):
self.parent_ids.remove(tag_id)
self.set_parent_tags()
self.set_categories(removed_parent=True)
def _create_alias_callback(self):
alias = TagAlias("", tag_id=self.tag.id)
@@ -315,9 +357,130 @@ class BuildTagPanel(ModalContent):
self.tag_color_slug = None
self.color_button.set_tag_color_group(tag_color_group)
def set_categories(self, added_parent_id: int | None = None, removed_parent: bool = False):
while self.category_scroll_layout.itemAt(0):
self.category_scroll_layout.takeAt(0).widget().deleteLater() # pyright: ignore[reportOptionalMemberAccess]
c = QWidget()
layout = QVBoxLayout(c)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(3)
if removed_parent:
tags_by_category: dict[Tag, set[Tag]] = {}
hierarchy = set(self._lib.get_tag_hierarchy(self.parent_ids).values())
for tag in hierarchy:
if tag.is_category:
tags_by_category[tag] = set()
for tag in hierarchy:
for parent in self._lib.get_tag_hierarchy([tag.id]).values():
if parent in tags_by_category:
if tag == parent and parent.id not in self.parent_ids:
continue
tags_by_category[parent].add(tag)
for category, tags in tags_by_category.items():
if len(tags) == 0:
continue
last_tab, next_tab, container = self._build_category_row_widget(category)
layout.addWidget(container)
self.setTabOrder(last_tab, next_tab)
else:
tag_ids = set(self.parent_ids)
if added_parent_id is not None:
tag_ids.add(added_parent_id)
for tag in self._lib.get_tag_hierarchy(tag_ids).values():
if not tag.is_category or tag == self.tag:
continue
last_tab, next_tab, container = self._build_category_row_widget(tag)
layout.addWidget(container)
self.setTabOrder(last_tab, next_tab)
self.category_scroll_layout.addWidget(c)
def _build_category_row_widget(self, category: Tag) -> tuple[QPushButton, QCheckBox, QWidget]:
container = QWidget()
row = QHBoxLayout(container)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(3)
def update_parent_tag_callback(build_tag_panel: BuildTagPanel):
self._lib.update_tag(
build_tag_panel.build_tag(),
parent_ids=set(build_tag_panel.parent_ids),
aliases=set(build_tag_panel.aliases),
exclusion_ids=set(build_tag_panel.exclusion_ids),
)
self.set_categories()
def on_category_edit(category_tag: Tag) -> None:
build_tag_panel = BuildTagPanel(self._lib, tag=category_tag)
edit_modal = Modal(
build_tag_panel,
self._lib.tag_display_name(category_tag),
"Edit Tag",
is_savable=True,
)
edit_modal.saved.connect(partial(update_parent_tag_callback, build_tag_panel))
edit_modal.show()
def update_category_exclusion(category_tag: Tag, checked: bool) -> None:
if checked:
self.exclusion_ids.remove(category_tag.id)
else:
self.exclusion_ids.add(category_tag.id)
# Add Tag Widget
tag_widget = TagWidget(
category,
library=self._lib,
has_edit=True,
has_remove=False,
)
tag_widget.on_edit.connect(partial(on_category_edit, category))
row.addWidget(tag_widget)
# Add Category Exclusion Tag Button
include_checkbox = QCheckBox()
include_checkbox.setFixedSize(22, 22)
include_checkbox.setToolTip(Translations["tag.categories.tooltip"])
include_checkbox.setStyleSheet(colored_checkbox_style(*self._tag_colors(category)))
if category.id not in self.exclusion_ids:
include_checkbox.setChecked(True)
include_checkbox.toggled.connect(partial(update_category_exclusion, category))
row.addWidget(include_checkbox)
return tag_widget.bg_button, include_checkbox, container
def _tag_colors(self, tag: Tag) -> tuple[QColor, QColor, QColor, QColor]:
primary_color = get_tag_primary_color(tag)
border_color = (
get_tag_border_color(primary_color)
if not (tag.color and tag.color.secondary and tag.color.color_border)
else (QColor(tag.color.secondary))
)
highlight_color = get_tag_highlight_color(
primary_color
if not (tag.color and tag.color.secondary)
else QColor(tag.color.secondary)
)
text_color: QColor
if tag.color and tag.color.secondary:
text_color = QColor(tag.color.secondary)
else:
text_color = get_tag_text_color(primary_color, highlight_color)
return primary_color, border_color, highlight_color, text_color
def set_parent_tags(self):
while self.parent_tags_scroll_layout.itemAt(0):
self.parent_tags_scroll_layout.takeAt(0).widget().deleteLater()
self.parent_tags_scroll_layout.takeAt(0).widget().deleteLater() # pyright: ignore[reportOptionalMemberAccess]
c = QWidget()
layout = QVBoxLayout(c)
@@ -346,29 +509,12 @@ class BuildTagPanel(ModalContent):
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(3)
# Init Colors
primary_color = get_tag_primary_color(tag)
border_color = (
get_tag_border_color(primary_color)
if not (tag.color and tag.color.secondary and tag.color.color_border)
else (QColor(tag.color.secondary))
)
highlight_color = get_tag_highlight_color(
primary_color
if not (tag.color and tag.color.secondary)
else QColor(tag.color.secondary)
)
text_color: QColor
if tag.color and tag.color.secondary:
text_color = QColor(tag.color.secondary)
else:
text_color = get_tag_text_color(primary_color, highlight_color)
def update_parent_tag_callback(build_tag_panel: BuildTagPanel):
self._lib.update_tag(
build_tag_panel.build_tag(),
parent_ids=set(build_tag_panel.parent_ids),
aliases=set(build_tag_panel.aliases),
exclusion_ids=set(build_tag_panel.exclusion_ids),
)
self.set_parent_tags()
@@ -395,9 +541,7 @@ class BuildTagPanel(ModalContent):
disam_button.setObjectName(f"disambiguationButton.{parent_id}")
disam_button.setFixedSize(22, 22)
disam_button.setToolTip(Translations["tag.disambiguation.tooltip"])
disam_button.setStyleSheet(
colored_radio_button_style(primary_color, text_color, border_color, highlight_color)
)
disam_button.setStyleSheet(colored_radio_button_style(*self._tag_colors(tag)))
self.disam_button_group.addButton(disam_button)
if is_disambiguation:
@@ -478,6 +622,10 @@ class BuildTagPanel(ModalContent):
self.parent_ids.add(parent_id)
self.set_parent_tags()
for exclusion_id in tag.exclusion_ids:
self.exclusion_ids.add(exclusion_id)
self.set_categories()
try:
self.tag_color_namespace = tag.color_namespace
self.tag_color_slug = tag.color_slug
+6 -5
View File
@@ -12,13 +12,13 @@ from PySide6.QtWidgets import QMessageBox, QPushButton
from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX
from tagstudio.core.library.alchemy.models import TagColorGroup
from tagstudio.core.utils.types import unwrap
from tagstudio.i18n.translations import Translations
from tagstudio.qt.controllers.modal import Modal
from tagstudio.qt.mixed.build_color import BuildColorPanel
from tagstudio.qt.mixed.field_widget import FieldWidget
from tagstudio.qt.mixed.tag_color_label import TagColorLabel
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.layouts.flow_layout import FlowLayout
from tagstudio.qt.views.stylesheets.stylesheets import add_button_style
from tagstudio.qt.views.styles.stylesheets import add_button_style
if typing.TYPE_CHECKING:
from tagstudio.core.library.alchemy.library import Library
@@ -26,14 +26,15 @@ if typing.TYPE_CHECKING:
logger = structlog.get_logger(__name__)
# TODO: Split to use MVC guidelines.
class ColorBoxWidget(FieldWidget):
updated = Signal()
def __init__(
self,
group: str,
colors: list["TagColorGroup"],
library: "Library",
colors: list[TagColorGroup],
library: Library,
) -> None:
self.namespace = group
self.colors: list[TagColorGroup] = colors
@@ -59,7 +60,7 @@ class ColorBoxWidget(FieldWidget):
color_widgets: list[TagColorLabel] = []
while self.base_layout.itemAt(0):
unwrap(self.base_layout.takeAt(0)).widget().deleteLater()
unwrap(self.base_layout.takeAt(0)).widget().deleteLater() # pyright: ignore[reportOptionalMemberAccess]
for color in colors_:
color_widget = TagColorLabel(
+4 -3
View File
@@ -10,10 +10,10 @@ from PySide6.QtCore import QDateTime
from PySide6.QtWidgets import QDateTimeEdit, QLineEdit, QVBoxLayout
from tagstudio.qt.controllers.modal_content import ModalContent
from tagstudio.qt.views.stylesheets.stylesheets import title_line_edit_style
from tagstudio.qt.views.styles.stylesheets import title_line_edit_style
if typing.TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
from tagstudio.qt.qt_driver import QtDriver
DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S"
@@ -39,8 +39,9 @@ def qdtf2dtf(dtf: str) -> str:
return out
# TODO: Split to use MVC guidelines.
class DatetimePicker(ModalContent):
def __init__(self, driver: "QtDriver", name: str, datetime: dt | str):
def __init__(self, driver: QtDriver, name: str, datetime: dt | str):
super().__init__()
self.setMinimumSize(300, 60)
self.root_layout = QVBoxLayout(self)
+6 -12
View File
@@ -11,21 +11,14 @@ import structlog
from PySide6 import QtCore, QtGui
from PySide6.QtCore import Qt, QUrl
from PySide6.QtGui import QStandardItem, QStandardItemModel
from PySide6.QtWidgets import (
QHBoxLayout,
QLabel,
QListView,
QPushButton,
QVBoxLayout,
QWidget,
)
from PySide6.QtWidgets import QHBoxLayout, QLabel, QListView, QPushButton, QVBoxLayout, QWidget
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.mixed.progress_bar import ProgressWidget
from tagstudio.qt.translations import Translations
from tagstudio.i18n.translations import Translations
from tagstudio.qt.controllers.progress_bar import ProgressWidget
if TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
from tagstudio.qt.qt_driver import QtDriver
logger = structlog.get_logger(__name__)
@@ -37,10 +30,11 @@ class DuplicateChoice(enum.StrEnum):
CANCEL = "Cancelled"
# TODO: Split to use MVC guidelines.
class DropImportModal(QWidget):
DUPE_NAME_LIMT: int = 5
def __init__(self, driver: "QtDriver"):
def __init__(self, driver: QtDriver):
super().__init__()
self.driver: QtDriver = driver
+9 -8
View File
@@ -29,27 +29,28 @@ from tagstudio.core.library.alchemy.fields import (
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry, Tag
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.controllers.edit_text_controller import EditText
from tagstudio.i18n.translations import FIELD_TYPE_KEYS, Translations
from tagstudio.qt.controllers.edit_text import EditText
from tagstudio.qt.controllers.modal import Modal
from tagstudio.qt.controllers.tag_box_controller import TagBoxWidget
from tagstudio.qt.controllers.tag_box import TagBoxWidget
from tagstudio.qt.mixed.datetime_picker import DatetimePicker
from tagstudio.qt.mixed.field_widget import FieldContainer
from tagstudio.qt.mixed.text_field import TextContainerWidget
from tagstudio.qt.translations import FIELD_TYPE_KEYS, Translations
from tagstudio.qt.views.stylesheets.stylesheets import inset_container_style
from tagstudio.qt.views.styles.stylesheets import inset_container_style
if typing.TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
from tagstudio.qt.qt_driver import QtDriver
logger = structlog.get_logger(__name__)
# TODO: Split to use MVC guidelines.
class FieldContainers(QWidget):
"""Widget for the tag and field containers displayed inside the Preview Panel."""
on_tags_update = Signal()
def __init__(self, library: Library, driver: "QtDriver") -> None:
def __init__(self, library: Library, driver: QtDriver) -> None:
super().__init__()
self.lib = library
@@ -185,7 +186,7 @@ class FieldContainers(QWidget):
grandparent_tags: set[Tag] = set()
for parent_tag in parent_tags:
if parent_tag in categories:
if parent_tag in categories and parent_tag.id not in tag.exclusion_ids:
categories[parent_tag].add(tag)
has_category_parent = True
grandparent_tags.update(parent_tag.parent_tags)
@@ -320,7 +321,7 @@ class FieldContainers(QWidget):
text = self.driver.settings.format_datetime(
DatetimePicker.string2dt(field.value)
)
except (ValueError, AssertionError):
except ValueError, AssertionError:
text = str(field.value)
else:
text = f"<i>{Translations['field.mixed_data']}</i>"

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