Compare commits

..

26 Commits

Author SHA1 Message Date
Travis Abendshien
986ccabc81 chore: bump version to v9.5.0-pr3 2025-02-10 11:22:00 -08:00
Travis Abendshien
297fdf22e8 feat: add smartcase and globless path searches (#743)
* fix: return path_strings in session

* feat: add smartcase and globless path search

Known issues: failing tests, sluggish autocomplete

* fix: all operational searches

* fix: limit path autocomplete to 100 items

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

Currently translated at 4.6% (11 of 237 strings)

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

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (237 of 237 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (236 of 236 strings)

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

---------

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

* ui: optimize badge updating

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

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

* chore: remove log statements

* refactor: use Qt enum in place of magic number

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

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

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

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

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

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

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

* docs: refer to alternate shells in CONTRIBUTING.md

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

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

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

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

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

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

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

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

Ref: #392, #464

* fix: I forgot to run ruff format

* revert: changes to docs and gitignore

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

Refs: 15f4f38, a25ef8c

* docs: other shells and pyenv in CONTRIBUTING.md

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

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

* revert: accidental inclution of #464

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

* translations: translate file deletion actions

* fix: rename method from refactor conflict

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

* chore: address mypy warnings

* fix: order results from sql before limiting

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

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

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

* update code comments and docstrings

* feat(ui): add tag view limit dropdown

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

* feat: return parent tags in tag search

Known issue: this bypasses the tag_limit

* refactor: use consistant `datetime` imports

* refactor: sort by base tag name to improve performance

* fix: escape `&` when displaying tag names

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

* fix: optimize and fix tag result sorting

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

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

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

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

* ui: move disambiguation button to right side

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

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

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

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

* refactor: move lambda to local function

* fix: catch `ParsingError` and return no results

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

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

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

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

4
.gitignore vendored
View File

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

View File

@@ -44,15 +44,30 @@ If you wish to launch the source version of TagStudio outside of your IDE:
> [!TIP]
> On Linux and macOS, you can launch the `tagstudio.sh` script to skip the following process, minus the `requirements-dev.txt` installation step. _Using the script is fine if you just want to launch the program from source._
1. In the root repository directory, create a python virtual environment:
1. Make sure you're using the correct Python version:
- If the output matches `Python 3.12.x` (where the x is any number) then you're using the correct Python version and can skip to step 2. Otherwise, you can install the correct Python version from the [Python](https://www.python.org/downloads/) website, or you can use a tool like [pyenv](https://github.com/pyenv/pyenv/) to install the correct version without changes to your system:
1. Follow pyenv's [install instructions](https://github.com/pyenv/pyenv/?tab=readme-ov-file#installation) for your system.
2. Install the appropriate Python version with pyenv by running `pyenv install 3.12` (This will **not** mess with your existing Python installation).
3. Navigate to the repository root folder in your terminal and run `pyenv local 3.12`.
- You could alternatively use `pyenv shell 3.12` or `pyenv global 3.12` instead to set the Python version for the current terminal session or the entire system respectively, however using `local` is recommended.
2. In the root repository directory, create a python virtual environment:
`python3 -m venv .venv`
2. Activate your environment:
3. Activate your environment:
- Windows w/Powershell: `.venv\Scripts\Activate.ps1`
- Windows w/Command Prompt: `.venv\Scripts\activate.bat`
- Linux/macOS: `source .venv/bin/activate`
Depending on your system, the regular activation script *might* not work on alternative shells. In this case, refer to the table below for supported shells:
|Shell |Script |
|-------:|:------------------------|
|Bash/ZSH|`.venv/bin/activate` |
|Fish |`.venv/bin/activate.fish`|
|CSH/TCSH|`.venv/bin/activate.csh` |
|PWSH |`.venv/bin/activate.ps1` |
3. Install the required packages:
4. Install the required packages:
- `pip install -r requirements.txt`
- If developing (includes Ruff and Mypy): `pip install -r requirements-dev.txt`
@@ -61,6 +76,8 @@ _Learn more about setting up a virtual environment [here](https://docs.python.or
### Manually Launching (Outside of an IDE)
If you encounter errors about the Python version, or seemingly vague script errors, [pyenv](https://github.com/pyenv/pyenv/) may solve your issue. See step 1 of [Creating a Python Virtual Environment](#creating-a-python-virtual-environment).
- **Windows** (start_win.bat)
- To launch TagStudio, launch the `start_win.bat` file. You can modify this .bat file or create a shortcut and add one or more additional arguments if desired.

View File

@@ -82,7 +82,7 @@ Translation hosting generously provided by [Weblate](https://weblate.org/en/). C
### Search
- Search for file entries based on tags, file path (`path:`), file types (`filetype:`), and even media types! (`mediatype:`)
- Search for file entries based on tags, file path (`path:`), file types (`filetype:`), and even media types! (`mediatype:`). Path searches currently use [glob](<https://en.wikipedia.org/wiki/Glob_(programming)>) syntax, so you may need to wrap your filename or filepath in asterisks while searching. This will not be strictly necessary in future versions of the program.
- Use and combine boolean operators (`AND`, `OR`, `NOT`) along with parentheses groups, quotation escaping, and underscore substitution to create detailed search queries
- Use special search conditions (`special:untagged` and `special:empty`) to find file entries without tags or fields, respectively
@@ -166,6 +166,10 @@ Create a new tag by accessing the "New Tag" option from the Edit menu or by pres
- The **color** option lets you select an optional color palette to use for your tag.
- The **"Is Cagegory"** property lets you treat this tag as a category under which itself and any child tags inheriting from it will be sorted by inside the preview panel.
#### Tag Manager
You can manage your library of tags from opening the "Tag Manager" panel from Edit -> "Tag Manager". From here you can create, search for, edit, and permanently delete any tags you've created in your library.
### Editing Tags
To edit a tag, click on it inside the preview panel or right-click the tag and select “Edit Tag” from the context menu.

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,93 @@
# Library Search
## Boolean Operators
TagStudio allows you to use common [boolean search](https://en.wikipedia.org/wiki/Full-text_search#Boolean_queries) operators when searching your library, along with [grouping](#grouping-and-nesting), [nesting](#grouping-and-nesting), and [character escaping](#escaping-characters). Note that you may need to use grouping in order to get the desired results you're looking for.
### AND
The `AND` operator will only return results that match **both** sides of the operator. `AND` is used implicitly when no boolean operators are given. To use the `AND` operator explicitly, simply type "and" (case insensitive) in-between items of your search.
> For example, searching for "Tag1 Tag2" will be treated the same as "Tag1 `AND` Tag2" and will only return results that contain both Tag1 and Tag2.
### OR
The `OR` operator will return results that match **either** the left or right side of the operator. To use the `OR` operator simply type "or" (case insensitive) in-between items of your search.
> For example, searching for "Tag1 `OR` Tag2" will return results that contain either "Tag1", "Tag2", or both.
### NOT
The `NOT` operator will returns results where the condition on the right is **false.** To use the `NOT` operator simply type "not" (case insensitive) in-between items of your search. You can also begin your search with `NOT` to only view results that do not contain the next term that follows.
> For example, searching for "Tag1 `NOT` Tag2" will only return results that contain "Tag1" while also not containing "Tag2".
### Grouping and Nesting
Searches can be grouped and nested by using parentheses to surround parts of your search query.
> For example, searching for "(Tag1 `OR` Tag2) `AND` Tag3" will return results any results that contain Tag3, plus one or the other (or both) of Tag1 and Tag2.
### Escaping Characters
Sometimes search queries have ambiguous characters and need to be "escaped". This is most common with tag names which contain spaces, or overlap with existing search keywords such as "[path:](#filename--filepath) of exile". To escape most search terms, surround the section of your search in plain quotes. Alternatively, spaces in tag names can be replaced by underscores.
#### Valid Escaped Tag Searches
- "Tag Name With Spaces"
- Tag_Name_With_Spaces
#### Invalid Escaped Tag Searches
- Tag Name With Spaces
- Reason: Ambiguity between a tag named "Tag Name With Spaces" and four individual tags called "Tag", "Name", "With", "Spaces".
## Tags
[Tag](#tags) search is the default mode of file entry search in TagStudio. No keyword prefix is required, however using `tag:` will also work. The tag search attempts to match tag [names](tag.md#name), [shorthands](tag.md#shorthand), [aliases](tag.md#aliases), as well as allows for tags to [substitute](tag.md#intuition-via-substitution) in for any of their [parent tags](tag.md#parent-tags).
You may also see the `tag_id:` prefix keyword show up with using the right-click "Search for Tag" option on tags. This is meant for internal use, and eventually will not be displayed or accessible to the user.
## Fields
_[Field](field.md) search is currently not in the program, however is coming in a future version._
## File Entry Search
### Filename + Filepath
Currently (v9.5.0-PR1) the filepath search uses [glob](<https://en.wikipedia.org/wiki/Glob_(programming)>) syntax, meaning you'll likely have to wrap your filename or partial filepath inside asterisks for results to appear. This search is also currently case sensitive. Use the `path:` keyword prefix followed by the filename or path, with asterisks surrounding partial names.
#### Examples
Given a file "artwork/piece.jpg", these searches will return results with it:
- `path: artwork/piece.jpg` _(Note how no asterisks are required if the full path is given)_
- `path: *piece.jpg*`
- `path: *artwork*`
- `path: *rtwor*`
- `path: *ece.jpg*`
- `path: *iec*`
And these (currently) won't:
- `path: piece.jpg`
- `path: piece.jpg`
- `path: artwork`
- `path: rtwor`
- `path: ece.jpg`
- `path: iec`
## Special Searches
"Special" searches use the `special:` keyword prefix and give quick results for certain special search queries.
### Untagged
To see all your file entries which don't contain any tags, use the `special:untagged` search.
### Empty
**_NOTE:_** _Currently unavailable in v9.5.0-PR1_
To see all your file entries which don't contain any tags _and_ any fields, use the `special:empty` search.

View File

@@ -37,7 +37,11 @@ Create a new tag by accessing the "New Tag" option from the Edit menu or by pres
- Parent tags with the disambiguation check next to them will be used to help disambiguate tag names that may not be unique.
- For example: If you had a tag for "Freddy Fazbear", you might add "Five Nights at Freddy's" as one of the parent tags. If the disambiguation box is checked next to "Five Nights at Freddy's" parent tag, then the tag "Freddy Fazbear" will display as "Freddy Fazbear (Five Nights at Freddy's)". Furthermore, if the "Five Nights at Freddy's" tag has a shorthand like "FNAF", then the "Freddy Fazbear" tag will display as "Freddy Fazbear (FNAF)".
- The **color** option lets you select an optional color palette to use for your tag.
- The **"Is Cagegory"** property lets you treat this tag as a category under which itself and any child tags inheriting from it will be sorted by inside the preview panel.
- The **"Is Category"** property lets you treat this tag as a category under which itself and any child tags inheriting from it will be sorted by inside the preview panel.
### Tag Manager
You can manage your library of tags from opening the "Tag Manager" panel from Edit -> "Tag Manager". From here you can create, search for, edit, and permanently delete any tags you've created in your library.
## Editing Tags

View File

@@ -1,3 +1,10 @@
[project]
name = "TagStudio"
description = "A User-Focused Photo & File Management System."
version = "9.5.0-pre3"
license = "GPL-3.0-only"
readme = "README.md"
[tool.ruff]
exclude = ["main_window.py", "home_ui.py", "resources.py", "resources_rc.py"]
line-length = 100
@@ -10,28 +17,8 @@ line-length = 100
convention = "google"
[tool.ruff.lint]
select = [
"B",
"D",
"E",
"F",
"FBT003",
"I",
"N",
"SIM",
"T20",
"UP",
]
ignore = [
"D100",
"D101",
"D102",
"D103",
"D104",
"D105",
"D106",
"D107",
]
select = ["B", "D", "E", "F", "FBT003", "I", "N", "SIM", "T20", "UP"]
ignore = ["D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107"]
[tool.mypy]
strict_optional = false

View File

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

View File

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

Binary file not shown.

View File

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

View File

@@ -157,6 +157,9 @@
"macros.running.dialog.new_entries": "Running Configured Macros on {count}/{total} New File Entries...",
"macros.running.dialog.title": "Running Macros on New Entries",
"media_player.autoplay": "Autoplay",
"menu.delete_selected_files_ambiguous": "Move File(s) to {trash_term}",
"menu.delete_selected_files_plural": "Move Files to {trash_term}",
"menu.delete_selected_files_singular": "Move File to {trash_term}",
"menu.edit.ignore_list": "Ignore Files and Folders",
"menu.edit.manage_file_extensions": "Manage File Extensions",
"menu.edit.manage_tags": "Manage Tags",
@@ -195,6 +198,11 @@
"sorting.direction.ascending": "Ascending",
"sorting.direction.descending": "Descending",
"splash.opening_library": "Opening Library \"{library_path}\"...",
"status.deleted_file_plural": "Deleted {count} files!",
"status.deleted_file_singular": "Deleted 1 file!",
"status.deleted_none": "No files deleted.",
"status.deleted_partial_warning": "Only deleted {count} file(s)! Check if any of the files are currently missing or in use.",
"status.deleting_file": "Deleting file [{i}/{count}]: \"{path}\"...",
"status.library_backup_in_progress": "Saving Library Backup...",
"status.library_backup_success": "Library Backup Saved at: \"{path}\" ({time_span})",
"status.library_closed": "Library Closed ({time_span})",
@@ -205,12 +213,14 @@
"status.library_version_found": "Found:",
"status.library_version_mismatch": "Library Version Mismatch!",
"status.results_found": "{count} Results Found ({time_span})",
"status.results.invalid_syntax": "Invalid Search Syntax:",
"status.results": "Results",
"tag_manager.title": "Library Tags",
"tag.add_to_search": "Add to Search",
"tag.add.plural": "Add Tags",
"tag.add": "Add Tag",
"tag.aliases": "Aliases",
"tag.all_tags": "All Tags",
"tag.choose_color": "Choose Tag Color",
"tag.color": "Color",
"tag.confirm_delete": "Are you sure you want to delete the tag \"{tag_name}\"?",
@@ -227,6 +237,19 @@
"tag.search_for_tag": "Search for Tag",
"tag.shorthand": "Shorthand",
"tag.tag_name_required": "Tag Name (Required)",
"tag.view_limit": "View Limit:",
"trash.context.ambiguous": "Move file(s) to {trash_term}",
"trash.context.plural": "Move files to {trash_term}",
"trash.context.singular": "Move file to {trash_term}",
"trash.dialog.disambiguation_warning.plural": "This will remove them from TagStudio <i>AND</i> your file system!",
"trash.dialog.disambiguation_warning.singular": "This will remove it from TagStudio <i>AND</i> your file system!",
"trash.dialog.move.confirmation.plural": "Are you sure you want to move these {count} files to the {trash_term}?",
"trash.dialog.move.confirmation.singular": "Are you sure you want to move this file to the {trash_term}?",
"trash.dialog.permanent_delete_warning": "<b>WARNING!</b> If this file can't be moved to the {trash_term}, <b>it will be <b>permanently deleted!</b>",
"trash.dialog.title.plural": "Delete Files",
"trash.dialog.title.singular": "Delete File",
"trash.name.generic": "Trash",
"trash.name.windows": "Recycle Bin",
"view.size.0": "Mini",
"view.size.1": "Small",
"view.size.2": "Medium",

View File

@@ -13,6 +13,8 @@
"drop_import.progress.label.singular": "Új fájlok importálása folyamatban…\n1 fájl importálva.{suffix}",
"drop_import.progress.window_title": "Fájlok importálása",
"drop_import.title": "Fájlütközés",
"edit.copy_fields": "Mezők másolása",
"edit.paste_fields": "Mezők beillesztése",
"edit.tag_manager": "Címkék kezelése",
"entries.duplicate.merge": "Egyező elemek &egyesítése",
"entries.duplicate.merge.label": "Egyező elemek egyesítése folyamatban…",
@@ -203,6 +205,7 @@
"status.library_version_found": "Tényleges érték:",
"status.library_version_mismatch": "A könyvtár és a program verziója nem egyezik.",
"status.results": "találat",
"status.results.invalid_syntax": "Szintaktikai hiba:",
"status.results_found": "{count} találat ({time_span})",
"tag.add": "Címke hozzáadása",
"tag.add.plural": "Címkék hozzáadása",

View File

@@ -3,7 +3,7 @@
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
VERSION: str = "9.5.0" # Major.Minor.Patch
VERSION_BRANCH: str = "Pre-Release 1" # Usually "" or "Pre-Release"
VERSION_BRANCH: str = "Pre-Release 3" # Usually "" or "Pre-Release"
# The folder & file names where TagStudio keeps its data relative to a library.
TS_FOLDER_NAME: str = ".TagStudio"

View File

@@ -71,4 +71,4 @@ class LibraryPrefs(DefaultEnum):
IS_EXCLUDE_LIST = True
EXTENSION_LIST: list[str] = [".json", ".xmp", ".aae"]
PAGE_SIZE: int = 500
DB_VERSION: int = 6
DB_VERSION: int = 7

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ from dataclasses import dataclass
from datetime import UTC, datetime
from os import makedirs
from pathlib import Path
from typing import TYPE_CHECKING
from uuid import uuid4
from warnings import catch_warnings
@@ -31,6 +32,7 @@ from sqlalchemy import (
func,
or_,
select,
text,
update,
)
from sqlalchemy.exc import IntegrityError
@@ -68,8 +70,24 @@ from .joins import TagEntry, TagParent
from .models import Entry, Folder, Namespace, Preferences, Tag, TagAlias, TagColorGroup, ValueType
from .visitors import SQLBoolExpressionBuilder
if TYPE_CHECKING:
from sqlalchemy import Select
logger = structlog.get_logger(__name__)
TAG_CHILDREN_QUERY = text("""
-- Note for this entire query that tag_parents.child_id is the parent id and tag_parents.parent_id is the child id due to bad naming
WITH RECURSIVE ChildTags AS (
SELECT :tag_id AS child_id
UNION ALL
SELECT tp.parent_id AS child_id
FROM tag_parents tp
INNER JOIN ChildTags c ON tp.child_id = c.child_id
)
SELECT * FROM ChildTags;
""") # noqa: E501
def slugify(input_string: str) -> str:
# Convert to lowercase and normalize unicode characters
@@ -246,7 +264,7 @@ class Library:
for k, v in field.items():
# Old tag fields get added as tags
if k in LEGACY_TAG_FIELD_IDS:
self.add_tags_to_entry(entry_id=entry.id + 1, tag_ids=v)
self.add_tags_to_entries(entry_ids=entry.id + 1, tag_ids=v)
else:
self.add_field_to_entry(
entry_id=(entry.id + 1), # JSON IDs start at 0 instead of 1
@@ -318,6 +336,7 @@ class Library:
# https://docs.sqlalchemy.org/en/20/changelog/migration_07.html
# Under -> sqlite-the-sqlite-dialect-now-uses-nullpool-for-file-based-databases
poolclass = None if self.storage_path == ":memory:" else NullPool
db_version: int = 0
logger.info(
"[Library] Opening SQLite Library",
@@ -328,11 +347,13 @@ class Library:
with Session(self.engine) as session:
# dont check db version when creating new library
if not is_new:
db_version = session.scalar(
db_result = session.scalar(
select(Preferences).where(Preferences.key == LibraryPrefs.DB_VERSION.name)
)
if db_result:
db_version = db_result.value # type: ignore
if not db_version or db_version.value != LibraryPrefs.DB_VERSION.default:
if db_version < 6: # NOTE: DB_VERSION 6 is the first supported SQL DB version.
mismatch_text = Translations.translate_formatted(
"status.library_version_mismatch"
)
@@ -344,15 +365,14 @@ class Library:
success=False,
message=(
f"{mismatch_text}\n"
f"{found_text} v{0 if not db_version else db_version.value}, "
f"{found_text} v{db_version}, "
f"{expected_text} v{LibraryPrefs.DB_VERSION.default}"
),
)
logger.info(f"[Library] DB_VERSION: {db_version}")
make_tables(self.engine)
# TODO: Determine a good way of updating built-in data after updates.
# Add default tag color namespaces.
if is_new:
namespaces = default_color_groups.namespaces()
@@ -421,14 +441,52 @@ class Library:
)
session.add(folder)
session.expunge(folder)
session.commit()
self.folder = folder
# Apply any post-SQL migration patches.
if not is_new:
# NOTE: DB_VERSION 6 was first used in v9.5.0-pr1
if db_version == 6:
self.apply_db6_patches(session)
else:
pass
# Update DB_VERSION
self.set_prefs(LibraryPrefs.DB_VERSION, LibraryPrefs.DB_VERSION.default)
# everything is fine, set the library path
self.library_dir = library_dir
return LibraryStatus(success=True, library_path=library_dir)
def apply_db6_patches(self, session: Session):
"""Apply migration patches to a library with DB_VERSION 6.
DB_VERSION 6 was first used in v9.5.0-pr1.
"""
logger.info("[Library] Applying patches to DB_VERSION: 6 library...")
with session:
# Repair "Description" fields with a TEXT_LINE key instead of a TEXT_BOX key.
desc_stmd = (
update(ValueType)
.where(ValueType.key == _FieldID.DESCRIPTION.name)
.values(type=FieldTypeEnum.TEXT_BOX.name)
)
session.execute(desc_stmd)
session.flush()
# Repair tags that may have a disambiguation_id pointing towards a deleted tag.
all_tag_ids: set[int] = {tag.id for tag in self.tags}
disam_stmt = (
update(Tag)
.where(Tag.disambiguation_id.not_in(all_tag_ids))
.values(disambiguation_id=None)
)
session.execute(disam_stmt)
session.flush()
session.commit()
@property
def default_fields(self) -> list[BaseField]:
with Session(self.engine) as session:
@@ -460,30 +518,49 @@ class Library:
self, entry_id: int, with_fields: bool = True, with_tags: bool = True
) -> Entry | None:
"""Load entry and join with all joins and all tags."""
# NOTE: TODO: Currently this method makes multiple separate queries to the db and combines
# those into a final Entry object (if using "with" args). This was done due to it being
# much more efficient than the existing join query, however there likely exists a single
# query that can accomplish the same task without exhibiting the same slowdown.
with Session(self.engine) as session:
statement = select(Entry).where(Entry.id == entry_id)
tags: set[Tag] | None = None
tag_stmt: Select[tuple[Tag]]
entry_stmt = select(Entry).where(Entry.id == entry_id).limit(1)
if with_fields:
statement = (
statement.outerjoin(Entry.text_fields)
entry_stmt = (
entry_stmt.outerjoin(Entry.text_fields)
.outerjoin(Entry.datetime_fields)
.options(selectinload(Entry.text_fields), selectinload(Entry.datetime_fields))
)
# if with_tags:
# entry_stmt = entry_stmt.outerjoin(Entry.tags).options(selectinload(Entry.tags))
if with_tags:
statement = (
statement.outerjoin(Entry.tags)
.outerjoin(TagAlias)
.options(
selectinload(Entry.tags).options(
joinedload(Tag.aliases),
joinedload(Tag.parent_tags),
)
tag_stmt = select(Tag).where(
and_(
TagEntry.tag_id == Tag.id,
TagEntry.entry_id == entry_id,
)
)
entry = session.scalar(statement)
start_time = time.time()
entry = session.scalar(entry_stmt)
if with_tags:
tags = set(session.scalars(tag_stmt)) # pyright: ignore [reportPossiblyUnboundVariable]
end_time = time.time()
logger.info(
f"[Library] Time it took to get entry: "
f"{format_timespan(end_time-start_time, max_units=5)}",
with_fields=with_fields,
with_tags=with_tags,
)
if not entry:
return None
session.expunge(entry)
make_transient(entry)
# Recombine the separately queried tags with the base entry object.
if with_tags and tags:
entry.tags = tags
return entry
def get_entries_full(self, entry_ids: list[int] | set[int]) -> Iterator[Entry]:
@@ -639,12 +716,15 @@ class Library:
with Session(self.engine) as session:
return session.query(exists().where(Entry.path == path)).scalar()
def get_paths(self, glob: str | None = None) -> list[str]:
def get_paths(self, glob: str | None = None, limit: int = -1) -> list[str]:
path_strings: list[str] = []
with Session(self.engine) as session:
paths = session.scalars(select(Entry.path)).unique()
path_strings: list[str] = list(map(lambda x: x.as_posix(), paths))
return path_strings
if limit > 0:
paths = session.scalars(select(Entry.path).limit(limit)).unique()
else:
paths = session.scalars(select(Entry.path)).unique()
path_strings = list(map(lambda x: x.as_posix(), paths))
return path_strings
def search_library(
self,
@@ -711,19 +791,16 @@ class Library:
return res
def search_tags(
self,
name: str | None,
) -> list[Tag]:
def search_tags(self, name: str | None, limit: int = 100) -> list[set[Tag]]:
"""Return a list of Tag records matching the query."""
tag_limit = 100
with Session(self.engine) as session:
query = select(Tag).outerjoin(TagAlias)
query = select(Tag).outerjoin(TagAlias).order_by(func.lower(Tag.name))
query = query.options(
selectinload(Tag.parent_tags),
selectinload(Tag.aliases),
).limit(tag_limit)
)
if limit > 0:
query = query.limit(limit)
if name:
query = query.where(
@@ -734,12 +811,28 @@ class Library:
)
)
tags = session.scalars(query)
res = list(set(tags))
direct_tags = set(session.scalars(query))
ancestor_tag_ids: list[Tag] = []
for tag in direct_tags:
ancestor_tag_ids.extend(
list(session.scalars(TAG_CHILDREN_QUERY, {"tag_id": tag.id}))
)
ancestor_tags = session.scalars(
select(Tag)
.where(Tag.id.in_(ancestor_tag_ids))
.options(selectinload(Tag.parent_tags), selectinload(Tag.aliases))
)
res = [
direct_tags,
{at for at in ancestor_tags if at not in direct_tags},
]
logger.info(
"searching tags",
search=name,
limit=limit,
statement=str(query),
results=len(res),
)
@@ -792,6 +885,14 @@ class Library:
session.delete(child_tag)
session.expunge(child_tag)
disam_stmt = (
update(Tag)
.where(Tag.disambiguation_id == tag.id)
.values(disambiguation_id=None)
)
session.execute(disam_stmt)
session.flush()
session.delete(tag)
session.commit()
session.expunge(tag)
@@ -1014,41 +1115,49 @@ class Library:
session.rollback()
return None
def add_tags_to_entry(self, entry_id: int, tag_ids: int | list[int] | set[int]) -> bool:
"""Add one or more tags to an entry."""
tag_ids = [tag_ids] if isinstance(tag_ids, int) else tag_ids
def add_tags_to_entries(
self, entry_ids: int | list[int], tag_ids: int | list[int] | set[int]
) -> bool:
"""Add one or more tags to one or more entries."""
entry_ids_ = [entry_ids] if isinstance(entry_ids, int) else entry_ids
tag_ids_ = [tag_ids] if isinstance(tag_ids, int) else tag_ids
with Session(self.engine, expire_on_commit=False) as session:
for tag_id in tag_ids:
try:
session.add(TagEntry(tag_id=tag_id, entry_id=entry_id))
session.flush()
except IntegrityError:
session.rollback()
for tag_id in tag_ids_:
for entry_id in entry_ids_:
try:
session.add(TagEntry(tag_id=tag_id, entry_id=entry_id))
session.flush()
except IntegrityError:
session.rollback()
try:
session.commit()
except IntegrityError as e:
logger.warning("[add_tags_to_entry]", warning=e)
logger.warning("[Library][add_tags_to_entries]", warning=e)
session.rollback()
return False
return True
def remove_tags_from_entry(self, entry_id: int, tag_ids: int | list[int] | set[int]) -> bool:
"""Remove one or more tags from an entry."""
def remove_tags_from_entries(
self, entry_ids: int | list[int], tag_ids: int | list[int] | set[int]
) -> bool:
"""Remove one or more tags from one or more entries."""
entry_ids_ = [entry_ids] if isinstance(entry_ids, int) else entry_ids
tag_ids_ = [tag_ids] if isinstance(tag_ids, int) else tag_ids
with Session(self.engine, expire_on_commit=False) as session:
try:
for tag_id in tag_ids_:
tag_entry = session.scalars(
select(TagEntry).where(
and_(
TagEntry.tag_id == tag_id,
TagEntry.entry_id == entry_id,
for entry_id in entry_ids_:
tag_entry = session.scalars(
select(TagEntry).where(
and_(
TagEntry.tag_id == tag_id,
TagEntry.entry_id == entry_id,
)
)
)
).first()
if tag_entry:
session.delete(tag_entry)
session.commit()
).first()
if tag_entry:
session.delete(tag_entry)
session.flush()
session.commit()
return True
except IntegrityError as e:
@@ -1256,7 +1365,7 @@ class Library:
value=field.value,
)
tag_ids = [tag.id for tag in from_entry.tags]
self.add_tags_to_entry(into_entry.id, tag_ids)
self.add_tags_to_entries(into_entry.id, tag_ids)
self.remove_entries([from_entry.id])
@property

View File

@@ -2,7 +2,7 @@
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import datetime as dt
from datetime import datetime as dt
from pathlib import Path
from sqlalchemy import JSON, ForeignKey, ForeignKeyConstraint, Integer, event
@@ -185,9 +185,9 @@ class Entry(Base):
path: Mapped[Path] = mapped_column(PathType, unique=True)
suffix: Mapped[str] = mapped_column()
date_created: Mapped[dt.datetime | None]
date_modified: Mapped[dt.datetime | None]
date_added: Mapped[dt.datetime | None]
date_created: Mapped[dt | None]
date_modified: Mapped[dt | None]
date_added: Mapped[dt | None]
tags: Mapped[set[Tag]] = relationship(secondary="tag_entries")
@@ -222,9 +222,9 @@ class Entry(Base):
folder: Folder,
fields: list[BaseField],
id: int | None = None,
date_created: dt.datetime | None = None,
date_modified: dt.datetime | None = None,
date_added: dt.datetime | None = None,
date_created: dt | None = None,
date_modified: dt | None = None,
date_added: dt | None = None,
) -> None:
self.path = path
self.folder = folder

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,10 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import structlog
from PySide6 import QtCore, QtGui
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QHBoxLayout,
@@ -16,7 +18,10 @@ from PySide6.QtWidgets import (
from src.core.library import Library
from src.qt.translations import Translations
logger = structlog.get_logger(__name__)
# NOTE: This class doesn't inherit from PanelWidget? Seems like it predates that system?
class AddFieldModal(QWidget):
done = Signal(list)
@@ -35,11 +40,7 @@ class AddFieldModal(QWidget):
self.title_widget = QLabel()
self.title_widget.setObjectName("fieldTitle")
self.title_widget.setWordWrap(True)
self.title_widget.setStyleSheet(
# 'background:blue;'
# 'text-align:center;'
"font-weight:bold;" "font-size:14px;" "padding-top: 6px" ""
)
self.title_widget.setStyleSheet("font-weight:bold;" "font-size:14px;" "padding-top: 6px;")
Translations.translate_qobject(self.title_widget, "library.field.add")
self.title_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
@@ -50,18 +51,13 @@ class AddFieldModal(QWidget):
self.button_layout.setContentsMargins(6, 6, 6, 6)
self.button_layout.addStretch(1)
# self.cancel_button = QPushButton()
# self.cancel_button.setText('Cancel')
self.cancel_button = QPushButton()
Translations.translate_qobject(self.cancel_button, "generic.cancel")
self.cancel_button.clicked.connect(self.hide)
# self.cancel_button.clicked.connect(widget.reset)
self.button_layout.addWidget(self.cancel_button)
self.save_button = QPushButton()
Translations.translate_qobject(self.save_button, "generic.add")
# self.save_button.setAutoDefault(True)
self.save_button.setDefault(True)
self.save_button.clicked.connect(self.hide)
self.save_button.clicked.connect(
@@ -74,8 +70,6 @@ class AddFieldModal(QWidget):
self.root_layout.addWidget(self.title_widget)
self.root_layout.addWidget(self.list_widget)
# self.root_layout.setStretch(1,2)
self.root_layout.addStretch(1)
self.root_layout.addWidget(self.button_container)
@@ -85,5 +79,13 @@ class AddFieldModal(QWidget):
item = QListWidgetItem(f"{df.name} ({df.type.value})")
item.setData(Qt.ItemDataRole.UserRole, df.key)
self.list_widget.addItem(item)
self.list_widget.setFocus()
super().show()
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
if event.key() == QtCore.Qt.Key.Key_Escape:
self.cancel_button.click()
else: # Other key presses
pass
return super().keyPressEvent(event)

View File

@@ -386,6 +386,16 @@ class BuildTagPanel(PanelWidget):
else:
text_color = get_text_color(primary_color, highlight_color)
# Add Tag Widget
tag_widget = TagWidget(
tag,
library=self.lib,
has_edit=False,
has_remove=True,
)
tag_widget.on_remove.connect(lambda t=parent_id: self.remove_parent_tag_callback(t))
row.addWidget(tag_widget)
# Add Disambiguation Tag Button
disam_button = QRadioButton()
disam_button.setObjectName(f"disambiguationButton.{parent_id}")
@@ -412,6 +422,15 @@ class BuildTagPanel(PanelWidget):
f"QRadioButton::hover{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"}}"
f"QRadioButton::pressed{{"
f"background: rgba{border_color.toTuple()};"
f"color: rgba{primary_color.toTuple()};"
f"border-color: rgba{primary_color.toTuple()};"
f"}}"
f"QRadioButton::focus{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"outline:none;"
f"}}"
)
self.disam_button_group.addButton(disam_button)
@@ -421,18 +440,7 @@ class BuildTagPanel(PanelWidget):
disam_button.clicked.connect(lambda checked=False: self.toggle_disam_id(parent_id))
row.addWidget(disam_button)
# Add Tag Widget
tag_widget = TagWidget(
tag,
library=self.lib,
has_edit=False,
has_remove=True,
)
tag_widget.on_remove.connect(lambda t=parent_id: self.remove_parent_tag_callback(t))
row.addWidget(tag_widget)
return disam_button, tag_widget.bg_button, container
return tag_widget.bg_button, disam_button, container
def toggle_disam_id(self, disambiguation_id: int | None):
if self.disambiguation_id == disambiguation_id:

View File

@@ -1,9 +1,10 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import typing
from typing import TYPE_CHECKING, override
from PySide6 import QtCore, QtGui
from PySide6.QtCore import Qt, QThreadPool, Signal
from PySide6.QtGui import QStandardItem, QStandardItemModel
from PySide6.QtWidgets import (
@@ -20,7 +21,7 @@ from src.qt.translations import Translations
from src.qt.widgets.progress import ProgressWidget
# Only import for type checking/autocompletion, will not be imported at runtime.
if typing.TYPE_CHECKING:
if TYPE_CHECKING:
from src.qt.ts_qt import QtDriver
@@ -111,3 +112,11 @@ class DeleteUnlinkedEntriesModal(QWidget):
self.done.emit(),
)
)
@override
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
if event.key() == QtCore.Qt.Key.Key_Escape:
self.cancel_button.click()
else: # Other key presses
pass
return super().keyPressEvent(event)

View File

@@ -1,12 +1,14 @@
# Copyright (C) 2025
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import enum
import shutil
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, override
import structlog
from PySide6 import QtCore, QtGui
from PySide6.QtCore import Qt, QUrl
from PySide6.QtGui import QStandardItem, QStandardItemModel
from PySide6.QtWidgets import (
@@ -232,3 +234,11 @@ class DropImportModal(QWidget):
)
index += 1
return filepath.name
@override
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
if event.key() == QtCore.Qt.Key.Key_Escape:
self.cancel_button.click()
else: # Other key presses
pass
return super().keyPressEvent(event)

View File

@@ -1,10 +1,11 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import typing
from typing import TYPE_CHECKING, override
from PySide6 import QtCore, QtGui
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QFileDialog,
@@ -20,7 +21,7 @@ from src.qt.modals.mirror_entities import MirrorEntriesModal
from src.qt.translations import Translations
# Only import for type checking/autocompletion, will not be imported at runtime.
if typing.TYPE_CHECKING:
if TYPE_CHECKING:
from src.qt.ts_qt import QtDriver
@@ -135,3 +136,11 @@ class FixDupeFilesModal(QWidget):
self.dupe_count.setText(
Translations.translate_formatted("file.duplicates.matches", count=count)
)
@override
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
if event.key() == QtCore.Qt.Key.Key_Escape:
self.done_button.click()
else: # Other key presses
pass
return super().keyPressEvent(event)

View File

@@ -1,10 +1,11 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import typing
from typing import TYPE_CHECKING, override
from PySide6 import QtCore, QtGui
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
from src.core.library import Library
@@ -16,7 +17,7 @@ from src.qt.translations import Translations
from src.qt.widgets.progress import ProgressWidget
# Only import for type checking/autocompletion, will not be imported at runtime.
if typing.TYPE_CHECKING:
if TYPE_CHECKING:
from src.qt.ts_qt import QtDriver
@@ -144,3 +145,11 @@ class FixUnlinkedEntriesModal(QWidget):
"entries.unlinked.missing_count.some", count=self.missing_count
)
)
@override
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
if event.key() == QtCore.Qt.Key.Key_Escape:
self.done_button.click()
else: # Other key presses
pass
return super().keyPressEvent(event)

View File

@@ -4,10 +4,12 @@
import math
import typing
from collections.abc import Sequence
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, override
import structlog
from PySide6 import QtCore, QtGui
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QFrame,
@@ -25,7 +27,7 @@ from src.core.palette import ColorType, get_tag_color
from src.qt.flowlayout import FlowLayout
from src.qt.translations import Translations
if typing.TYPE_CHECKING:
if TYPE_CHECKING:
from src.qt.ts_qt import QtDriver
logger = structlog.get_logger(__name__)
@@ -73,7 +75,7 @@ def folders_to_tags(library: Library):
tag = add_folders_to_tree(library, tree, folders).tag
if tag and not entry.has_tag(tag):
library.add_tags_to_entry(entry.id, tag.id)
library.add_tags_to_entries(entry.id, tag.id)
logger.info("Done")
@@ -104,7 +106,7 @@ def generate_preview_data(library: Library) -> BranchData:
branch.dirs[tag.name] = BranchData(tag=tag)
branch = branch.dirs[tag.name]
def _add_folders_to_tree(items: typing.Sequence[str]) -> BranchData:
def _add_folders_to_tree(items: Sequence[str]) -> BranchData:
branch = tree
for folder in items:
if folder not in branch.dirs:
@@ -245,6 +247,14 @@ class FoldersToTagsModal(QWidget):
if isinstance(child, TreeItem):
child.set_all_branches(hidden)
@override
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
if event.key() == QtCore.Qt.Key.Key_Escape:
self.close()
else: # Other key presses
pass
return super().keyPressEvent(event)
class TreeItem(QWidget):
def __init__(self, data: BranchData, parent_tag: Tag | None = None):

View File

@@ -18,19 +18,19 @@ logger = structlog.get_logger(__name__)
# TODO: Once this class is removed, the `is_tag_chooser` option of `TagSearchPanel`
# will most likely be enabled in every case
# and the possibilty of disabling it can therefore be removed
# and the possibility of disabling it can therefore be removed
class TagDatabasePanel(TagSearchPanel):
def __init__(self, library: Library):
def __init__(self, driver, library: Library):
super().__init__(library, is_tag_chooser=False)
self.driver = driver
self.create_tag_button = QPushButton()
Translations.translate_qobject(self.create_tag_button, "tag.create")
self.create_tag_button.clicked.connect(lambda: self.build_tag(self.search_field.text()))
self.root_layout.addWidget(self.create_tag_button)
self.update_tags()
def build_tag(self, name: str):
panel = BuildTagPanel(self.lib)
@@ -39,7 +39,7 @@ class TagDatabasePanel(TagSearchPanel):
has_save=True,
)
Translations.translate_with_setter(self.modal.setTitle, "tag.new")
Translations.translate_with_setter(self.modal.setWindowTitle, "tag.add")
Translations.translate_with_setter(self.modal.setWindowTitle, "tag.new")
if name.strip():
panel.name_field.setText(name)

View File

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

View File

@@ -1,4 +1,4 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
@@ -9,10 +9,17 @@ import platform
from src.qt.translations import Translations
class PlatformStrings:
open_file_str: str = Translations["file.open_location.generic"]
def open_file_str() -> str:
if platform.system() == "Windows":
open_file_str = Translations["file.open_location.windows"]
return Translations["file.open_location.windows"]
elif platform.system() == "Darwin":
open_file_str = Translations["file.open_location.mac"]
return Translations["file.open_location.mac"]
else:
return Translations["file.open_location.generic"]
def trash_term() -> str:
if platform.system() == "Windows":
return Translations["trash.name.windows"]
else:
return Translations["trash.name.generic"]

View File

@@ -7,6 +7,7 @@
"""A Qt driver for TagStudio."""
import contextlib
import ctypes
import dataclasses
import math
@@ -16,6 +17,7 @@ import sys
import time
from pathlib import Path
from queue import Queue
from warnings import catch_warnings
# this import has side-effect of import PySide resources
import src.qt.resources_rc # noqa: F401
@@ -66,12 +68,15 @@ from src.core.library.alchemy.enums import (
from src.core.library.alchemy.fields import _FieldID
from src.core.library.alchemy.library import Entry, LibraryStatus
from src.core.media_types import MediaCategories
from src.core.palette import ColorType, UiColor, get_ui_color
from src.core.query_lang.util import ParsingError
from src.core.ts_core import TagStudioCore
from src.core.utils.refresh_dir import RefreshDirTracker
from src.core.utils.web import strip_web_protocol
from src.qt.cache_manager import CacheManager
from src.qt.flowlayout import FlowLayout
from src.qt.helpers.custom_runnable import CustomRunnable
from src.qt.helpers.file_deleter import delete_file
from src.qt.helpers.function_iterator import FunctionIterator
from src.qt.main_window import Ui_MainWindow
from src.qt.modals.about import AboutModal
@@ -84,6 +89,7 @@ from src.qt.modals.fix_unlinked import FixUnlinkedEntriesModal
from src.qt.modals.folders_to_tags import FoldersToTagsModal
from src.qt.modals.tag_database import TagDatabasePanel
from src.qt.modals.tag_search import TagSearchPanel
from src.qt.platform_strings import trash_term
from src.qt.resource_manager import ResourceManager
from src.qt.splash import Splash
from src.qt.translations import Translations
@@ -135,6 +141,8 @@ class QtDriver(DriverMixin, QObject):
SIGTERM = Signal()
preview_panel: PreviewPanel
tag_manager_panel: PanelModal
file_extension_panel: PanelModal | None = None
tag_search_panel: TagSearchPanel
add_tag_modal: PanelModal
@@ -210,7 +218,7 @@ class QtDriver(DriverMixin, QObject):
def init_workers(self):
"""Init workers for rendering thumbnails."""
if not self.thumb_threads:
max_threads = os.cpu_count()
max_threads = os.cpu_count() or 1
for i in range(max_threads):
thread = Consumer(self.thumb_job_queue)
thread.setObjectName(f"ThumbRenderer_{i}")
@@ -290,10 +298,24 @@ class QtDriver(DriverMixin, QObject):
icon.addFile(str(icon_path))
app.setWindowIcon(icon)
# Initialize the main window's tag search panel
# Initialize the Tag Manager panel
self.tag_manager_panel = PanelModal(
widget=TagDatabasePanel(self, self.lib),
done_callback=lambda: self.preview_panel.update_widgets(update_preview=False),
has_save=False,
)
Translations.translate_with_setter(self.tag_manager_panel.setTitle, "tag_manager.title")
Translations.translate_with_setter(
self.tag_manager_panel.setWindowTitle, "tag_manager.title"
)
# Initialize the Tag Search panel
self.tag_search_panel = TagSearchPanel(self.lib, is_tag_chooser=True)
self.tag_search_panel.set_driver(self)
self.add_tag_modal = PanelModal(
self.tag_search_panel, Translations.translate_formatted("tag.add.plural")
widget=self.tag_search_panel,
title=Translations.translate_formatted("tag.add.plural"),
window_title=Translations.translate_formatted("tag.add.plural"),
)
self.tag_search_panel.tag_chosen.connect(
lambda t: (
@@ -352,12 +374,12 @@ class QtDriver(DriverMixin, QObject):
file_menu.addSeparator()
save_library_backup_action = QAction(menu_bar)
Translations.translate_qobject(save_library_backup_action, "menu.file.save_backup")
save_library_backup_action.triggered.connect(
self.save_library_backup_action = QAction(menu_bar)
Translations.translate_qobject(self.save_library_backup_action, "menu.file.save_backup")
self.save_library_backup_action.triggered.connect(
lambda: self.callback_library_needed_check(self.backup_library)
)
save_library_backup_action.setShortcut(
self.save_library_backup_action.setShortcut(
QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier(
QtCore.Qt.KeyboardModifier.ControlModifier
@@ -366,65 +388,71 @@ class QtDriver(DriverMixin, QObject):
QtCore.Qt.Key.Key_S,
)
)
save_library_backup_action.setStatusTip("Ctrl+Shift+S")
file_menu.addAction(save_library_backup_action)
self.save_library_backup_action.setStatusTip("Ctrl+Shift+S")
self.save_library_backup_action.setEnabled(False)
file_menu.addAction(self.save_library_backup_action)
file_menu.addSeparator()
add_new_files_action = QAction(menu_bar)
Translations.translate_qobject(add_new_files_action, "menu.file.refresh_directories")
add_new_files_action.triggered.connect(
self.refresh_dir_action = QAction(menu_bar)
Translations.translate_qobject(self.refresh_dir_action, "menu.file.refresh_directories")
self.refresh_dir_action.triggered.connect(
lambda: self.callback_library_needed_check(self.add_new_files_callback)
)
add_new_files_action.setShortcut(
self.refresh_dir_action.setShortcut(
QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
QtCore.Qt.Key.Key_R,
)
)
add_new_files_action.setStatusTip("Ctrl+R")
file_menu.addAction(add_new_files_action)
self.refresh_dir_action.setStatusTip("Ctrl+R")
self.refresh_dir_action.setEnabled(False)
file_menu.addAction(self.refresh_dir_action)
file_menu.addSeparator()
close_library_action = QAction(menu_bar)
Translations.translate_qobject(close_library_action, "menu.file.close_library")
close_library_action.triggered.connect(self.close_library)
file_menu.addAction(close_library_action)
self.close_library_action = QAction(menu_bar)
Translations.translate_qobject(self.close_library_action, "menu.file.close_library")
self.close_library_action.triggered.connect(self.close_library)
self.close_library_action.setEnabled(False)
file_menu.addAction(self.close_library_action)
file_menu.addSeparator()
# Edit Menu ============================================================
new_tag_action = QAction(menu_bar)
Translations.translate_qobject(new_tag_action, "menu.edit.new_tag")
new_tag_action.triggered.connect(lambda: self.add_tag_action_callback())
new_tag_action.setShortcut(
self.new_tag_action = QAction(menu_bar)
Translations.translate_qobject(self.new_tag_action, "menu.edit.new_tag")
self.new_tag_action.triggered.connect(lambda: self.add_tag_action_callback())
self.new_tag_action.setShortcut(
QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
QtCore.Qt.Key.Key_T,
)
)
new_tag_action.setToolTip("Ctrl+T")
edit_menu.addAction(new_tag_action)
self.new_tag_action.setToolTip("Ctrl+T")
self.new_tag_action.setEnabled(False)
edit_menu.addAction(self.new_tag_action)
edit_menu.addSeparator()
select_all_action = QAction(menu_bar)
Translations.translate_qobject(select_all_action, "select.all")
select_all_action.triggered.connect(self.select_all_action_callback)
select_all_action.setShortcut(
self.select_all_action = QAction(menu_bar)
Translations.translate_qobject(self.select_all_action, "select.all")
self.select_all_action.triggered.connect(self.select_all_action_callback)
self.select_all_action.setShortcut(
QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
QtCore.Qt.Key.Key_A,
)
)
select_all_action.setToolTip("Ctrl+A")
edit_menu.addAction(select_all_action)
self.select_all_action.setToolTip("Ctrl+A")
self.select_all_action.setEnabled(False)
edit_menu.addAction(self.select_all_action)
clear_select_action = QAction(menu_bar)
Translations.translate_qobject(clear_select_action, "select.clear")
clear_select_action.triggered.connect(self.clear_select_action_callback)
clear_select_action.setShortcut(QtCore.Qt.Key.Key_Escape)
clear_select_action.setToolTip("Esc")
edit_menu.addAction(clear_select_action)
self.clear_select_action = QAction(menu_bar)
Translations.translate_qobject(self.clear_select_action, "select.clear")
self.clear_select_action.triggered.connect(self.clear_select_action_callback)
self.clear_select_action.setShortcut(QtCore.Qt.Key.Key_Escape)
self.clear_select_action.setToolTip("Esc")
self.clear_select_action.setEnabled(False)
edit_menu.addAction(self.clear_select_action)
self.copy_buffer: dict = {"fields": [], "tags": []}
@@ -474,17 +502,36 @@ class QtDriver(DriverMixin, QObject):
edit_menu.addSeparator()
manage_file_extensions_action = QAction(menu_bar)
self.delete_file_action = QAction(menu_bar)
Translations.translate_qobject(
manage_file_extensions_action, "menu.edit.manage_file_extensions"
self.delete_file_action, "menu.delete_selected_files_ambiguous", trash_term=trash_term()
)
manage_file_extensions_action.triggered.connect(self.show_file_extension_modal)
edit_menu.addAction(manage_file_extensions_action)
self.delete_file_action.triggered.connect(lambda f="": self.delete_files_callback(f))
self.delete_file_action.setShortcut(QtCore.Qt.Key.Key_Delete)
self.delete_file_action.setEnabled(False)
edit_menu.addAction(self.delete_file_action)
tag_database_action = QAction(menu_bar)
Translations.translate_qobject(tag_database_action, "menu.edit.manage_tags")
tag_database_action.triggered.connect(lambda: self.show_tag_database())
edit_menu.addAction(tag_database_action)
edit_menu.addSeparator()
self.manage_file_ext_action = QAction(menu_bar)
Translations.translate_qobject(
self.manage_file_ext_action, "menu.edit.manage_file_extensions"
)
edit_menu.addAction(self.manage_file_ext_action)
self.manage_file_ext_action.setEnabled(False)
self.tag_manager_action = QAction(menu_bar)
Translations.translate_qobject(self.tag_manager_action, "menu.edit.manage_tags")
self.tag_manager_action.triggered.connect(self.tag_manager_panel.show)
self.tag_manager_action.setShortcut(
QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
QtCore.Qt.Key.Key_M,
)
)
self.tag_manager_action.setEnabled(False)
self.tag_manager_action.setToolTip("Ctrl+M")
edit_menu.addAction(self.tag_manager_action)
# View Menu ============================================================
show_libs_list_action = QAction(menu_bar)
@@ -514,32 +561,37 @@ class QtDriver(DriverMixin, QObject):
self.unlinked_modal = FixUnlinkedEntriesModal(self.lib, self)
self.unlinked_modal.show()
fix_unlinked_entries_action = QAction(menu_bar)
self.fix_unlinked_entries_action = QAction(menu_bar)
Translations.translate_qobject(
fix_unlinked_entries_action, "menu.tools.fix_unlinked_entries"
self.fix_unlinked_entries_action, "menu.tools.fix_unlinked_entries"
)
fix_unlinked_entries_action.triggered.connect(create_fix_unlinked_entries_modal)
tools_menu.addAction(fix_unlinked_entries_action)
self.fix_unlinked_entries_action.triggered.connect(create_fix_unlinked_entries_modal)
self.fix_unlinked_entries_action.setEnabled(False)
tools_menu.addAction(self.fix_unlinked_entries_action)
def create_dupe_files_modal():
if not hasattr(self, "dupe_modal"):
self.dupe_modal = FixDupeFilesModal(self.lib, self)
self.dupe_modal.show()
fix_dupe_files_action = QAction(menu_bar)
Translations.translate_qobject(fix_dupe_files_action, "menu.tools.fix_duplicate_files")
fix_dupe_files_action.triggered.connect(create_dupe_files_modal)
tools_menu.addAction(fix_dupe_files_action)
self.fix_dupe_files_action = QAction(menu_bar)
Translations.translate_qobject(self.fix_dupe_files_action, "menu.tools.fix_duplicate_files")
self.fix_dupe_files_action.triggered.connect(create_dupe_files_modal)
self.fix_dupe_files_action.setEnabled(False)
tools_menu.addAction(self.fix_dupe_files_action)
tools_menu.addSeparator()
# TODO: Move this to a settings screen.
clear_thumb_cache_action = QAction(menu_bar)
Translations.translate_qobject(clear_thumb_cache_action, "settings.clear_thumb_cache.title")
clear_thumb_cache_action.triggered.connect(
self.clear_thumb_cache_action = QAction(menu_bar)
Translations.translate_qobject(
self.clear_thumb_cache_action, "settings.clear_thumb_cache.title"
)
self.clear_thumb_cache_action.triggered.connect(
lambda: CacheManager.clear_cache(self.lib.library_dir)
)
tools_menu.addAction(clear_thumb_cache_action)
self.clear_thumb_cache_action.setEnabled(False)
tools_menu.addAction(self.clear_thumb_cache_action)
# create_collage_action = QAction("Create Collage", menu_bar)
# create_collage_action.triggered.connect(lambda: self.create_collage())
@@ -560,10 +612,11 @@ class QtDriver(DriverMixin, QObject):
self.folders_modal = FoldersToTagsModal(self.lib, self)
self.folders_modal.show()
folders_to_tags_action = QAction(menu_bar)
Translations.translate_qobject(folders_to_tags_action, "menu.macros.folders_to_tags")
folders_to_tags_action.triggered.connect(create_folders_tags_modal)
macros_menu.addAction(folders_to_tags_action)
self.folders_to_tags_action = QAction(menu_bar)
Translations.translate_qobject(self.folders_to_tags_action, "menu.macros.folders_to_tags")
self.folders_to_tags_action.triggered.connect(create_folders_tags_modal)
self.folders_to_tags_action.setEnabled(False)
macros_menu.addAction(self.folders_to_tags_action)
# Help Menu ============================================================
def create_about_modal():
@@ -659,24 +712,26 @@ class QtDriver(DriverMixin, QObject):
# in a global dict for methods to access for different DPIs.
# adj_font_size = math.floor(12 * self.main_window.devicePixelRatio())
def _filter_items():
try:
self.filter_items(
FilterState.from_search_query(self.main_window.searchField.text())
.with_sorting_mode(self.sorting_mode)
.with_sorting_direction(self.sorting_direction)
)
except ParsingError as e:
self.main_window.statusbar.showMessage(
f"{Translations["status.results.invalid_syntax"]} "
f"\"{self.main_window.searchField.text()}\""
)
logger.error("[QtDriver] Could not filter items", error=e)
# Search Button
search_button: QPushButton = self.main_window.searchButton
search_button.clicked.connect(
lambda: self.filter_items(
FilterState.from_search_query(self.main_window.searchField.text())
.with_sorting_mode(self.sorting_mode)
.with_sorting_direction(self.sorting_direction)
)
)
search_button.clicked.connect(_filter_items)
# Search Field
search_field: QLineEdit = self.main_window.searchField
search_field.returnPressed.connect(
lambda: self.filter_items(
FilterState.from_search_query(self.main_window.searchField.text())
.with_sorting_mode(self.sorting_mode)
.with_sorting_direction(self.sorting_direction)
)
)
search_field.returnPressed.connect(_filter_items)
# Sorting Dropdowns
sort_mode_dropdown: QComboBox = self.main_window.sorting_mode_combobox
for sort_mode in SortingModeEnum:
@@ -724,6 +779,27 @@ class QtDriver(DriverMixin, QObject):
self.splash.finish(self.main_window)
def init_file_extension_manager(self):
"""Initialize the File Extension panel."""
if self.file_extension_panel:
with catch_warnings(record=True):
self.manage_file_ext_action.triggered.disconnect()
self.file_extension_panel.saved.disconnect()
self.file_extension_panel.deleteLater()
self.file_extension_panel = None
panel = FileExtensionModal(self.lib)
self.file_extension_panel = PanelModal(
panel,
has_save=True,
)
Translations.translate_with_setter(self.file_extension_panel.setTitle, "ignore_list.title")
Translations.translate_with_setter(
self.file_extension_panel.setWindowTitle, "ignore_list.title"
)
self.file_extension_panel.saved.connect(lambda: (panel.save(), self.filter_items()))
self.manage_file_ext_action.triggered.connect(self.file_extension_panel.show)
def show_grid_filenames(self, value: bool):
for thumb in self.item_thumbs:
thumb.set_filename_visibility(value)
@@ -778,13 +854,31 @@ class QtDriver(DriverMixin, QObject):
self.main_window.setWindowTitle(self.base_title)
self.selected = []
self.frame_content = []
self.selected.clear()
self.frame_content.clear()
[x.set_mode(None) for x in self.item_thumbs]
self.set_clipboard_menu_viability()
self.set_select_actions_visibility()
self.preview_panel.update_widgets()
self.main_window.toggle_landing_page(enabled=True)
self.main_window.pagination.setHidden(True)
try:
self.save_library_backup_action.setEnabled(False)
self.close_library_action.setEnabled(False)
self.refresh_dir_action.setEnabled(False)
self.tag_manager_action.setEnabled(False)
self.manage_file_ext_action.setEnabled(False)
self.new_tag_action.setEnabled(False)
self.fix_unlinked_entries_action.setEnabled(False)
self.fix_dupe_files_action.setEnabled(False)
self.clear_thumb_cache_action.setEnabled(False)
self.folders_to_tags_action.setEnabled(False)
except AttributeError:
logger.warning(
"[Library] Could not disable library management menu actions. Is this in a test?"
)
# NOTE: Doesn't try to disable during tests
if self.add_tag_to_selected_action:
@@ -843,13 +937,13 @@ class QtDriver(DriverMixin, QObject):
self.set_macro_menu_viability()
self.set_clipboard_menu_viability()
self.set_add_to_selected_visibility()
self.set_select_actions_visibility()
self.preview_panel.update_widgets(update_preview=False)
def clear_select_action_callback(self):
self.selected.clear()
self.set_add_to_selected_visibility()
self.set_select_actions_visibility()
for item in self.item_thumbs:
item.thumb_button.set_selected(False)
@@ -858,30 +952,142 @@ class QtDriver(DriverMixin, QObject):
self.preview_panel.update_widgets()
def add_tags_to_selected_callback(self, tag_ids: list[int]):
for entry_id in self.selected:
self.lib.add_tags_to_entry(entry_id, tag_ids)
self.lib.add_tags_to_entries(self.selected, tag_ids)
def show_tag_database(self):
self.modal = PanelModal(
widget=TagDatabasePanel(self.lib),
done_callback=lambda: self.preview_panel.update_widgets(update_preview=False),
has_save=False,
def delete_files_callback(self, origin_path: str | Path, origin_id: int | None = None):
"""Callback to send on or more files to the system trash.
If 0-1 items are currently selected, the origin_path is used to delete the file
from the originating context menu item.
If there are currently multiple items selected,
then the selection buffer is used to determine the files to be deleted.
Args:
origin_path(str): The file path associated with the widget making the call.
May or may not be the file targeted, depending on the selection rules.
origin_id(id): The entry ID associated with the widget making the call.
"""
entry: Entry | None = None
pending: list[tuple[int, Path]] = []
deleted_count: int = 0
if len(self.selected) <= 1 and origin_path:
origin_id_ = origin_id
if not origin_id_:
with contextlib.suppress(IndexError):
origin_id_ = self.selected[0]
pending.append((origin_id_, Path(origin_path)))
elif (len(self.selected) > 1) or (len(self.selected) <= 1):
for item in self.selected:
entry = self.lib.get_entry(item)
filepath: Path = entry.path
pending.append((item, filepath))
if pending:
return_code = self.delete_file_confirmation(len(pending), pending[0][1])
# If there was a confirmation and not a cancellation
if (
return_code == QMessageBox.ButtonRole.DestructiveRole.value
and return_code != QMessageBox.ButtonRole.ActionRole.value
):
for i, tup in enumerate(pending):
e_id, f = tup
if (origin_path == f) or (not origin_path):
self.preview_panel.thumb.stop_file_use()
if delete_file(self.lib.library_dir / f):
self.main_window.statusbar.showMessage(
Translations.translate_formatted(
"status.deleting_file", i=i, count=len(pending), path=f
)
)
self.main_window.statusbar.repaint()
self.lib.remove_entries([e_id])
deleted_count += 1
self.selected.clear()
if deleted_count > 0:
self.filter_items()
self.preview_panel.update_widgets()
if len(self.selected) <= 1 and deleted_count == 0:
self.main_window.statusbar.showMessage(Translations["status.deleted_none"])
elif len(self.selected) <= 1 and deleted_count == 1:
self.main_window.statusbar.showMessage(
Translations.translate_formatted("status.deleted_file_plural", count=deleted_count)
)
elif len(self.selected) > 1 and deleted_count == 0:
self.main_window.statusbar.showMessage(Translations["status.deleted_none"])
elif len(self.selected) > 1 and deleted_count < len(self.selected):
self.main_window.statusbar.showMessage(
Translations.translate_formatted(
"status.deleted_partial_warning", count=deleted_count
)
)
elif len(self.selected) > 1 and deleted_count == len(self.selected):
self.main_window.statusbar.showMessage(
Translations.translate_formatted("status.deleted_file_plural", count=deleted_count)
)
self.main_window.statusbar.repaint()
def delete_file_confirmation(self, count: int, filename: Path | None = None) -> int:
"""A confirmation dialogue box for deleting files.
Args:
count(int): The number of files to be deleted.
filename(Path | None): The filename to show if only one file is to be deleted.
"""
# NOTE: Windows + send2trash will PERMANENTLY delete files which cannot be moved to the
# Recycle Bin. This is done without any warning, so this message is currently the
# best way I've got to inform the user.
# https://github.com/arsenetar/send2trash/issues/28
# This warning is applied to all platforms until at least macOS and Linux can be verified
# to not exhibit this same behavior.
perm_warning_msg = Translations.translate_formatted(
"trash.dialog.permanent_delete_warning", trash_term=trash_term()
)
Translations.translate_with_setter(self.modal.setTitle, "tag_manager.title")
Translations.translate_with_setter(self.modal.setWindowTitle, "tag_manager.title")
self.modal.show()
def show_file_extension_modal(self):
panel = FileExtensionModal(self.lib)
self.modal = PanelModal(
panel,
has_save=True,
perm_warning: str = (
f"<h4 style='color: {get_ui_color(ColorType.PRIMARY, UiColor.RED)}'>"
f"{perm_warning_msg}</h4>"
)
Translations.translate_with_setter(self.modal.setTitle, "ignore_list.title")
Translations.translate_with_setter(self.modal.setWindowTitle, "ignore_list.title")
self.modal.saved.connect(lambda: (panel.save(), self.filter_items()))
self.modal.show()
msg = QMessageBox()
msg.setStyleSheet("font-weight:normal;")
msg.setTextFormat(Qt.TextFormat.RichText)
msg.setWindowTitle(
Translations["trash.title.singular"]
if count == 1
else Translations["trash.title.plural"]
)
msg.setIcon(QMessageBox.Icon.Warning)
if count <= 1:
msg_text = Translations.translate_formatted(
"trash.dialog.move.confirmation.singular", trash_term=trash_term()
)
msg.setText(
f"<h3>{msg_text}</h3>"
f"<h4>{Translations["trash.dialog.disambiguation_warning.singular"]}</h4>"
f"{filename if filename else ''}"
f"{perm_warning}<br>"
)
elif count > 1:
msg_text = Translations.translate_formatted(
"trash.dialog.move.confirmation.plural",
count=count,
trash_term=trash_term(),
)
msg.setText(
f"<h3>{msg_text}</h3>"
f"<h4>{Translations["trash.dialog.disambiguation_warning.plural"]}</h4>"
f"{perm_warning}<br>"
)
yes_button: QPushButton = msg.addButton("&Yes", QMessageBox.ButtonRole.YesRole)
msg.addButton("&No", QMessageBox.ButtonRole.NoRole)
msg.setDefaultButton(yes_button)
return msg.exec()
def add_new_files_callback(self):
"""Run when user initiates adding new files to the Library."""
@@ -1152,7 +1358,7 @@ class QtDriver(DriverMixin, QObject):
exists = True
if not exists:
self.lib.add_field_to_entry(id, field_id=field.type_key, value=field.value)
self.lib.add_tags_to_entry(id, self.copy_buffer["tags"])
self.lib.add_tags_to_entries(id, self.copy_buffer["tags"])
if len(self.selected) > 1:
if TAG_ARCHIVED in self.copy_buffer["tags"]:
self.update_badges({BadgeType.ARCHIVED: True}, origin_id=0, add_tags=False)
@@ -1232,7 +1438,7 @@ class QtDriver(DriverMixin, QObject):
self.set_macro_menu_viability()
self.set_clipboard_menu_viability()
self.set_add_to_selected_visibility()
self.set_select_actions_visibility()
self.preview_panel.update_widgets()
@@ -1249,14 +1455,23 @@ class QtDriver(DriverMixin, QObject):
else:
self.paste_fields_action.setEnabled(False)
def set_add_to_selected_visibility(self):
def set_select_actions_visibility(self):
if not self.add_tag_to_selected_action:
return
if self.frame_content:
self.select_all_action.setEnabled(True)
else:
self.select_all_action.setEnabled(False)
if self.selected:
self.add_tag_to_selected_action.setEnabled(True)
self.clear_select_action.setEnabled(True)
self.delete_file_action.setEnabled(True)
else:
self.add_tag_to_selected_action.setEnabled(False)
self.clear_select_action.setEnabled(False)
self.delete_file_action.setEnabled(False)
def update_completions_list(self, text: str) -> None:
matches = re.search(
@@ -1290,7 +1505,9 @@ class QtDriver(DriverMixin, QObject):
elif query_type == "tag_id":
completion_list = list(map(lambda x: prefix + "tag_id:" + str(x.id), self.lib.tags))
elif query_type == "path":
completion_list = list(map(lambda x: prefix + "path:" + x, self.lib.get_paths()))
completion_list = list(
map(lambda x: prefix + "path:" + x, self.lib.get_paths(limit=100))
)
elif query_type == "mediatype":
single_word_completions = map(
lambda x: prefix + "mediatype:" + x.name,
@@ -1364,6 +1581,9 @@ class QtDriver(DriverMixin, QObject):
if not entry:
continue
with catch_warnings(record=True):
item_thumb.delete_action.triggered.disconnect()
item_thumb.set_mode(ItemType.ENTRY)
item_thumb.set_item_id(entry.id)
item_thumb.show()
@@ -1409,6 +1629,11 @@ class QtDriver(DriverMixin, QObject):
)
)
)
item_thumb.delete_action.triggered.connect(
lambda checked=False, f=filenames[index], e_id=entry.id: self.delete_files_callback(
f, e_id
)
)
# Restore Selected Borders
is_selected = item_thumb.item_id in self.selected
@@ -1426,14 +1651,41 @@ class QtDriver(DriverMixin, QObject):
the items. Defaults to True.
"""
item_ids = self.selected if (not origin_id or origin_id in self.selected) else [origin_id]
pending_entries: dict[BadgeType, list[int]] = {}
logger.info(
"[QtDriver][update_badges] Updating ItemThumb badges",
badge_values=badge_values,
origin_id=origin_id,
add_tags=add_tags,
)
for it in self.item_thumbs:
if it.item_id in item_ids:
for badge_type, value in badge_values.items():
if add_tags:
if not pending_entries.get(badge_type):
pending_entries[badge_type] = []
pending_entries[badge_type].append(it.item_id)
it.toggle_item_tag(it.item_id, value, BADGE_TAGS[badge_type])
it.assign_badge(badge_type, value)
if not add_tags:
return
logger.info(
"[QtDriver][update_badges] Adding tags to updated entries",
pending_entries=pending_entries,
)
for badge_type, value in badge_values.items():
if value:
self.lib.add_tags_to_entries(
pending_entries.get(badge_type, []), BADGE_TAGS[badge_type]
)
else:
self.lib.remove_tags_from_entries(
pending_entries.get(badge_type, []), BADGE_TAGS[badge_type]
)
def filter_items(self, filter: FilterState | None = None) -> None:
if not self.lib.library_dir:
logger.info("Library not loaded")
@@ -1620,8 +1872,21 @@ class QtDriver(DriverMixin, QObject):
)
self.main_window.setAcceptDrops(True)
self.init_file_extension_manager()
self.selected.clear()
self.set_add_to_selected_visibility()
self.set_select_actions_visibility()
self.save_library_backup_action.setEnabled(True)
self.close_library_action.setEnabled(True)
self.refresh_dir_action.setEnabled(True)
self.tag_manager_action.setEnabled(True)
self.manage_file_ext_action.setEnabled(True)
self.new_tag_action.setEnabled(True)
self.fix_dupe_files_action.setEnabled(True)
self.fix_unlinked_entries_action.setEnabled(True)
self.clear_thumb_cache_action.setEnabled(True)
self.folders_to_tags_action.setEnabled(True)
self.preview_panel.update_widgets()
# page (re)rendering, extract eventually

View File

@@ -70,7 +70,7 @@ class FieldContainer(QWidget):
self.title_container = QWidget()
self.title_layout = QHBoxLayout(self.title_container)
self.title_layout.setAlignment(Qt.AlignmentFlag.AlignHCenter)
self.title_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.title_layout.setObjectName("fieldLayout")
self.title_layout.setContentsMargins(0, 0, 0, 0)
self.title_layout.setSpacing(0)
@@ -78,6 +78,7 @@ class FieldContainer(QWidget):
self.title_widget = QLabel()
self.title_widget.setMinimumHeight(button_size)
self.title_widget.setMinimumWidth(200)
self.title_widget.setObjectName("fieldTitle")
self.title_widget.setWordWrap(True)
self.title_widget.setText(title)

View File

@@ -29,7 +29,7 @@ from src.core.library import ItemType, Library
from src.core.media_types import MediaCategories, MediaType
from src.qt.flowlayout import FlowWidget
from src.qt.helpers.file_opener import FileOpenerHelper
from src.qt.platform_strings import PlatformStrings
from src.qt.platform_strings import open_file_str, trash_term
from src.qt.translations import Translations
from src.qt.widgets.thumb_button import ThumbButton
from src.qt.widgets.thumb_renderer import ThumbRenderer
@@ -219,10 +219,17 @@ class ItemThumb(FlowWidget):
open_file_action = QAction(self)
Translations.translate_qobject(open_file_action, "file.open_file")
open_file_action.triggered.connect(self.opener.open_file)
open_explorer_action = QAction(PlatformStrings.open_file_str, self)
open_explorer_action = QAction(open_file_str(), self)
open_explorer_action.triggered.connect(self.opener.open_explorer)
self.delete_action = QAction(self)
Translations.translate_qobject(
self.delete_action, "trash.context.ambiguous", trash_term=trash_term()
)
self.thumb_button.addAction(open_file_action)
self.thumb_button.addAction(open_explorer_action)
self.thumb_button.addAction(self.delete_action)
# Static Badges ========================================================
@@ -492,15 +499,11 @@ class ItemThumb(FlowWidget):
toggle_value: bool,
tag_id: int,
):
logger.info("toggle_item_tag", entry_id=entry_id, toggle_value=toggle_value, tag_id=tag_id)
if toggle_value:
self.lib.add_tags_to_entry(entry_id, tag_id)
else:
self.lib.remove_tags_from_entry(entry_id, tag_id)
if self.driver.preview_panel.is_open:
self.driver.preview_panel.update_widgets(update_preview=False)
if entry_id in self.driver.selected and self.driver.preview_panel.is_open:
if len(self.driver.selected) == 1:
self.driver.preview_panel.fields.update_toggled_tag(tag_id, toggle_value)
else:
pass
def mouseMoveEvent(self, event): # noqa: N802
if event.buttons() is not Qt.MouseButton.LeftButton:

View File

@@ -367,29 +367,35 @@ class JsonMigrationModal(QObject):
pb.setCancelButton(None)
self.body_wrapper_01.layout().addWidget(pb)
iterator = FunctionIterator(self.migration_iterator)
iterator.value.connect(
lambda x: (
pb.setLabelText(f"<h4>{x}</h4>"),
self.update_sql_value_ui(show_msg_box=False)
if x == Translations["json_migration.checking_for_parity"]
else (),
self.update_parity_ui()
if x == Translations["json_migration.checking_for_parity"]
else (),
try:
iterator = FunctionIterator(self.migration_iterator)
iterator.value.connect(
lambda x: (
pb.setLabelText(f"<h4>{x}</h4>"),
self.update_sql_value_ui(show_msg_box=False)
if x == Translations["json_migration.checking_for_parity"]
else (),
self.update_parity_ui()
if x == Translations["json_migration.checking_for_parity"]
else (),
)
)
)
r = CustomRunnable(iterator.run)
r.done.connect(
lambda: (
self.update_sql_value_ui(show_msg_box=not skip_ui),
pb.setMinimum(1),
pb.setValue(1),
# Enable the finish button
self.stack[1].buttons[4].setDisabled(False), # type: ignore
r = CustomRunnable(iterator.run)
r.done.connect(
lambda: (
self.update_sql_value_ui(show_msg_box=not skip_ui),
pb.setMinimum(1),
pb.setValue(1),
# Enable the finish button
self.stack[1].buttons[4].setDisabled(False), # type: ignore
)
)
)
QThreadPool.globalInstance().start(r)
QThreadPool.globalInstance().start(r)
except Exception as e:
logger.error("[MigrationModal][Iterator] Error:", error=e)
pb.setLabelText(f"<h4>{type(e).__name__}</h4>")
pb.setMinimum(1)
pb.setValue(1)
def migration_iterator(self):
"""Iterate over the library migration process."""

View File

@@ -1,8 +1,11 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from typing import override
import structlog
from PySide6 import QtCore, QtGui
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QHBoxLayout,
@@ -110,3 +113,11 @@ class PagedPanel(QWidget):
item.setHidden(False)
elif isinstance(item, int):
self.button_nav_layout.addStretch(item)
@override
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
if event.key() == QtCore.Qt.Key.Key_Escape:
self.close()
else: # Other key presses
pass
return super().keyPressEvent(event)

View File

@@ -1,13 +1,16 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import logging
from typing import Callable
import structlog
from PySide6 import QtCore, QtGui
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
from src.qt.translations import Translations
logger = structlog.get_logger(__name__)
class PanelModal(QWidget):
saved = Signal()
@@ -95,7 +98,10 @@ class PanelModal(QWidget):
widget.parent_post_init()
def closeEvent(self, event): # noqa: N802
self.done_button.click()
if self.cancel_button:
self.cancel_button.click()
elif self.done_button:
self.done_button.click()
event.accept()
def setTitle(self, title: str): # noqa: N802
@@ -124,4 +130,19 @@ class PanelWidget(QWidget):
pass
def add_callback(self, callback: Callable, event: str = "returnPressed"):
logging.warning(f"add_callback not implemented for {self.__class__.__name__}")
logger.warning(f"[PanelModal] add_callback not implemented for {self.__class__.__name__}")
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
if event.key() == QtCore.Qt.Key.Key_Escape:
if self.panel_cancel_button:
self.panel_cancel_button.click()
elif self.panel_done_button:
self.panel_done_button.click()
elif event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter:
if self.panel_save_button:
self.panel_save_button.click()
elif self.panel_done_button:
self.panel_done_button.click()
else: # Other key presses
pass
return super().keyPressEvent(event)

View File

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

View File

@@ -6,6 +6,7 @@ import io
import time
import typing
from pathlib import Path
from warnings import catch_warnings
import cv2
import rawpy
@@ -24,7 +25,8 @@ from src.qt.helpers.file_opener import FileOpenerHelper, open_file
from src.qt.helpers.file_tester import is_readable_video
from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper
from src.qt.helpers.rounded_pixmap_style import RoundedPixmapStyle
from src.qt.platform_strings import PlatformStrings
from src.qt.platform_strings import open_file_str, trash_term
from src.qt.resource_manager import ResourceManager
from src.qt.translations import Translations
from src.qt.widgets.media_player import MediaPlayer
from src.qt.widgets.thumb_renderer import ThumbRenderer
@@ -54,7 +56,11 @@ class PreviewThumb(QWidget):
self.open_file_action = QAction(self)
Translations.translate_qobject(self.open_file_action, "file.open_file")
self.open_explorer_action = QAction(PlatformStrings.open_file_str, self)
self.open_explorer_action = QAction(open_file_str(), self)
self.delete_action = QAction(self)
Translations.translate_qobject(
self.delete_action, "trash.context.ambiguous", trash_term=trash_term()
)
self.preview_img = QPushButtonWrapper()
self.preview_img.setMinimumSize(*self.img_button_size)
@@ -62,6 +68,7 @@ class PreviewThumb(QWidget):
self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
self.preview_img.addAction(self.open_file_action)
self.preview_img.addAction(self.open_explorer_action)
self.preview_img.addAction(self.delete_action)
self.preview_gif = QLabel()
self.preview_gif.setMinimumSize(*self.img_button_size)
@@ -69,10 +76,12 @@ class PreviewThumb(QWidget):
self.preview_gif.setCursor(Qt.CursorShape.ArrowCursor)
self.preview_gif.addAction(self.open_file_action)
self.preview_gif.addAction(self.open_explorer_action)
self.preview_gif.addAction(self.delete_action)
self.preview_gif.hide()
self.gif_buffer: QBuffer = QBuffer()
self.preview_vid = VideoPlayer(driver)
self.preview_vid.addAction(self.delete_action)
self.preview_vid.hide()
self.thumb_renderer = ThumbRenderer(self.lib)
self.thumb_renderer.updated.connect(lambda ts, i, s: (self.preview_img.setIcon(i)))
@@ -355,7 +364,7 @@ class PreviewThumb(QWidget):
update_on_ratio_change=True,
)
if self.preview_img.is_connected:
with catch_warnings(record=True):
self.preview_img.clicked.disconnect()
self.preview_img.clicked.connect(lambda checked=False, path=filepath: open_file(path))
self.preview_img.is_connected = True
@@ -367,12 +376,31 @@ class PreviewThumb(QWidget):
self.open_file_action.triggered.connect(self.opener.open_file)
self.open_explorer_action.triggered.connect(self.opener.open_explorer)
with catch_warnings(record=True):
self.delete_action.triggered.disconnect()
self.delete_action.setText(
Translations.translate_formatted("trash.context.singular", trash_term=trash_term())
)
self.delete_action.triggered.connect(
lambda checked=False, f=filepath: self.driver.delete_files_callback(f)
)
self.delete_action.setEnabled(bool(filepath))
return stats
def hide_preview(self):
"""Completely hide the file preview."""
self.switch_preview("")
def stop_file_use(self):
"""Stops the use of the currently previewed file. Used to release file permissions."""
logger.info("[PreviewThumb] Stopping file use in video playback...")
# This swaps the video out for a placeholder so the previous video's file
# is no longer in use by this object.
self.preview_vid.play(str(ResourceManager.get_path("placeholder_mp4")), QSize(8, 8))
self.preview_vid.hide()
def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802
self.update_image_size((self.size().width(), self.size().height()))
return super().resizeEvent(event)

View File

@@ -211,9 +211,4 @@ class PreviewPanel(QWidget):
)
)
self.add_tag_button.clicked.connect(
lambda: (
self.tag_search_panel.update_tags(),
self.add_tag_modal.show(),
)
)
self.add_tag_button.clicked.connect(self.add_tag_modal.show)

View File

@@ -19,6 +19,7 @@ from PySide6.QtWidgets import (
from src.core.library import Tag
from src.core.library.alchemy.enums import TagColorEnum
from src.core.palette import ColorType, get_tag_color
from src.qt.helpers.escape_text import escape_text
from src.qt.translations import Translations
logger = structlog.get_logger(__name__)
@@ -104,7 +105,7 @@ class TagWidget(QWidget):
def __init__(
self,
tag: Tag,
tag: Tag | None,
has_edit: bool,
has_remove: bool,
library: "Library | None" = None,
@@ -126,10 +127,7 @@ class TagWidget(QWidget):
self.bg_button = QPushButton(self)
self.bg_button.setFlat(True)
if self.lib:
self.bg_button.setText(self.lib.tag_display_name(tag.id))
else:
self.bg_button.setText(tag.name)
if has_edit:
edit_action = QAction(self)
edit_action.setText(Translations.translate_formatted("generic.edit"))
@@ -150,10 +148,39 @@ class TagWidget(QWidget):
self.inner_layout = QHBoxLayout()
self.inner_layout.setObjectName("innerLayout")
self.inner_layout.setContentsMargins(2, 2, 2, 2)
self.inner_layout.setContentsMargins(0, 0, 0, 0)
self.remove_button = QPushButton(self)
self.remove_button.setFlat(True)
self.remove_button.setText("")
self.remove_button.setHidden(True)
self.remove_button.setMinimumSize(22, 22)
self.remove_button.setMaximumSize(22, 22)
self.remove_button.clicked.connect(self.on_remove.emit)
self.remove_button.setHidden(True)
self.inner_layout.addWidget(self.remove_button)
self.inner_layout.addStretch(1)
self.bg_button.setLayout(self.inner_layout)
self.bg_button.setMinimumSize(22, 22)
self.bg_button.setMinimumSize(44, 22)
self.bg_button.setMinimumHeight(22)
self.bg_button.setMaximumHeight(22)
self.base_layout.addWidget(self.bg_button)
# NOTE: Do this if you don't want the tag to stretch, like in a search.
# self.bg_button.setMaximumWidth(self.bg_button.sizeHint().width())
self.bg_button.clicked.connect(self.on_click.emit)
self.set_tag(tag)
def set_tag(self, tag: Tag | None) -> None:
self.tag = tag
if not tag:
return
primary_color = get_primary_color(tag)
border_color = (
@@ -189,38 +216,52 @@ class TagWidget(QWidget):
f"QPushButton::hover{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"}}"
f"QPushButton::pressed{{"
f"background: rgba{highlight_color.toTuple()};"
f"color: rgba{primary_color.toTuple()};"
f"border-color: rgba{primary_color.toTuple()};"
f"}}"
f"QPushButton::focus{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"outline:none;"
f"}}"
)
self.bg_button.setMinimumHeight(22)
self.bg_button.setMaximumHeight(22)
self.base_layout.addWidget(self.bg_button)
self.remove_button.setStyleSheet(
f"QPushButton{{"
f"color: rgba{primary_color.toTuple()};"
f"background: rgba{text_color.toTuple()};"
f"font-weight: 800;"
f"border-radius: 5px;"
f"border-width: 4;"
f"border-color: rgba(0,0,0,0);"
f"padding-bottom: 4px;"
f"font-size: 14px"
f"}}"
f"QPushButton::hover{{"
f"background: rgba{primary_color.toTuple()};"
f"color: rgba{text_color.toTuple()};"
f"border-color: rgba{highlight_color.toTuple()};"
f"border-width: 2;"
f"border-radius: 6px;"
f"}}"
f"QPushButton::pressed{{"
f"background: rgba{border_color.toTuple()};"
f"color: rgba{highlight_color.toTuple()};"
f"}}"
f"QPushButton::focus{{"
f"background: rgba{border_color.toTuple()};"
f"outline:none;"
f"}}"
)
if has_remove:
self.remove_button = QPushButton(self)
self.remove_button.setFlat(True)
self.remove_button.setText("")
self.remove_button.setHidden(True)
self.remove_button.setStyleSheet(
f"color: rgba{primary_color.toTuple()};"
f"background: rgba{text_color.toTuple()};"
f"font-weight: 800;"
f"border-radius: 3px;"
f"border-width:0;"
f"padding-bottom: 4px;"
f"font-size: 14px"
)
self.remove_button.setMinimumSize(18, 18)
self.remove_button.setMaximumSize(18, 18)
self.remove_button.clicked.connect(self.on_remove.emit)
if self.lib:
self.bg_button.setText(escape_text(self.lib.tag_display_name(tag.id)))
else:
self.bg_button.setText(escape_text(tag.name))
if has_remove:
self.inner_layout.addWidget(self.remove_button)
self.inner_layout.addStretch(1)
# NOTE: Do this if you don't want the tag to stretch, like in a search.
# self.bg_button.setMaximumWidth(self.bg_button.sizeHint().width())
self.bg_button.clicked.connect(self.on_click.emit)
def set_has_remove(self, has_remove: bool):
self.has_remove = has_remove
def enterEvent(self, event: QEnterEvent) -> None: # noqa: N802
if self.has_remove:

View File

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

View File

@@ -30,7 +30,7 @@ from PySide6.QtSvgWidgets import QSvgWidget
from PySide6.QtWidgets import QGraphicsScene, QGraphicsView
from src.core.enums import SettingItems
from src.qt.helpers.file_opener import FileOpenerHelper
from src.qt.platform_strings import PlatformStrings
from src.qt.platform_strings import open_file_str
from src.qt.translations import Translations
if typing.TYPE_CHECKING:
@@ -130,7 +130,7 @@ class VideoPlayer(QGraphicsView):
Translations.translate_qobject(open_file_action, "file.open_file")
open_file_action.triggered.connect(self.opener.open_file)
open_explorer_action = QAction(PlatformStrings.open_file_str, self)
open_explorer_action = QAction(open_file_str(), self)
open_explorer_action.triggered.connect(self.opener.open_explorer)
self.addAction(open_file_action)

View File

@@ -95,7 +95,7 @@ def library(request):
path=pathlib.Path("foo.txt"),
fields=lib.default_fields,
)
assert lib.add_tags_to_entry(entry.id, tag.id)
assert lib.add_tags_to_entries(entry.id, tag.id)
entry2 = Entry(
id=2,
@@ -103,7 +103,7 @@ def library(request):
path=pathlib.Path("one/two/bar.md"),
fields=lib.default_fields,
)
assert lib.add_tags_to_entry(entry2.id, tag2.id)
assert lib.add_tags_to_entries(entry2.id, tag2.id)
assert lib.add_entries([entry, entry2])
assert len(lib.tags) == 6

View File

@@ -119,7 +119,7 @@ def test_meta_tag_category(qt_driver, library, entry_full):
panel = PreviewPanel(library, qt_driver)
# Ensure the Favorite tag is on entry_full
library.add_tags_to_entry(1, entry_full.id)
library.add_tags_to_entries(1, entry_full.id)
# Select the single entry
qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False)
@@ -151,7 +151,7 @@ def test_custom_tag_category(qt_driver, library, entry_full):
)
# Ensure the Favorite tag is on entry_full
library.add_tags_to_entry(1, entry_full.id)
library.add_tags_to_entries(1, entry_full.id)
# Select the single entry
qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False)

View File

@@ -119,7 +119,7 @@ def test_tag_search(library):
assert library.search_tags(tag.name.lower())
assert library.search_tags(tag.name.upper())
assert library.search_tags(tag.name[2:-2])
assert not library.search_tags(tag.name * 2)
assert library.search_tags(tag.name * 2) == [set(), set()]
def test_get_entry(library: Library, entry_min):
@@ -330,8 +330,8 @@ def test_merge_entries(library: Library):
tag_0 = library.add_tag(Tag(id=1000, name="tag_0"))
tag_1 = library.add_tag(Tag(id=1001, name="tag_1"))
tag_2 = library.add_tag(Tag(id=1002, name="tag_2"))
library.add_tags_to_entry(ids[0], [tag_0.id, tag_2.id])
library.add_tags_to_entry(ids[1], [tag_1.id])
library.add_tags_to_entries(ids[0], [tag_0.id, tag_2.id])
library.add_tags_to_entries(ids[1], [tag_1.id])
library.merge_entries(entry_a, entry_b)
assert library.has_path_entry(Path("b"))
assert not library.has_path_entry(Path("a"))
@@ -344,11 +344,11 @@ def test_merge_entries(library: Library):
AssertionError()
def test_remove_tag_from_entry(library, entry_full):
def test_remove_tags_from_entries(library, entry_full):
removed_tag_id = -1
for tag in entry_full.tags:
removed_tag_id = tag.id
library.remove_tags_from_entry(entry_full.id, tag.id)
library.remove_tags_from_entries(entry_full.id, tag.id)
entry = next(library.get_entries(with_joins=True))
assert removed_tag_id not in [t.id for t in entry.tags]
@@ -414,6 +414,24 @@ def test_library_prefs_multiple_identical_vals():
assert TestPrefs.BAR.value
def test_path_search_ilike(library: Library):
results = library.search_library(FilterState.from_path("bar.md"))
assert results.total_count == 1
assert len(results.items) == 1
def test_path_search_like(library: Library):
results = library.search_library(FilterState.from_path("BAR.MD"))
assert results.total_count == 0
assert len(results.items) == 0
def test_path_search_default_with_sep(library: Library):
results = library.search_library(FilterState.from_path("one/two"))
assert results.total_count == 1
assert len(results.items) == 1
def test_path_search_glob_after(library: Library):
results = library.search_library(FilterState.from_path("foo*"))
assert results.total_count == 1
@@ -432,6 +450,50 @@ def test_path_search_glob_both_sides(library: Library):
assert len(results.items) == 1
def test_path_search_ilike_glob_equality(library: Library):
results_ilike = library.search_library(FilterState.from_path("one/two"))
results_glob = library.search_library(FilterState.from_path("*one/two*"))
assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items]
results_ilike, results_glob = None, None
results_ilike = library.search_library(FilterState.from_path("bar.md"))
results_glob = library.search_library(FilterState.from_path("*bar.md*"))
assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items]
results_ilike, results_glob = None, None
results_ilike = library.search_library(FilterState.from_path("bar"))
results_glob = library.search_library(FilterState.from_path("*bar*"))
assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items]
results_ilike, results_glob = None, None
results_ilike = library.search_library(FilterState.from_path("bar.md"))
results_glob = library.search_library(FilterState.from_path("*bar.md*"))
assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items]
results_ilike, results_glob = None, None
def test_path_search_like_glob_equality(library: Library):
results_ilike = library.search_library(FilterState.from_path("ONE/two"))
results_glob = library.search_library(FilterState.from_path("*ONE/two*"))
assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items]
results_ilike, results_glob = None, None
results_ilike = library.search_library(FilterState.from_path("BAR.MD"))
results_glob = library.search_library(FilterState.from_path("*BAR.MD*"))
assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items]
results_ilike, results_glob = None, None
results_ilike = library.search_library(FilterState.from_path("BAR.MD"))
results_glob = library.search_library(FilterState.from_path("*bar.md*"))
assert [e.id for e in results_ilike.items] != [e.id for e in results_glob.items]
results_ilike, results_glob = None, None
results_ilike = library.search_library(FilterState.from_path("bar.md"))
results_glob = library.search_library(FilterState.from_path("*BAR.MD*"))
assert [e.id for e in results_ilike.items] != [e.id for e in results_glob.items]
results_ilike, results_glob = None, None
@pytest.mark.parametrize(["filetype", "num_of_filetype"], [("md", 1), ("txt", 1), ("png", 0)])
def test_filetype_search(library, filetype, num_of_filetype):
results = library.search_library(FilterState.from_filetype(filetype))