mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2026-01-30 14:50:47 +00:00
Compare commits
17 Commits
v9.5.0-pr2
...
v9.5.0-pr4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe94d84b94 | ||
|
|
db408f09c4 | ||
|
|
61b9fcf764 | ||
|
|
28de21ade7 | ||
|
|
2173d1d4f4 | ||
|
|
c1ec8a6650 | ||
|
|
69d3a6ed09 | ||
|
|
e481ab64c9 | ||
|
|
986ccabc81 | ||
|
|
297fdf22e8 | ||
|
|
319ef9a5fe | ||
|
|
6b646f8955 | ||
|
|
a2b9237be4 | ||
|
|
abc7cc3915 | ||
|
|
a3df70bb8d | ||
|
|
466af1e6a6 | ||
|
|
26d3b1908b |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Library Search
|
||||
|
||||
TagStudio provides various methods to search your library, ranging from TagStudio data such as tags to inherent file data such as paths or media types.
|
||||
|
||||
## Boolean Operators
|
||||
|
||||
TagStudio allows you to use common [boolean search](https://en.wikipedia.org/wiki/Full-text_search#Boolean_queries) operators when searching your library, along with [grouping](#grouping-and-nesting), [nesting](#grouping-and-nesting), and [character escaping](#escaping-characters). Note that you may need to use grouping in order to get the desired results you're looking for.
|
||||
@@ -54,40 +56,58 @@ _[Field](field.md) search is currently not in the program, however is coming in
|
||||
|
||||
## File Entry Search
|
||||
|
||||
### Filename + Filepath
|
||||
### Filename + Path
|
||||
|
||||
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.
|
||||
Filename and path search is available via the `path:` keyword and comes in a few different styles. By default, any string that follows the `path:` keyword will be searched as a substring inside a file's complete filepath. This means that given a file `folder/my_file.txt`, searching for `path: my_file` or `path: folder` will both return results for that file.
|
||||
|
||||
#### Case Sensitivity
|
||||
|
||||
TagStudio uses a "[smartcase](https://neovim.io/doc/user/options.html#'smartcase')"-like system for case sensitivity. This means that a search term typed in `lowercase` will be treated as **case-insensitive**, while a term typed in any `MixedCase` will be treated as **case-sensitive**. This makes it quicker to type searches when case sensitivity isn't required, while also providing a simple option to leverage case sensitivity when desired. Note that this means there's technically no way to currently search for a lowercase term while respecting case sensitivity.
|
||||
|
||||
#### Glob Syntax
|
||||
|
||||
Optionally, you may use [glob](<https://en.wikipedia.org/wiki/Glob_(programming)>) syntax to search filepaths.
|
||||
|
||||
#### Examples
|
||||
|
||||
Given a file "artwork/piece.jpg", 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:
|
||||
Given a file "Artwork/Piece.jpg", the following searches will return results for it:
|
||||
|
||||
- `path: artwork/piece.jpg`
|
||||
- `path: Artwork/Piece.jpg`
|
||||
- `path: piece.jpg`
|
||||
- `path: piece.jpg`
|
||||
- `path: Piece.jpg`
|
||||
- `path: artwork`
|
||||
- `path: rtwor`
|
||||
- `path: ece.jpg`
|
||||
- `path: iec`
|
||||
- `path: artwork/*`
|
||||
- `path: Artwork/*`
|
||||
- `path: *piece.jpg*`
|
||||
- `path: *Piece.jpg*`
|
||||
- `path: *artwork*`
|
||||
- `path: *Artwork*`
|
||||
- `path: *rtwor*`
|
||||
- `path: *ece.jpg*`
|
||||
- `path: *iec*`
|
||||
- `path: *.jpg`
|
||||
|
||||
While the following searches will **NOT:**
|
||||
|
||||
- `path: ARTWORK/Piece.jpg` _(Reason: Mismatched case)_
|
||||
- `path: *aRtWoRk/Piece*` _(Reason: Mismatched case)_
|
||||
- `path: PieCe.jpg` _(Reason: Mismatched case)_
|
||||
- `path: *PieCe.jpg*` _(Reason: Mismatched case)_
|
||||
|
||||
## Special Searches
|
||||
|
||||
"Special" searches use the `special:` keyword prefix and give quick results for certain special search queries.
|
||||
Some predefined searches use the `special:` keyword prefix and give quick results for certain special search queries.
|
||||
|
||||
### Untagged
|
||||
|
||||
To see all your file entries which don't contain any tags, use the `special:untagged` search.
|
||||
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_
|
||||
**_NOTE:_** _Currently unavailable in v9.5.0_
|
||||
|
||||
To see all your file entries which don't contain any tags _and_ any fields, use the `special:empty` search.
|
||||
To see all your file entries which don't contain any tags _and_ any fields, use the `special: empty` search.
|
||||
|
||||
@@ -10,218 +10,120 @@ Features are broken up into the following priority levels, with nested prioritie
|
||||
- [MEDIUM] - Important but not necessary
|
||||
- [LOW] - Just nice to have
|
||||
|
||||
## Core Feature List
|
||||
|
||||
- [ ] Tags [HIGH]
|
||||
- [x] ID-based, not string based [HIGH]
|
||||
- [x] Tag name [HIGH]
|
||||
- [x] Tag alias list, aka alternate names [HIGH]
|
||||
- [x] Tag shorthand (specific short alias for displaying) [HIGH]
|
||||
- [x] Parent/Inheritance subtags [HIGH]
|
||||
- [ ] Composition/HAS subtags [HIGH]
|
||||
- [x] Deleting Tags [HIGH]
|
||||
- [ ] Merging Tags [HIGH]
|
||||
- [ ] Tag Icons [HIGH]
|
||||
- [ ] Small Icons [HIGH]
|
||||
- [ ] Large Icons for Profiles [MEDIUM]
|
||||
- [ ] Built-in Icon Packs (i.e. Boxicons) [HIGH]
|
||||
- [ ] User Defined Icons [HIGH]
|
||||
- [ ] Multiple Languages for Tag Strings [MEDIUM]
|
||||
- [ ] User-defined tag colors [HIGH]
|
||||
- [x] ID based, not string or hex [HIGH]
|
||||
- [x] Color name [HIGH]
|
||||
- [x] Color value (hex) [HIGH]
|
||||
- [x] Existing colors are now a set of base colors [HIGH]
|
||||
- [x] [Tag Categories](../library/tag_categories.md) [HIGH]
|
||||
- [x] Property available for tags that allow the tag and any inheriting from it to be displayed separately in the preview panel under a title [HIGH]
|
||||
- [x] Title is tag name [HIGH]
|
||||
- [ ] [Tag Overrides](../library/tag_overrides.md) [MEDIUM]
|
||||
- [ ] Per-file overrides of subtags [HIGH]
|
||||
- [ ] Tag Packs [MEDIUM]
|
||||
- [ ] Human-readable (i.e. JSON) files containing tag data [HIGH]
|
||||
- [ ] Importable [HIGH]
|
||||
- [ ] Exportable [HIGH]
|
||||
- [ ] Conflict resolution [HIGH]
|
||||
- [ ] Color Packs [MEDIUM]
|
||||
- [ ] Human-readable (i.e. JSON) files containing tag data [HIGH]
|
||||
- [ ] Importable [HIGH]
|
||||
- [ ] Exportable [HIGH]
|
||||
- [ ] Exportable Library Data [HIGH]
|
||||
- [ ] Standard notation format (i.e. JSON) contacting all library data [HIGH]
|
||||
- [ ] [Macros](../utilities/macro.md) [HIGH]
|
||||
- [ ] Sharable Macros [MEDIUM]
|
||||
- [ ] Standard notation format (i.e. JSON) contacting macro instructions [HIGH]
|
||||
- [ ] Exportable [HIGH]
|
||||
- [ ] Importable [HIGH]
|
||||
- [ ] Triggers [HIGH]
|
||||
- [ ] On new file [HIGH]
|
||||
- [ ] On library refresh [HIGH]
|
||||
- [...]
|
||||
- [ ] Actions [HIGH]
|
||||
- [ ] Add tag(s) [HIGH]
|
||||
- [ ] Add field(s) [HIGH]
|
||||
- [ ] Set field content [HIGH]
|
||||
- [ ] [...]
|
||||
- [ ] Settings Menu [HIGH]
|
||||
- [ ] Application Settings [HIGH]
|
||||
- [ ] Stored in system user folder/designated folder [HIGH]
|
||||
- [ ] Library Settings [HIGH]
|
||||
- [ ] Stored in `.TagStudio` folder [HIGH]
|
||||
- [ ] Multiple Root Directories per Library [HIGH]
|
||||
- [ ] [Entry groups](../library/entry_groups.md) [HIGH]
|
||||
- [ ] Groups for files/entries where the same entry can be in multiple groups [HIGH]
|
||||
- [ ] Ability to number entries within group [HIGH]
|
||||
- [ ] Ability to set sorting method for group [HIGH]
|
||||
- [ ] Ability to set custom thumbnail for group [HIGH]
|
||||
- [ ] Group is treated as entry with tags and metadata [HIGH]
|
||||
- [ ] Nested groups [MEDIUM]
|
||||
- [ ] Fields [HIGH]
|
||||
- [x] Text Boxes [HIGH]
|
||||
- [x] Text Lines [HIGH]
|
||||
- [ ] Dates [HIGH]
|
||||
- [ ] Custom field names [HIGH]
|
||||
- [ ] Search engine [HIGH]
|
||||
- [x] Boolean operators [HIGH]
|
||||
- [ ] Tag objects + autocomplete [HIGH]
|
||||
- [x] Filename search [HIGH]
|
||||
- [x] Filetype search [HIGH]
|
||||
- [x] Search by extension (e.g. ".jpg", ".png") [HIGH]
|
||||
- [x] Optional consolidation of extension synonyms (i.e. ".jpg" can equal ".jpeg") [LOW]
|
||||
- [x] Search by media type (e.g. "image", "video", "document") [MEDIUM]
|
||||
- [ ] Field content search [HIGH]
|
||||
- [ ] HAS operator for composition tags [HIGH]
|
||||
- [ ] OCR search [LOW]
|
||||
- [ ] Fuzzy Search [LOW]
|
||||
- [ ] Sortable results [HIGH]
|
||||
- [ ] Sort by relevance [HIGH]
|
||||
- [x] Sort by date added [HIGH]
|
||||
- [ ] Sort by date created [HIGH]
|
||||
- [ ] Sort by date modified [HIGH]
|
||||
- [ ] Sort by date taken (photos) [MEDIUM]
|
||||
- [ ] Sort by file size [HIGH]
|
||||
- [ ] Sort by file dimension (images/video) [LOW]
|
||||
- [ ] Automatic Entry Relinking [HIGH] [#36](https://github.com/TagStudioDev/TagStudio/issues/36)
|
||||
- [ ] Detect Renames [HIGH]
|
||||
- [ ] Detect Moves [HIGH]
|
||||
- [ ] Detect Deletions [HIGH]
|
||||
- [ ] Image Collages [LOW] [#91](https://github.com/TagStudioDev/TagStudio/issues/91)
|
||||
- [ ] UI [HIGH]
|
||||
- [ ] Tagging Panel [HIGH]
|
||||
- [ ] Top Tags [HIGH]
|
||||
- [ ] Recent Tags [HIGH]
|
||||
- [ ] Tag Search [HIGH]
|
||||
- [ ] Pinned Tags [HIGH]
|
||||
- [ ] Configurable Thumbnails [MEDIUM]
|
||||
- [ ] Custom thumbnail override [HIGH]
|
||||
- [ ] Toggle File Extension Label [MEDIUM]
|
||||
- [ ] Toggle Duration Label [MEDIUM]
|
||||
- [ ] Custom Tag Badges [LOW]
|
||||
- [ ] Thumbnails [HIGH]
|
||||
- [ ] File Duration Label [HIGH]
|
||||
- [ ] 3D Model Previews [MEDIUM]
|
||||
- [ ] STL Previews [HIGH]
|
||||
- [x] Drag and Drop [HIGH]
|
||||
- [x] Drag files _to_ other programs [HIGH]
|
||||
- [x] Drag files _to_ file explorer windows [MEDIUM]
|
||||
- [x] Drag files _from_ file explorer windows [MEDIUM]
|
||||
- [x] Drag files _from_ other programs [LOW]
|
||||
- [ ] File Preview Panel [HIGH]
|
||||
- [ ] Video Playback [HIGH]
|
||||
- [x] Play/Pause [HIGH]
|
||||
- [x] Loop [HIGH]
|
||||
- [x] Toggle Autoplay [MEDIUM]
|
||||
- [ ] Volume Control [HIGH]
|
||||
- [x] Toggle Mute [HIGH]
|
||||
- [ ] Timeline scrubber [HIGH]
|
||||
- [ ] Fullscreen [MEDIUM]
|
||||
- [x] Audio Playback [HIGH]
|
||||
- [x] Play/Pause [HIGH]
|
||||
- [ ] Loop [HIGH]
|
||||
- [ ] Toggle Autoplay [MEDIUM]
|
||||
- [x] Volume Control [HIGH]
|
||||
- [x] Toggle Mute [HIGH]
|
||||
- [x] Timeline scrubber [HIGH]
|
||||
- [ ] Optimizations [HIGH]
|
||||
- [x] Thumbnail caching [HIGH]
|
||||
- [ ] File property caching/indexes [HIGH]
|
||||
|
||||
## Version Milestones
|
||||
|
||||
These version milestones are rough estimations for when the previous core features will be added. For a more definitive idea for when features are coming, please reference the current GitHub [milestones](https://github.com/TagStudioDev/TagStudio/milestones).
|
||||
|
||||
_(This list was created after the release of version 9.4)_
|
||||
!!! note
|
||||
This list was created after the release of version 9.4
|
||||
|
||||
### 9.5 (Alpha)
|
||||
### v9.5
|
||||
|
||||
#### Core
|
||||
|
||||
- [x] SQL backend [HIGH]
|
||||
- [x] Translations _(Any applicable)_ [MEDIUM]
|
||||
- [ ] Tags [HIGH]
|
||||
- [x] Deleting Tags [HIGH]
|
||||
- [ ] User-defined tag colors [HIGH]
|
||||
- [x] ID based, not string or hex [HIGH]
|
||||
- [x] Color name [HIGH]
|
||||
- [x] Color value (hex) [HIGH]
|
||||
- [x] Existing colors are now a set of base colors [HIGH]
|
||||
- [x] [Tag Categories](../library/tag_categories.md) [HIGH]
|
||||
- [x] Property available for tags that allow the tag and any inheriting from it to be displayed separately in the preview panel under a title [HIGH]
|
||||
- [ ] Search engine [HIGH]
|
||||
- [x] Boolean operators [HIGH]
|
||||
- [ ] Tag objects + autocomplete [HIGH]
|
||||
- [x] Filename search [HIGH]
|
||||
- [x] Filetype search [HIGH]
|
||||
- [x] Search by extension (e.g. ".jpg", ".png") [HIGH]
|
||||
- [x] Optional consolidation of extension synonyms (i.e. ".jpg" can equal ".jpeg") [LOW]
|
||||
- [x] Search by media type (e.g. "image", "video", "document") [MEDIUM]
|
||||
- [ ] Field content search [HIGH]
|
||||
- [ ] Sortable results [HIGH]
|
||||
- [x] Sort by date added [HIGH]
|
||||
- [ ] Sort by date created [HIGH]
|
||||
- [ ] Sort by date modified [HIGH]
|
||||
- [ ] Settings Menu [HIGH]
|
||||
- [ ] Application Settings [HIGH]
|
||||
- [ ] Stored in system user folder/designated folder [HIGH]
|
||||
- [ ] Library Settings [HIGH]
|
||||
- [ ] Stored in `.TagStudio` folder [HIGH]
|
||||
- [ ] Optimizations [HIGH]
|
||||
- [x] Thumbnail caching [HIGH]
|
||||
|
||||
### 9.6 (Alpha)
|
||||
#### Tags
|
||||
|
||||
- [x] Deleting Tags [HIGH]
|
||||
- [ ] User-defined tag colors [HIGH]
|
||||
- [x] ID based, not string or hex [HIGH]
|
||||
- [x] Color name [HIGH]
|
||||
- [x] Color value (hex) [HIGH]
|
||||
- [x] Existing colors are now a set of base colors [HIGH]
|
||||
- [x] [Tag Categories](../library/tag_categories.md) [HIGH]
|
||||
- [x] Property available for tags that allow the tag and any inheriting from it to be displayed separately in the preview panel under a title [HIGH]
|
||||
|
||||
#### Search
|
||||
|
||||
- [x] Boolean operators [HIGH]
|
||||
- [x] Filename search [HIGH]
|
||||
- [x] Filetype search [HIGH]
|
||||
- [x] Search by extension (e.g. ".jpg", ".png") [HIGH]
|
||||
- [x] Optional consolidation of extension synonyms (i.e. ".jpg" can equal ".jpeg") [LOW]
|
||||
- [x] Search by media type (e.g. "image", "video", "document") [MEDIUM]
|
||||
- [x] Sort by date added [HIGH]
|
||||
|
||||
#### UI
|
||||
|
||||
- [ ] Translations _(Any applicable)_ [MEDIUM]
|
||||
|
||||
#### Performance
|
||||
|
||||
- [x] Thumbnail caching [HIGH]
|
||||
|
||||
### v9.6
|
||||
|
||||
#### Core
|
||||
|
||||
- [ ] Cached file property table (media duration, word count, dimensions, etc.) [MEDIUM]
|
||||
|
||||
#### Library
|
||||
|
||||
- [ ] Tags [HIGH]
|
||||
- [ ] Merging Tags [HIGH]
|
||||
- [ ] Composition/HAS subtags [HIGH]
|
||||
- [ ] Tag Icons [HIGH]
|
||||
- [ ] Small Icons [HIGH]
|
||||
- [ ] Large Icons for Profiles [MEDIUM]
|
||||
- [ ] Built-in Icon Packs (i.e. Boxicons) [HIGH]
|
||||
- [ ] User Defined Icons [HIGH]
|
||||
- [ ] Multiple Languages for Tag Strings [MEDIUM]
|
||||
- [ ] Title is tag name [HIGH]
|
||||
- [ ] Title has tag color [MEDIUM]
|
||||
- [ ] Tag marked as category does not display as a tag itself [HIGH]
|
||||
- [ ] [Tag Overrides](../library/tag_overrides.md) [MEDIUM]
|
||||
- [ ] Per-file overrides of subtags [HIGH]
|
||||
- [ ] Fields [HIGH]
|
||||
- [ ] Dates [HIGH]
|
||||
- [ ] Custom field names [HIGH]
|
||||
- [ ] Multiple Root Directories per Library [HIGH]
|
||||
- [ ] `.ts_ignore` (`.gitignore`-style glob ignoring) [HIGH]
|
||||
- [ ] Sharable Color Packs [MEDIUM]
|
||||
- [ ] Human-readable (TOML) files containing tag data [HIGH]
|
||||
- [ ] Importable [HIGH]
|
||||
- [ ] Exportable [HIGH]
|
||||
|
||||
#### Tags
|
||||
|
||||
- [ ] Merging Tags [HIGH]
|
||||
- [ ] [Component/HAS](../library/tag.md#component-tags) subtags [HIGH]
|
||||
- [ ] Tag Icons [HIGH]
|
||||
- [ ] Small Icons [HIGH]
|
||||
- [ ] Large Icons for Profiles [MEDIUM]
|
||||
- [ ] Built-in Icon Packs (i.e. Boxicons) [HIGH]
|
||||
- [ ] User Defined Icons [HIGH]
|
||||
- [ ] Multiple Languages for Tag Strings [MEDIUM]
|
||||
- [ ] Title is tag name [HIGH]
|
||||
- [ ] Title has tag color [MEDIUM]
|
||||
- [ ] Tag marked as category does not display as a tag itself [HIGH]
|
||||
- [ ] [Tag Overrides](../library/tag_overrides.md) [MEDIUM]
|
||||
- [ ] Per-file overrides of subtags [HIGH]
|
||||
|
||||
#### Fields
|
||||
|
||||
- [ ] Datetime fields [HIGH]
|
||||
- [ ] Custom field names [HIGH]
|
||||
|
||||
#### Search
|
||||
|
||||
- [ ] Field content search [HIGH]
|
||||
- [ ] Sort by date created [HIGH]
|
||||
- [ ] Sort by date modified [HIGH]
|
||||
- [ ] HAS operator for composition tags [HIGH]
|
||||
- [ ] Search bar rework
|
||||
- [ ] Improved tag autocomplete [HIGH]
|
||||
- [ ] Tags appear as widgets in search bar [HIGH]
|
||||
|
||||
#### UI
|
||||
|
||||
- [ ] File duration on video thumbnails [HIGH]
|
||||
- [ ] 3D Model Previews [MEDIUM]
|
||||
- [ ] STL Previews [HIGH]
|
||||
- [ ] Word count/line count on text thumbnails [LOW]
|
||||
- [ ] Settings Menu [HIGH]
|
||||
- [ ] Application Settings [HIGH]
|
||||
- [ ] Stored in system user folder/designated folder [HIGH]
|
||||
- [ ] Library Settings [HIGH]
|
||||
- [ ] Stored in `.TagStudio` folder [HIGH]
|
||||
- [ ] Tagging Panel [HIGH]
|
||||
|
||||
Togglebale persistent main window panel or popout. Replaces the current tag manager.
|
||||
|
||||
- [ ] Top Tags [HIGH]
|
||||
- [ ] Recent Tags [HIGH]
|
||||
- [ ] Tag Search [HIGH]
|
||||
- [ ] Pinned Tags [HIGH]
|
||||
- [ ] Search engine [HIGH]
|
||||
- [ ] HAS operator for composition tags [HIGH]
|
||||
|
||||
### 9.7 (Alpha)
|
||||
- [ ] New tabbed tag building UI to support the new tag features [HIGH]
|
||||
|
||||
### v9.7
|
||||
|
||||
#### Library
|
||||
|
||||
- [ ] Configurable Thumbnails [MEDIUM]
|
||||
- [ ] Toggle File Extension Label [MEDIUM]
|
||||
- [ ] Toggle Duration Label [MEDIUM]
|
||||
- [ ] Custom Tag Badges [LOW]
|
||||
- [ ] Thumbnails [HIGH]
|
||||
- [ ] File Duration Label [HIGH]
|
||||
- [ ] [Entry groups](../library/entry_groups.md) [HIGH]
|
||||
- [ ] Groups for files/entries where the same entry can be in multiple groups [HIGH]
|
||||
- [ ] Ability to number entries within group [HIGH]
|
||||
@@ -230,46 +132,84 @@ _(This list was created after the release of version 9.4)_
|
||||
- [ ] Group is treated as entry with tags and metadata [HIGH]
|
||||
- [ ] Nested groups [MEDIUM]
|
||||
|
||||
### 9.8 (Possible Beta)
|
||||
#### Search
|
||||
|
||||
- [ ] Sort by relevance [HIGH]
|
||||
- [ ] Sort by date taken (photos) [MEDIUM]
|
||||
- [ ] Sort by file size [HIGH]
|
||||
- [ ] Sort by file dimension (images/video) [LOW]
|
||||
|
||||
#### [Macros](../utilities/macro.md)
|
||||
|
||||
- [ ] Sharable Macros [MEDIUM]
|
||||
- [ ] Standard notation format (TOML) contacting macro instructions [HIGH]
|
||||
- [ ] Exportable [HIGH]
|
||||
- [ ] Importable [HIGH]
|
||||
- [ ] Triggers [HIGH]
|
||||
- [ ] On new file [HIGH]
|
||||
- [ ] On library refresh [HIGH]
|
||||
- [ ] [...]
|
||||
- [ ] Actions [HIGH]
|
||||
- [ ] Add tag(s) [HIGH]
|
||||
- [ ] Add field(s) [HIGH]
|
||||
- [ ] Set field content [HIGH]
|
||||
- [ ] [...]
|
||||
|
||||
#### UI
|
||||
|
||||
- [ ] Custom thumbnail overrides [MEDIUM]
|
||||
- [ ] Toggle File Extension Label [MEDIUM]
|
||||
- [ ] Toggle Duration Label [MEDIUM]
|
||||
- [ ] Custom Tag Badges [LOW]
|
||||
- [ ] Unified Media Player [HIGH]
|
||||
- [ ] Auto-hiding player controls
|
||||
- [x] Play/Pause [HIGH]
|
||||
- [x] Loop [HIGH]
|
||||
- [x] Toggle Autoplay [MEDIUM]
|
||||
- [ ] Volume Control [HIGH]
|
||||
- [x] Toggle Mute [HIGH]
|
||||
- [ ] Timeline scrubber [HIGH]
|
||||
- [ ] Fullscreen [MEDIUM]
|
||||
- [ ] Library list view [HIGH]
|
||||
- [ ] Configurable page size [HIGH]
|
||||
|
||||
### v9.8
|
||||
|
||||
#### Library
|
||||
|
||||
- [ ] Automatic Entry Relinking [HIGH]
|
||||
- [ ] Detect Renames [HIGH]
|
||||
- [ ] Detect Moves [HIGH]
|
||||
- [ ] Detect Deletions [HIGH]
|
||||
- [ ] [Macros](../utilities/macro.md) [HIGH]
|
||||
- [ ] Sharable Macros [MEDIUM]
|
||||
- [ ] Standard notation format (i.e. JSON) contacting macro instructions [HIGH]
|
||||
- [ ] Exportable [HIGH]
|
||||
- [ ] Importable [HIGH]
|
||||
- [ ] Triggers [HIGH]
|
||||
- [ ] On new file [HIGH]
|
||||
- [ ] On library refresh [HIGH]
|
||||
- [...]
|
||||
- [ ] Actions [HIGH]
|
||||
- [ ] Add tag(s) [HIGH]
|
||||
- [ ] Add field(s) [HIGH]
|
||||
- [ ] Set field content [HIGH]
|
||||
- [ ] [...]
|
||||
|
||||
### 9.9 (Possible Beta)
|
||||
#### Search
|
||||
|
||||
- [ ] OCR search [LOW]
|
||||
- [ ] Fuzzy Search [LOW]
|
||||
|
||||
### v9.9
|
||||
|
||||
#### Library
|
||||
|
||||
- [ ] Tag Packs [MEDIUM]
|
||||
- [ ] Human-readable (i.e. JSON) files containing tag data [HIGH]
|
||||
- [ ] Importable [HIGH]
|
||||
- [ ] Exportable [HIGH]
|
||||
- [ ] Conflict resolution [HIGH]
|
||||
- [ ] Color Packs [MEDIUM]
|
||||
- [ ] Human-readable (i.e. JSON) files containing tag data [HIGH]
|
||||
- [ ] Importable [HIGH]
|
||||
- [ ] Exportable [HIGH]
|
||||
- [ ] Exportable Library Data [HIGH]
|
||||
- [ ] Standard notation format (i.e. JSON) contacting all library data [HIGH]
|
||||
|
||||
### 10.0 (Possible Beta/Full Release)
|
||||
#### Tags
|
||||
|
||||
- [ ] Tag Packs [MEDIUM]
|
||||
- [ ] Human-readable (TOML) files containing tag data [HIGH]
|
||||
- [ ] Multiple Languages for Tag Strings [MEDIUM]
|
||||
- [ ] Importable [HIGH]
|
||||
- [ ] Exportable [HIGH]
|
||||
- [ ] Conflict resolution [HIGH]
|
||||
|
||||
### v10.0
|
||||
|
||||
- [ ] All remaining [HIGH] and optional [MEDIUM] features
|
||||
|
||||
### Post 10.0
|
||||
### Post v10.0
|
||||
|
||||
#### Core
|
||||
|
||||
- [ ] Core Library/API
|
||||
- [ ] Plugin Support
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "TagStudio"
|
||||
description = "A User-Focused Photo & File Management System."
|
||||
version = "9.5.0-pre2"
|
||||
version = "9.5.0-pre4"
|
||||
license = "GPL-3.0-only"
|
||||
readme = "README.md"
|
||||
|
||||
@@ -20,6 +20,17 @@ convention = "google"
|
||||
select = ["B", "D", "E", "F", "FBT003", "I", "N", "SIM", "T20", "UP"]
|
||||
ignore = ["D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107"]
|
||||
|
||||
[tool.pyright]
|
||||
ignore = [".venv/**"]
|
||||
include = ["tagstudio/**"]
|
||||
reportAny = false
|
||||
reportImplicitStringConcatenation = false
|
||||
# reportOptionalMemberAccess = false
|
||||
reportUnannotatedClassAttribute = false
|
||||
reportUnknownArgumentType = false
|
||||
reportUnknownMemberType = false
|
||||
reportUnusedCallResult = false
|
||||
|
||||
[tool.mypy]
|
||||
strict_optional = false
|
||||
disable_error_code = ["func-returns-value", "import-untyped"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -78,7 +78,7 @@ app = BUNDLE(
|
||||
name='TagStudio.app',
|
||||
icon=icon,
|
||||
bundle_identifier='com.cyanvoxel.tagstudio',
|
||||
version='9.5.0-pr2',
|
||||
version='9.5.0-pr4',
|
||||
info_plist={
|
||||
'NSAppleScriptEnabled': False,
|
||||
'NSPrincipalClass': 'NSApplication',
|
||||
|
||||
BIN
tagstudio/resources/qt/videos/placeholder.mp4
Normal file
BIN
tagstudio/resources/qt/videos/placeholder.mp4
Normal file
Binary file not shown.
@@ -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"
|
||||
}
|
||||
|
||||
@@ -2,6 +2,19 @@
|
||||
"app.git": "Git Commit",
|
||||
"app.pre_release": "Pre-Release",
|
||||
"app.title": "{base_title} - Library '{library_dir}'",
|
||||
"color_manager.title": "Manage Tag Colors",
|
||||
"color.color_border": "Use Secondary Color for Border",
|
||||
"color.confirm_delete": "Are you sure you want to delete the color \"{color_name}\"?",
|
||||
"color.delete": "Delete Tag",
|
||||
"color.import_pack": "Import Color Pack",
|
||||
"color.name": "Name",
|
||||
"color.namespace.delete.prompt": "Are you sure you want to delete this color namespace? This will delete ALL colors in the namespace along with it!",
|
||||
"color.namespace.delete.title": "Delete Color Namespace",
|
||||
"color.new": "New Color",
|
||||
"color.placeholder": "Color",
|
||||
"color.primary_required": "Primary Color (Required)",
|
||||
"color.primary": "Primary Color",
|
||||
"color.secondary": "Secondary Color",
|
||||
"color.title.no_color": "No Color",
|
||||
"drop_import.description": "The following files match file paths that already exist in the library",
|
||||
"drop_import.duplicates_choice.plural": "The following {count} files match file paths that already exist in the library.",
|
||||
@@ -11,6 +24,7 @@
|
||||
"drop_import.progress.label.singular": "Importing New Files...\n1 File imported.{suffix}",
|
||||
"drop_import.progress.window_title": "Import Files",
|
||||
"drop_import.title": "Conflicting File(s)",
|
||||
"edit.color_manager": "Manage Tag Colors",
|
||||
"edit.tag_manager": "Manage Tags",
|
||||
"entries.duplicate.merge.label": "Merging Duplicate Entries...",
|
||||
"entries.duplicate.merge": "Merge Duplicate Entries",
|
||||
@@ -96,6 +110,7 @@
|
||||
"generic.recent_libraries": "Recent Libraries",
|
||||
"generic.rename_alt": "&Rename",
|
||||
"generic.rename": "Rename",
|
||||
"generic.reset": "Reset",
|
||||
"generic.save": "Save",
|
||||
"generic.skip_alt": "&Skip",
|
||||
"generic.skip": "Skip",
|
||||
@@ -143,6 +158,10 @@
|
||||
"json_migration.title.old_lib": "<h2>v9.4 Library</h2>",
|
||||
"json_migration.title": "Save Format Migration: \"{path}\"",
|
||||
"landing.open_create_library": "Open/Create Library {shortcut}",
|
||||
"library_object.name_required": "Name (Required)",
|
||||
"library_object.name": "Name",
|
||||
"library_object.slug_required": "ID Slug (Required)",
|
||||
"library_object.slug": "ID Slug",
|
||||
"library.field.add": "Add Field",
|
||||
"library.field.confirm_remove": "Are you sure you want to remove this \"{name}\" field?",
|
||||
"library.field.mixed_data": "Mixed Data",
|
||||
@@ -157,6 +176,9 @@
|
||||
"macros.running.dialog.new_entries": "Running Configured Macros on {count}/{total} New File Entries...",
|
||||
"macros.running.dialog.title": "Running Macros on New Entries",
|
||||
"media_player.autoplay": "Autoplay",
|
||||
"menu.delete_selected_files_ambiguous": "Move File(s) to {trash_term}",
|
||||
"menu.delete_selected_files_plural": "Move Files to {trash_term}",
|
||||
"menu.delete_selected_files_singular": "Move File to {trash_term}",
|
||||
"menu.edit.ignore_list": "Ignore Files and Folders",
|
||||
"menu.edit.manage_file_extensions": "Manage File Extensions",
|
||||
"menu.edit.manage_tags": "Manage Tags",
|
||||
@@ -172,16 +194,23 @@
|
||||
"menu.file.save_backup": "&Save Library Backup",
|
||||
"menu.file.save_library": "Save Library",
|
||||
"menu.file": "&File",
|
||||
"menu.help": "&Help",
|
||||
"menu.help.about": "About",
|
||||
"menu.help": "&Help",
|
||||
"menu.macros.folders_to_tags": "Folders to Tags",
|
||||
"menu.macros": "&Macros",
|
||||
"menu.select": "Select",
|
||||
"menu.settings": "Settings...",
|
||||
"menu.tools.fix_duplicate_files": "Fix Duplicate &Files",
|
||||
"menu.tools.fix_unlinked_entries": "Fix &Unlinked Entries",
|
||||
"menu.tools": "&Tools",
|
||||
"menu.view": "&View",
|
||||
"menu.window": "Window",
|
||||
"namespace.create.description_color": "Tag colors use namespaces as color palette groups. All custom colors must be under a namespace group first.",
|
||||
"namespace.create.description": "Namespaces are used by TagStudio to separate groups of items such as tags and colors in a way that makes them easy to export and share. Namespaces starting with \"tagstudio\" are reserved by TagStudio for internal use.",
|
||||
"namespace.create.title": "Create Namespace",
|
||||
"namespace.new.button": "New Namespace",
|
||||
"namespace.new.prompt": "Create a New Namespace to Start Adding Custom Colors!",
|
||||
"preview.multiple_selection": "<b>{count}</b> Items Selected",
|
||||
"preview.no_selection": "No Items Selected",
|
||||
"select.add_tag_to_selected": "Add Tag to Selected",
|
||||
"select.all": "Select All",
|
||||
@@ -189,12 +218,20 @@
|
||||
"edit.copy_fields": "Copy Fields",
|
||||
"edit.paste_fields": "Paste Fields",
|
||||
"settings.clear_thumb_cache.title": "Clear Thumbnail Cache",
|
||||
"settings.language": "Language",
|
||||
"settings.open_library_on_start": "Open Library on Start",
|
||||
"settings.restart_required": "Please restart TagStudio for changes to take effect.",
|
||||
"settings.show_filenames_in_grid": "Show Filenames in Grid",
|
||||
"settings.show_recent_libraries": "Show Recent Libraries",
|
||||
"settings.title": "Settings",
|
||||
"sorting.direction.ascending": "Ascending",
|
||||
"sorting.direction.descending": "Descending",
|
||||
"splash.opening_library": "Opening Library \"{library_path}\"...",
|
||||
"status.deleted_file_plural": "Deleted {count} files!",
|
||||
"status.deleted_file_singular": "Deleted 1 file!",
|
||||
"status.deleted_none": "No files deleted.",
|
||||
"status.deleted_partial_warning": "Only deleted {count} file(s)! Check if any of the files are currently missing or in use.",
|
||||
"status.deleting_file": "Deleting file [{i}/{count}]: \"{path}\"...",
|
||||
"status.library_backup_in_progress": "Saving Library Backup...",
|
||||
"status.library_backup_success": "Library Backup Saved at: \"{path}\" ({time_span})",
|
||||
"status.library_closed": "Library Closed ({time_span})",
|
||||
@@ -212,6 +249,7 @@
|
||||
"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}\"?",
|
||||
@@ -219,6 +257,7 @@
|
||||
"tag.create": "Create Tag",
|
||||
"tag.disambiguation.tooltip": "Use this tag for disambiguation",
|
||||
"tag.edit": "Edit Tag",
|
||||
"tag.is_category": "Is Category",
|
||||
"tag.name": "Name",
|
||||
"tag.new": "New Tag",
|
||||
"tag.parent_tags.add": "Add Parent Tag(s)",
|
||||
@@ -228,6 +267,19 @@
|
||||
"tag.search_for_tag": "Search for Tag",
|
||||
"tag.shorthand": "Shorthand",
|
||||
"tag.tag_name_required": "Tag Name (Required)",
|
||||
"tag.view_limit": "View Limit:",
|
||||
"trash.context.ambiguous": "Move file(s) to {trash_term}",
|
||||
"trash.context.plural": "Move files to {trash_term}",
|
||||
"trash.context.singular": "Move file to {trash_term}",
|
||||
"trash.dialog.disambiguation_warning.plural": "This will remove them from TagStudio <i>AND</i> your file system!",
|
||||
"trash.dialog.disambiguation_warning.singular": "This will remove it from TagStudio <i>AND</i> your file system!",
|
||||
"trash.dialog.move.confirmation.plural": "Are you sure you want to move these {count} files to the {trash_term}?",
|
||||
"trash.dialog.move.confirmation.singular": "Are you sure you want to move this file to the {trash_term}?",
|
||||
"trash.dialog.permanent_delete_warning": "<b>WARNING!</b> If this file can't be moved to the {trash_term}, <b>it will be <b>permanently deleted!</b>",
|
||||
"trash.dialog.title.plural": "Delete Files",
|
||||
"trash.dialog.title.singular": "Delete File",
|
||||
"trash.name.generic": "Trash",
|
||||
"trash.name.windows": "Recycle Bin",
|
||||
"view.size.0": "Mini",
|
||||
"view.size.1": "Small",
|
||||
"view.size.2": "Medium",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"app.git": "Git Commit",
|
||||
"app.pre_release": "Previas al lanzamiento",
|
||||
"app.title": "{base_title} - Biblioteca '{library_dir}'",
|
||||
"color.title.no_color": "sin color",
|
||||
"drop_import.description": "Los siguientes archivos tienen nombres de archivo que ya existen en la biblioteca",
|
||||
"drop_import.duplicates_choice.plural": "Los siguientes {count} archivos tienen nombres de archivo que ya existen en la biblioteca.",
|
||||
"drop_import.duplicates_choice.singular": "El siguiente archivo tiene un nombre de archivo que ya existe en la biblioteca.",
|
||||
@@ -98,7 +99,7 @@
|
||||
"home.search_entries": "Buscar entradas",
|
||||
"home.search_library": "Buscar el biblioteca",
|
||||
"home.search_tags": "Buscar etiquetas",
|
||||
"home.thumbnail_size": "Tamaño de las imágenes",
|
||||
"home.thumbnail_size": "Tamaño de la vista previa",
|
||||
"home.thumbnail_size.extra_large": "Imágenes extra grandes",
|
||||
"home.thumbnail_size.large": "Imágenes grandes",
|
||||
"home.thumbnail_size.medium": "Imágenes medianas",
|
||||
|
||||
@@ -3,19 +3,21 @@
|
||||
"about.title": "À propos",
|
||||
"app.git": "Git Commit",
|
||||
"app.pre_release": "Version Préliminaire",
|
||||
"app.title": "ase_title} - Bibliothèque '{library_dir}'",
|
||||
"app.title": "{base_title} - Bibliothèque '{library_dir}'",
|
||||
"color.title.no_color": "Aucune couleur",
|
||||
"drop_import.description": "Les fichiers suivants correspondent à des chemins de fichiers déjà existant dans la bibliothèque",
|
||||
"drop_import.duplicates_choice.plural": "Les noms des {count} fichiers suivants existent déjà dans la Bibliothèque.",
|
||||
"drop_import.duplicates_choice.singular": "Le fichier suivant a un nom déjà existant dans la bibliothèque.",
|
||||
"drop_import.progress.label.initial": "Import des Nouveaux Fichiers...",
|
||||
"drop_import.progress.label.plural": "Import des Nouveaux Fichiers...\n{count} Fichiers Importés.{suffix}",
|
||||
"drop_import.progress.label.singular": "Import des Nouveaux Fichiers...\n1 Fichier Importé.{suffix}",
|
||||
"drop_import.duplicates_choice.plural": "Les chemins d'accès des {count} fichiers suivants existent déjà dans la Bibliothèque.",
|
||||
"drop_import.duplicates_choice.singular": "Le fichier suivant a un chemins d'accès déjà existant dans la bibliothèque.",
|
||||
"drop_import.progress.label.initial": "Importer des Nouveaux Fichiers...",
|
||||
"drop_import.progress.label.plural": "Importation des Nouveaux Fichiers...\n{count} Fichiers Importés.{suffix}",
|
||||
"drop_import.progress.label.singular": "Importation des Nouveaux Fichiers...\n1 Fichier Importé.{suffix}",
|
||||
"drop_import.progress.window_title": "Importer des Fichiers",
|
||||
"drop_import.title": "Fichier(s) en Conflit",
|
||||
"edit.copy_fields": "Copier les Fields",
|
||||
"edit.paste_fields": "Coller les Fields",
|
||||
"edit.tag_manager": "Gérer les Tags",
|
||||
"entries.duplicate.merge": "Fusion des Duplicatas",
|
||||
"entries.duplicate.merge.label": "Fusionner les duplicatas...",
|
||||
"entries.duplicate.merge": "Fusion des entrées dupliquées",
|
||||
"entries.duplicate.merge.label": "Fusionner les entrées dupliquées...",
|
||||
"entries.duplicate.refresh": "Rafraichir les Entrées en Doublon",
|
||||
"entries.duplicates.description": "Les entrées dupliquées sont définies comme des entrées multiple qui pointent vers le même fichier sur le disque. Les fusionner va combiner les labels et metadatas de tous les duplicatas vers une seule entrée consolidée. Elles ne doivent pas être confondues avec les \"fichiers en doublon\", qui sont des doublons de vos fichiers en dehors de TagStudio.",
|
||||
"entries.mirror": "&Refléter",
|
||||
@@ -25,7 +27,7 @@
|
||||
"entries.mirror.window_title": "Entrée Miroir",
|
||||
"entries.running.dialog.new_entries": "Ajout de {total} Nouvelles entrées de fichier...",
|
||||
"entries.running.dialog.title": "Ajout de Nouvelles entrées de fichier",
|
||||
"entries.tags": "Labels",
|
||||
"entries.tags": "Tags",
|
||||
"entries.unlinked.delete": "Supprimer les Entrées non Liées",
|
||||
"entries.unlinked.delete.confirm": "Êtes-vous sûr de vouloir supprimer les {count} entrées suivantes ?",
|
||||
"entries.unlinked.delete.deleting": "Suppression des Entrées",
|
||||
@@ -51,14 +53,14 @@
|
||||
"file.duplicates.description": "TagStudio supporte l'importation de résultats DupeGuru pour gérer les doublons de fichier.",
|
||||
"file.duplicates.dupeguru.advice": "Après réplication, vous êtes libre d'utiliser DupeGuru pour supprimer des fichiers non désirés. Ensuite, utilisez la fonctionnalité \"Réparation des Entrées non Liées\" de TagStudio dans le menu Outils pour supprimer les Entrées non liées.",
|
||||
"file.duplicates.dupeguru.file_extension": "Fichiers DupeGuru (*.dupeguru)",
|
||||
"file.duplicates.dupeguru.load_file": "Charger un Fichier DupeGuru",
|
||||
"file.duplicates.dupeguru.load_file": "&Charger un Fichier DupeGuru",
|
||||
"file.duplicates.dupeguru.no_file": "Aucun Fichier DupeGuru Sélectionné",
|
||||
"file.duplicates.dupeguru.open_file": "Ouvrire les Fichiers de Résultats de DupeGuru",
|
||||
"file.duplicates.fix": "Réparer les Fichiers en Double",
|
||||
"file.duplicates.matches": "Dupliquer les Correspondances de Fichier : %{count}",
|
||||
"file.duplicates.matches": "Dupliquer les Correspondances de Fichier: {count}",
|
||||
"file.duplicates.matches_uninitialized": "Dupliquer les Correspondances de Fichier : N/A",
|
||||
"file.duplicates.mirror.description": "Repliquer les données d'entrée dans chaque jeu de correspondances en double, en combinant toutes les données sans supprimer ni dupliquer de champs. Cette opération ne supprime aucun fichier ni aucune donnée.",
|
||||
"file.duplicates.mirror_entries": "Répliquer les Entrées",
|
||||
"file.duplicates.mirror_entries": "&Répliquer les Entrées",
|
||||
"file.duration": "Durée",
|
||||
"file.not_found": "Fichier non trouvé",
|
||||
"file.open_file": "Ouvrir un Fichier",
|
||||
@@ -110,13 +112,14 @@
|
||||
"home.thumbnail_size.medium": "Miniatures Moyennes",
|
||||
"home.thumbnail_size.mini": "Mini Miniatures",
|
||||
"home.thumbnail_size.small": "Petites Miniatures",
|
||||
"ignore_list.add_extension": "Ajouter une Extension",
|
||||
"ignore_list.add_extension": "&Ajouter une Extension",
|
||||
"ignore_list.mode.exclude": "Exclure",
|
||||
"ignore_list.mode.include": "Inclure",
|
||||
"ignore_list.mode.label": "Mode Liste",
|
||||
"ignore_list.mode.label": "Mode de la liste:",
|
||||
"ignore_list.title": "Extensions de Fichiers",
|
||||
"json_migration.checking_for_parity": "Vérification de la Parité...",
|
||||
"json_migration.creating_database_tables": "Création des Tables de Base de Données SQL...",
|
||||
"json_migration.description": "<br>Démarrez et prévisualisez les résultats du processus de migration de la bibliothèque. La bibliothèque convertie <i>ne</i> sera utilisée que si vous cliquez sur \"Terminer la migration\". <br><br>Les données de la bibliothèque doivent soit avoir des valeurs correspondantes, soit comporter un label \"Matched\". Les valeurs qui ne correspondent pas seront affichées en rouge et comporteront un symbole \"<b>(!)</b>\" à côté d'elles.<br><center><i>Ce processus peut prendre jusqu'à plusieurs minutes pour les bibliothèques plus volumineuses.</i></center>",
|
||||
"json_migration.discrepancies_found": "Divergence Détectées dans la Bibliothèque",
|
||||
"json_migration.discrepancies_found.description": "Des divergences ont été détectées entre le format d'origine et le format converti de la bibliothèque. Veuillez les examiner et choisir de poursuivre la migration ou de l'annuler.",
|
||||
"json_migration.finish_migration": "Terminer la Migration",
|
||||
@@ -124,67 +127,135 @@
|
||||
"json_migration.heading.colors": "Couleurs:",
|
||||
"json_migration.heading.differ": "Divergence",
|
||||
"json_migration.heading.entires": "Entrées:",
|
||||
"json_migration.heading.extension_list_type": "Type de liste d'extension:",
|
||||
"json_migration.heading.fields": "Champs:",
|
||||
"json_migration.heading.file_extension_list": "Liste des extensions de fichiers:",
|
||||
"json_migration.heading.match": "Correspondant",
|
||||
"json_migration.heading.names": "Noms:",
|
||||
"json_migration.heading.parent_tags": "Tags Parents:",
|
||||
"json_migration.heading.paths": "Chemins:",
|
||||
"json_migration.heading.shorthands": "Abréviations:",
|
||||
"json_migration.heading.tags": "Tags:",
|
||||
"json_migration.info.description": "Les fichiers de sauvegarde de bibliothèque créés avec les versions <b>9.4 et inférieures</b> de TagStudio devront être migrés vers le nouveau format <b>v9.5+</b>.<br><h2>Ce que vous devez savoir:</h2><ul><li>Votre fichier de sauvegarde de bibliothèque existant ne sera <b><i>PAS</i></b> supprimé</li><li>Vos fichiers personnels ne seront <b><i>PAS</i></b> supprimés, déplacés ou modifié</li><li>Le nouveau format de sauvegarde v9.5+ ne peut pas être ouvert dans les versions antérieures de TagStudio</li></ul><h3>Ce qui a changé:</h3><ul><li>Les \"Tag Fields\" ont été remplacés par \"Tag Categories\". Au lieu d’ajouter d’abord des Tags aux fields, les Tags sont désormais ajoutées directement aux entrées de fichier. Ils sont ensuite automatiquement organisés en catégories basées sur les tags parent marquées avec la nouvelle propriété \"Is Category\" dans le menu d'édition des tags. N'importe quelle tag peut être marquée comme catégorie, et les tags enfants seront triées sous les tags parents marquées comme catégories. Les tags « Favoris » et « Archivés » héritent désormais d'un nouveaux tag \"Meta Tags\" qui est marquée comme catégorie par défaut.</li><li>La couleur des tags à été modifiées et développées. Certaines couleurs ont été renommées ou consolidées, mais toutes les couleurs des tags seront toujours converties en correspondances exactes ou proches dans la version 9.5.</li></ul><ul>",
|
||||
"json_migration.migrating_files_entries": "Migration de {entries:,d} entrées de fichier.",
|
||||
"json_migration.migration_complete": "Migration Terminée!",
|
||||
"json_migration.migration_complete_with_discrepancies": "Migration Terminée, Divergences Trouvées",
|
||||
"json_migration.start_and_preview": "Commencer et Prévisualiser",
|
||||
"json_migration.title": "Migration du format d'enregistrement: \"{path}\"",
|
||||
"json_migration.title.new_lib": "<h2>v9.5+ Library</h2>",
|
||||
"json_migration.title.old_lib": "<h2>v9.4 Library</h2>",
|
||||
"landing.open_create_library": "Ouvrir/Créer une Bibliothèque {shortcut}",
|
||||
"library.field.add": "Ajouter un Champ",
|
||||
"library.field.confirm_remove": "Êtes-vous sûr de vouloir supprimer le champ \"%{self.lib.get_field_attr(field, \"name\")}\" ?",
|
||||
"library.field.confirm_remove": "Êtes-vous sûr de vouloir supprimer le champ \"{name}\"?",
|
||||
"library.field.mixed_data": "Données Mélangées",
|
||||
"library.field.remove": "Supprimer un Champ",
|
||||
"library.missing": "Emplacement Manquant",
|
||||
"library.name": "Bibliothèque",
|
||||
"library.refresh.scanning.plural": "Analyse du Répertoire pour de Nouveaux Fichiers...\n{searched_count} Fichiers Trouvées, {found_count} Nouveaux Fichiers",
|
||||
"library.refresh.scanning.singular": "Analyse du Répertoire pour de Nouveaux Fichiers...\n{searched_count} Fichier Trouvé, {found_count} Nouveaux Fichiers",
|
||||
"library.refresh.scanning_preparing": "Recherche de Nouveaux Fichiers dans les Dossiers...\nPréparation...",
|
||||
"library.refresh.title": "Rafraîchissement des Dossiers",
|
||||
"library.scan_library.title": "Balayage de la Bibliothèque",
|
||||
"macros.running.dialog.new_entries": "Éxectution des Macros Configurées sur %{x + 1}/%{len(new_ids)} Nouvelles Entrées",
|
||||
"macros.running.dialog.new_entries": "Exécution des Macros Configurées sur {count}/{total} Nouvelles Entrées de Fichiers...",
|
||||
"macros.running.dialog.title": "Exécution des Macros sur les Nouvelles Entrées",
|
||||
"media_player.autoplay": "Lecture automatique",
|
||||
"menu.delete_selected_files_ambiguous": "Déplacer les Fichier(s) vers {trash_term}",
|
||||
"menu.delete_selected_files_plural": "Déplacer les Fichiers vers {trash_term}",
|
||||
"menu.delete_selected_files_singular": "Déplacer le Fichier vers {trash_term}",
|
||||
"menu.edit": "Édition",
|
||||
"menu.edit.ignore_list": "Ignorer les Fichiers et Dossiers",
|
||||
"menu.file": "Fichier",
|
||||
"menu.edit.manage_file_extensions": "Gérer les extensions de fichier",
|
||||
"menu.edit.manage_tags": "Gérer les Tags",
|
||||
"menu.edit.new_tag": "Nouveaux &Tag",
|
||||
"menu.file": "&Fichier",
|
||||
"menu.file.clear_recent_libraries": "Supprimer l'historique de Bibliothèque récente",
|
||||
"menu.file.close_library": "&Fermer la Bibliothèque",
|
||||
"menu.file.new_library": "Nouvelle Bibliothéque",
|
||||
"menu.file.open_create_library": "Ouvrir/Créer une Bibliothèque",
|
||||
"menu.file.open_create_library": "&Ouvrir/Créer une Bibliothèque",
|
||||
"menu.file.open_library": "Ouvrir la Bibliothèque",
|
||||
"menu.file.open_recent_library": "Ouvrir la Bibliothèque récente",
|
||||
"menu.file.refresh_directories": "&Rafraichir les Répertoires",
|
||||
"menu.file.save_backup": "&Sauvegarde de la Bibliothèque",
|
||||
"menu.file.save_library": "Enregistrer la Bibliothèque",
|
||||
"menu.help": "Aide",
|
||||
"menu.macros": "Macros",
|
||||
"menu.help": "&Aide",
|
||||
"menu.help.about": "À propos",
|
||||
"menu.macros": "&Macros",
|
||||
"menu.macros.folders_to_tags": "Répertoires à Tags",
|
||||
"menu.select": "Sélectionner",
|
||||
"menu.tools": "Outils",
|
||||
"menu.view": "Vues",
|
||||
"menu.tools": "&Outils",
|
||||
"menu.tools.fix_duplicate_files": "Réparer les entrées de fichiers en double",
|
||||
"menu.tools.fix_unlinked_entries": "Réparer les entrées de fichier non liée",
|
||||
"menu.view": "&Vues",
|
||||
"menu.window": "Fenêtre",
|
||||
"preview.no_selection": "Pas d'Objet Selectionné",
|
||||
"select.add_tag_to_selected": "Ajouter un Tag à la sélection",
|
||||
"select.all": "Tout Sélectionner",
|
||||
"select.clear": "Effacer la Sélection",
|
||||
"settings.clear_thumb_cache.title": "Effacer le cache des vignettes",
|
||||
"settings.open_library_on_start": "Ouvrir la Bibliothèque au Démarrage",
|
||||
"settings.show_filenames_in_grid": "Afficher les Noms de Fichiers en Grille",
|
||||
"settings.show_recent_libraries": "Afficher les Bibliothèques Récentes",
|
||||
"sorting.direction.ascending": "Croissant",
|
||||
"sorting.direction.descending": "Décroissant",
|
||||
"splash.opening_library": "Ouverture de la Bibliothèque",
|
||||
"status.library_backup_success": "Bibliothèque sauvegardée au chemin :",
|
||||
"status.library_save_success": "Bibliothèque Sauvegardée et Fermée !",
|
||||
"status.library_search_query": "Recherche dans la Bibliothèque pour",
|
||||
"splash.opening_library": "Ouverture de la Bibliothèque \"{library_path}\"...",
|
||||
"status.deleted_file_plural": "Suppression de {count} fichiers!",
|
||||
"status.deleted_file_singular": "Suppression de 1 fichier!",
|
||||
"status.deleted_none": "Aucun fichiers supprimer.",
|
||||
"status.deleted_partial_warning": "Seulement {count} fichier(s) on été supprimé! Vérifier si les fichiers restant ne serais pas manquant ou en cours d'utilisation.",
|
||||
"status.deleting_file": "Suppression de fichier(s) [{i}/{count}]: \"{path}\"...",
|
||||
"status.library_backup_in_progress": "Création d'une Sauvegarde de la Bibliothèque...",
|
||||
"status.library_backup_success": "Bibliothèque sauvegardée au chemin: \"{path}\" ({time_span})",
|
||||
"status.library_closed": "Bibliothèque fermée ({time_span})",
|
||||
"status.library_closing": "Fermeture de la Bibliothèque...",
|
||||
"status.library_save_success": "Bibliothèque Sauvegardée et Fermée!",
|
||||
"status.library_search_query": "Rechercher dans la Bibliothèque...",
|
||||
"status.library_version_expected": "Exceptée:",
|
||||
"status.library_version_found": "Trouvée:",
|
||||
"status.library_version_mismatch": "La version de la library ne correspond pas!",
|
||||
"status.results": "Résultats",
|
||||
"status.results_found": "{results.total_count} Résultats Trouvés",
|
||||
"tag.add": "Ajouter un Label",
|
||||
"status.results.invalid_syntax": "Syntaxe de recherche invalide:",
|
||||
"status.results_found": "{count} Résultats Trouvés({time_span})",
|
||||
"tag.add": "Ajouter un Tag",
|
||||
"tag.add.plural": "Ajouter des Tags",
|
||||
"tag.add_to_search": "Ajouter à la Recherche",
|
||||
"tag.aliases": "Alias",
|
||||
"tag.all_tags": "Tout les Tags",
|
||||
"tag.choose_color": "Choisir une couleur de Tag",
|
||||
"tag.color": "Couleur",
|
||||
"tag.create_add": "Créer && Adjouter \"{query}\"",
|
||||
"tag.confirm_delete": "Voulez vous vraiment supprimer le tag \"{tag_name}\"?",
|
||||
"tag.create": "Créer un Tag",
|
||||
"tag.create_add": "Créer && Ajouter \"{query}\"",
|
||||
"tag.disambiguation.tooltip": "Utilisez ce Tag pour définir une ambiguïté",
|
||||
"tag.edit": "Modifier un Tag",
|
||||
"tag.name": "Nom",
|
||||
"tag.new": "Nouveau Label",
|
||||
"tag.parent_tags": "Labels Parent",
|
||||
"tag.parent_tags.add": "Ajouter des Labels Parents",
|
||||
"tag.new": "Nouveau Tag",
|
||||
"tag.parent_tags": "Tags Parent",
|
||||
"tag.parent_tags.add": "Ajouter des Tags Parents",
|
||||
"tag.parent_tags.description": "Ce Tag peut être utilisé en replacement de tous ces Tags Parents dans les recherches.",
|
||||
"tag.remove": "Supprimer un Tag",
|
||||
"tag.search_for_tag": "Recherche de Label",
|
||||
"tag.shorthand": "Abrégé",
|
||||
"tag.tag_name_required": "Nom du Tag (Requis)",
|
||||
"tag.view_limit": "Limite d'affichage:",
|
||||
"tag_manager.title": "Labels de la Bibliothèque",
|
||||
"trash.context.ambiguous": "Déplacer les fichier(s) vers {trash_term}",
|
||||
"trash.context.plural": "Déplacer les fichiers vers {trash_term}",
|
||||
"trash.context.singular": "Déplacer le fichier vers {trash_term}",
|
||||
"trash.dialog.disambiguation_warning.plural": "Cela les retirera de TagStudio <i>ET</i> de votre système de fichier!",
|
||||
"trash.dialog.disambiguation_warning.singular": "Cela le retirera de TagStudio <i>ET</i> de votre système de fichier!",
|
||||
"trash.dialog.move.confirmation.plural": "Voulez vous vraiment déplacer {count} fichiers vers la {trash_term}?",
|
||||
"trash.dialog.move.confirmation.singular": "Voulez vous vraiment déplacer ce fichier vers la {trash_term}?",
|
||||
"trash.dialog.permanent_delete_warning": "<b>ATTENTION!</b> Si le fichier ne peut pas être déplacer vers la {trash_term}, <b>elle sera<b>supprimer de manière permanente!</b>",
|
||||
"trash.dialog.title.plural": "Supprimer les Fichiers",
|
||||
"trash.dialog.title.singular": "Supprimer le Fichier",
|
||||
"trash.name.generic": "Poubelle",
|
||||
"trash.name.windows": "Corbeille",
|
||||
"view.size.0": "Mini",
|
||||
"view.size.1": "Petit",
|
||||
"view.size.2": "Moyen",
|
||||
"view.size.3": "Grand",
|
||||
"view.size.4": "Très Grand"
|
||||
"view.size.4": "Très Grand",
|
||||
"window.message.error_opening_library": "Une erreur est survenue lors de l'ouverture de la bibliothèque.",
|
||||
"window.title.error": "Erreur",
|
||||
"window.title.open_create_library": "Ouvrir/Créer une Bibliothèque"
|
||||
}
|
||||
|
||||
@@ -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…",
|
||||
@@ -157,6 +159,9 @@
|
||||
"macros.running.dialog.new_entries": "Korábban beállított makrók futtatása {total}/{count} új elemen…",
|
||||
"macros.running.dialog.title": "Makrók futtatása az új elemeken",
|
||||
"media_player.autoplay": "Automatikus lejátszás",
|
||||
"menu.delete_selected_files_ambiguous": "Fájl(ok) {trash_term} helyezése",
|
||||
"menu.delete_selected_files_plural": "Fájlok {trash_term} helyezése",
|
||||
"menu.delete_selected_files_singular": "Fájl {trash_term} helyezése",
|
||||
"menu.edit": "S&zerkesztés",
|
||||
"menu.edit.ignore_list": "Fájlok és mappák figyelmen kívül hagyása",
|
||||
"menu.edit.manage_file_extensions": "&Fájlkiterjesztések kezelése",
|
||||
@@ -193,6 +198,11 @@
|
||||
"sorting.direction.ascending": "Növekvő sorrend",
|
||||
"sorting.direction.descending": "Csökkenő sorrend",
|
||||
"splash.opening_library": "Könyvtár megnyitása folyamatban: „{library_path}”…",
|
||||
"status.deleted_file_plural": "{count} fájl törlése befejeződött.",
|
||||
"status.deleted_file_singular": "1 fájl törlése befejeződött.",
|
||||
"status.deleted_none": "Egy fájl sem került törlésre.",
|
||||
"status.deleted_partial_warning": "Csak {count} fájl került törlésre. Lehetséges, hogy a többi fájl hiányzik vagy használatban van.",
|
||||
"status.deleting_file": "{count}/{i}. fájl törlése folyamatban: „{path}”…",
|
||||
"status.library_backup_in_progress": "Könyvtár biztonsági mentése folyamatban…",
|
||||
"status.library_backup_success": "A biztonsági mentés létrehozása megtörtént az alábbi elérési úton: „{path}” ({time_span})",
|
||||
"status.library_closed": "Könyvtár bezárása ({time_span}) sikeresen megtörtént.",
|
||||
@@ -203,11 +213,13 @@
|
||||
"status.library_version_found": "Tényleges érték:",
|
||||
"status.library_version_mismatch": "A könyvtár és a program verziója nem egyezik.",
|
||||
"status.results": "találat",
|
||||
"status.results.invalid_syntax": "Szintaktikai hiba:",
|
||||
"status.results_found": "{count} találat ({time_span})",
|
||||
"tag.add": "Címke hozzáadása",
|
||||
"tag.add.plural": "Címkék hozzáadása",
|
||||
"tag.add_to_search": "Keresési kifejezés kiegészítése",
|
||||
"tag.aliases": "Áljelek",
|
||||
"tag.all_tags": "Minden címke",
|
||||
"tag.choose_color": "Címkeszín",
|
||||
"tag.color": "Szín",
|
||||
"tag.confirm_delete": "Biztosan törölni akarja a(z) „{tag_name}” címkét?",
|
||||
@@ -224,7 +236,20 @@
|
||||
"tag.search_for_tag": "Címke keresése",
|
||||
"tag.shorthand": "Rövidítés",
|
||||
"tag.tag_name_required": "Címkenév (Kötelező)",
|
||||
"tag.view_limit": "Megtekintési korlát:",
|
||||
"tag_manager.title": "Könyvtárcímkék",
|
||||
"trash.context.ambiguous": "Fájl(ok) {trash_term} helyezése",
|
||||
"trash.context.plural": "Fájlok {trash_term} helyezése",
|
||||
"trash.context.singular": "Fájl {trash_term} helyezése",
|
||||
"trash.dialog.disambiguation_warning.plural": "Ezzel a fájlok nem csak a TagStudióból, hanem a fájlrendszerből <i>is</i> el lesznek távolítva!",
|
||||
"trash.dialog.disambiguation_warning.singular": "Ezzel a fájl nem csak a TagStudióból, hanem a fájlrendszerből <i>is</i> el lesz távolítva!",
|
||||
"trash.dialog.move.confirmation.plural": "Biztosan a {trash_term} akarod helyezni ezt a(z) {count} elemet?",
|
||||
"trash.dialog.move.confirmation.singular": "Biztosan a {trash_term} akarod helyezni ezt az elemet?",
|
||||
"trash.dialog.permanent_delete_warning": "<b>FIGYELMEZTETÉS:</b> Ha a fájlt nem lehet a {trash_term} helyezni, akkor <b>véglegesen <b>törlésre kerül!</b>",
|
||||
"trash.dialog.title.plural": "Fájlok törlése",
|
||||
"trash.dialog.title.singular": "Fájl törlése",
|
||||
"trash.name.generic": "Kukába",
|
||||
"trash.name.windows": "Lomtárba",
|
||||
"view.size.0": "Apró",
|
||||
"view.size.1": "Kicsi",
|
||||
"view.size.2": "Közepes",
|
||||
|
||||
106
tagstudio/resources/translations/nl.json
Normal file
106
tagstudio/resources/translations/nl.json
Normal file
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"color.title.no_color": "Geen Kleur",
|
||||
"drop_import.progress.label.initial": "Nieuwe bestanden importeren…",
|
||||
"drop_import.progress.label.plural": "Nieuwe bestanden importeren…\n{count} bestanden geïmporteerd.{suffix}",
|
||||
"drop_import.progress.label.singular": "Nieuwe bestanden importeren…\n1 bestand geïmporteerd.{suffix}",
|
||||
"drop_import.progress.window_title": "Importeer bestanden",
|
||||
"edit.copy_fields": "Velden Kopiëren",
|
||||
"edit.paste_fields": "Velden Plakken",
|
||||
"edit.tag_manager": "Beheer Labels",
|
||||
"entries.tags": "Labels",
|
||||
"field.copy": "Veld Kopiëren",
|
||||
"field.edit": "Veld Aanpassen",
|
||||
"field.paste": "Veld Plakken",
|
||||
"file.date_added": "Datum Toegevoegd",
|
||||
"file.date_created": "Datum Aangemaakt",
|
||||
"file.date_modified": "Datum Aangepast",
|
||||
"file.dimensions": "Dimensies",
|
||||
"file.duration": "Speelduur",
|
||||
"file.not_found": "Bestand Niet Gevonden",
|
||||
"file.open_file": "Bestand openen",
|
||||
"file.open_file_with": "Bestand openen met",
|
||||
"folders_to_tags.close_all": "Alles sluiten",
|
||||
"folders_to_tags.open_all": "Alles openen",
|
||||
"generic.add": "Toevoegen",
|
||||
"generic.apply": "Toepassen",
|
||||
"generic.apply_alt": "&Toepassen",
|
||||
"generic.cancel": "Annuleren",
|
||||
"generic.cancel_alt": "&Annuleren",
|
||||
"generic.close": "Sluiten",
|
||||
"generic.continue": "Doorgaan",
|
||||
"generic.copy": "Kopiëren",
|
||||
"generic.cut": "Knippen",
|
||||
"generic.delete": "Verwijderen",
|
||||
"generic.delete_alt": "&Verwijderen",
|
||||
"generic.done": "Klaar",
|
||||
"generic.done_alt": "&klaar",
|
||||
"generic.edit": "Aanpassen",
|
||||
"generic.edit_alt": "&Aanpassen",
|
||||
"generic.filename": "Bestandsnaam",
|
||||
"generic.navigation.back": "Terug",
|
||||
"generic.navigation.next": "Volgende",
|
||||
"generic.none": "Niks",
|
||||
"generic.overwrite": "Overschrijven",
|
||||
"generic.overwrite_alt": "&Overschrijven",
|
||||
"generic.paste": "Plakken",
|
||||
"generic.rename": "Hernoemen",
|
||||
"generic.rename_alt": "&Hernoemen",
|
||||
"generic.save": "Opslaan",
|
||||
"generic.skip": "Overslaan",
|
||||
"generic.skip_alt": "&Overslaan",
|
||||
"home.search": "Zoeken",
|
||||
"home.search_tags": "Labels Zoeken",
|
||||
"home.thumbnail_size": "Miniatuur Grootte",
|
||||
"home.thumbnail_size.extra_large": "Extra Grote Miniaturen",
|
||||
"home.thumbnail_size.large": "Grote Miniaturen",
|
||||
"home.thumbnail_size.medium": "Gemiddelde Minituren",
|
||||
"home.thumbnail_size.mini": "Mini Miniaturen",
|
||||
"home.thumbnail_size.small": "Kleine Miniaturen",
|
||||
"ignore_list.mode.exclude": "Uitsluiten",
|
||||
"json_migration.finish_migration": "Migratie Afronden",
|
||||
"json_migration.heading.aliases": "Aliassen:",
|
||||
"json_migration.heading.colors": "Kleuren:",
|
||||
"json_migration.heading.fields": "Velden:",
|
||||
"json_migration.heading.names": "Namen:",
|
||||
"json_migration.heading.paths": "Paden:",
|
||||
"json_migration.heading.shorthands": "Afkortingen:",
|
||||
"json_migration.heading.tags": "Labels:",
|
||||
"json_migration.migration_complete": "Migratie Afgerond!",
|
||||
"json_migration.title": "Migratie Formaat Opslaan: \"{path}\"",
|
||||
"library.field.add": "Veld Toevoegen",
|
||||
"library.field.mixed_data": "Gemixte Data",
|
||||
"library.field.remove": "Veld Verwijderen",
|
||||
"menu.delete_selected_files_ambiguous": "Bestand(en) verplaatsen naar {trash_term}",
|
||||
"menu.delete_selected_files_plural": "Bestanden verplaatsen naar {trash_term}",
|
||||
"menu.delete_selected_files_singular": "Bestand verplaatsen naar {trash_term}",
|
||||
"menu.edit": "Aanpassen",
|
||||
"menu.edit.ignore_list": "Bestanden en Mappen Negeren",
|
||||
"menu.edit.manage_tags": "Labels Beheren",
|
||||
"menu.edit.new_tag": "Nieuw &Label",
|
||||
"menu.file": "&Bestand",
|
||||
"menu.help": "&Help",
|
||||
"menu.macros.folders_to_tags": "Mappen naar Labels",
|
||||
"menu.select": "Selecteren",
|
||||
"menu.window": "Venster",
|
||||
"select.all": "Alles Selecteren",
|
||||
"sorting.direction.ascending": "Oplopend",
|
||||
"sorting.direction.descending": "Aflopend",
|
||||
"status.deleted_file_plural": "{count} Bestanden Verwijderd!",
|
||||
"status.deleted_file_singular": "1 Bestand Verwijderd!",
|
||||
"status.deleted_none": "Geen bestanden verwijderd.",
|
||||
"status.deleting_file": "Bestand [{i}/{count}] verwijderen: \"{path}\"…",
|
||||
"status.library_version_found": "Gevonden:",
|
||||
"status.results": "Resultaten",
|
||||
"tag.add": "Label toevoegen",
|
||||
"tag.add.plural": "Labels toevoegen",
|
||||
"tag.aliases": "Aliassen",
|
||||
"tag.all_tags": "Alle Labels",
|
||||
"tag.choose_color": "Kies Label Kleur",
|
||||
"tag.color": "Kleur",
|
||||
"tag.edit": "Label Aanpassen",
|
||||
"tag.name": "Naam",
|
||||
"tag.new": "Nieuw Label",
|
||||
"tag.remove": "Verwijder Label",
|
||||
"trash.dialog.title.plural": "Bestanden Verwijderen",
|
||||
"trash.dialog.title.singular": "Bestand Verwijden"
|
||||
}
|
||||
@@ -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 2" # Usually "" or "Pre-Release"
|
||||
VERSION_BRANCH: str = "Pre-Release 4" # Usually "" or "Pre-Release"
|
||||
|
||||
# The folder & file names where TagStudio keeps its data relative to a library.
|
||||
TS_FOLDER_NAME: str = ".TagStudio"
|
||||
@@ -25,3 +25,5 @@ TAG_FAVORITE = 1
|
||||
TAG_META = 2
|
||||
RESERVED_TAG_START = 0
|
||||
RESERVED_TAG_END = 999
|
||||
|
||||
RESERVED_NAMESPACE_PREFIX = "tagstudio"
|
||||
|
||||
@@ -17,6 +17,7 @@ class SettingItems(str, enum.Enum):
|
||||
SHOW_FILENAMES = "show_filenames"
|
||||
AUTOPLAY = "autoplay_videos"
|
||||
THUMB_CACHE_SIZE_LIMIT = "thumb_cache_size_limit"
|
||||
LANGUAGE = "language"
|
||||
|
||||
|
||||
class Theme(str, enum.Enum):
|
||||
@@ -71,4 +72,4 @@ class LibraryPrefs(DefaultEnum):
|
||||
IS_EXCLUDE_LIST = True
|
||||
EXTENSION_LIST: list[str] = [".json", ".xmp", ".aae"]
|
||||
PAGE_SIZE: int = 500
|
||||
DB_VERSION: int = 7
|
||||
DB_VERSION: int = 8
|
||||
|
||||
@@ -307,6 +307,12 @@ def pastels() -> list[TagColorGroup]:
|
||||
|
||||
|
||||
def shades() -> list[TagColorGroup]:
|
||||
burgundy = TagColorGroup(
|
||||
slug="burgundy",
|
||||
namespace="tagstudio-shades",
|
||||
name="Burgundy",
|
||||
primary="#6E1C24",
|
||||
)
|
||||
auburn = TagColorGroup(
|
||||
slug="auburn",
|
||||
namespace="tagstudio-shades",
|
||||
@@ -319,19 +325,31 @@ def shades() -> list[TagColorGroup]:
|
||||
name="Olive",
|
||||
primary="#4C652E",
|
||||
)
|
||||
dark_teal = TagColorGroup(
|
||||
slug="dark-teal",
|
||||
namespace="tagstudio-shades",
|
||||
name="Dark Teal",
|
||||
primary="#1F5E47",
|
||||
)
|
||||
navy = TagColorGroup(
|
||||
slug="navy",
|
||||
namespace="tagstudio-shades",
|
||||
name="Navy",
|
||||
primary="#104B98",
|
||||
)
|
||||
dark_lavender = TagColorGroup(
|
||||
slug="dark_lavender",
|
||||
namespace="tagstudio-shades",
|
||||
name="Dark Lavender",
|
||||
primary="#3D3B6C",
|
||||
)
|
||||
berry = TagColorGroup(
|
||||
slug="berry",
|
||||
namespace="tagstudio-shades",
|
||||
name="Berry",
|
||||
primary="#9F2AA7",
|
||||
)
|
||||
return [auburn, olive, navy, berry]
|
||||
return [burgundy, auburn, olive, dark_teal, navy, dark_lavender, berry]
|
||||
|
||||
|
||||
def earth_tones() -> list[TagColorGroup]:
|
||||
@@ -421,6 +439,7 @@ def neon() -> list[TagColorGroup]:
|
||||
name="Neon Red",
|
||||
primary="#180607",
|
||||
secondary="#E22C3C",
|
||||
color_border=True,
|
||||
)
|
||||
neon_red_orange = TagColorGroup(
|
||||
slug="neon-red-orange",
|
||||
@@ -428,6 +447,7 @@ def neon() -> list[TagColorGroup]:
|
||||
name="Neon Red Orange",
|
||||
primary="#220905",
|
||||
secondary="#E83726",
|
||||
color_border=True,
|
||||
)
|
||||
neon_orange = TagColorGroup(
|
||||
slug="neon-orange",
|
||||
@@ -435,6 +455,7 @@ def neon() -> list[TagColorGroup]:
|
||||
name="Neon Orange",
|
||||
primary="#1F0D05",
|
||||
secondary="#ED6022",
|
||||
color_border=True,
|
||||
)
|
||||
neon_amber = TagColorGroup(
|
||||
slug="neon-amber",
|
||||
@@ -442,6 +463,7 @@ def neon() -> list[TagColorGroup]:
|
||||
name="Neon Amber",
|
||||
primary="#251507",
|
||||
secondary="#FA9A2C",
|
||||
color_border=True,
|
||||
)
|
||||
neon_yellow = TagColorGroup(
|
||||
slug="neon-yellow",
|
||||
@@ -449,6 +471,7 @@ def neon() -> list[TagColorGroup]:
|
||||
name="Neon Yellow",
|
||||
primary="#2B1C0B",
|
||||
secondary="#FFD63D",
|
||||
color_border=True,
|
||||
)
|
||||
neon_lime = TagColorGroup(
|
||||
slug="neon-lime",
|
||||
@@ -456,6 +479,7 @@ def neon() -> list[TagColorGroup]:
|
||||
name="Neon Lime",
|
||||
primary="#1B220C",
|
||||
secondary="#92E649",
|
||||
color_border=True,
|
||||
)
|
||||
neon_green = TagColorGroup(
|
||||
slug="neon-green",
|
||||
@@ -463,6 +487,7 @@ def neon() -> list[TagColorGroup]:
|
||||
name="Neon Green",
|
||||
primary="#091610",
|
||||
secondary="#45D649",
|
||||
color_border=True,
|
||||
)
|
||||
neon_teal = TagColorGroup(
|
||||
slug="neon-teal",
|
||||
@@ -470,6 +495,7 @@ def neon() -> list[TagColorGroup]:
|
||||
name="Neon Teal",
|
||||
primary="#09191D",
|
||||
secondary="#22D589",
|
||||
color_border=True,
|
||||
)
|
||||
neon_cyan = TagColorGroup(
|
||||
slug="neon-cyan",
|
||||
@@ -477,6 +503,7 @@ def neon() -> list[TagColorGroup]:
|
||||
name="Neon Cyan",
|
||||
primary="#0B191C",
|
||||
secondary="#3DDBDB",
|
||||
color_border=True,
|
||||
)
|
||||
neon_blue = TagColorGroup(
|
||||
slug="neon-blue",
|
||||
@@ -484,6 +511,7 @@ def neon() -> list[TagColorGroup]:
|
||||
name="Neon Blue",
|
||||
primary="#09101C",
|
||||
secondary="#3B87F0",
|
||||
color_border=True,
|
||||
)
|
||||
neon_indigo = TagColorGroup(
|
||||
slug="neon-indigo",
|
||||
@@ -491,6 +519,7 @@ def neon() -> list[TagColorGroup]:
|
||||
name="Neon Indigo",
|
||||
primary="#150B24",
|
||||
secondary="#874FF5",
|
||||
color_border=True,
|
||||
)
|
||||
neon_purple = TagColorGroup(
|
||||
slug="neon-purple",
|
||||
@@ -498,6 +527,7 @@ def neon() -> list[TagColorGroup]:
|
||||
name="Neon Purple",
|
||||
primary="#1E0B26",
|
||||
secondary="#BB4FF0",
|
||||
color_border=True,
|
||||
)
|
||||
neon_magenta = TagColorGroup(
|
||||
slug="neon-magenta",
|
||||
@@ -505,6 +535,7 @@ def neon() -> list[TagColorGroup]:
|
||||
name="Neon Magenta",
|
||||
primary="#220A13",
|
||||
secondary="#F64680",
|
||||
color_border=True,
|
||||
)
|
||||
neon_pink = TagColorGroup(
|
||||
slug="neon-pink",
|
||||
@@ -512,6 +543,7 @@ def neon() -> list[TagColorGroup]:
|
||||
name="Neon Pink",
|
||||
primary="#210E15",
|
||||
secondary="#FF62AF",
|
||||
color_border=True,
|
||||
)
|
||||
neon_white = TagColorGroup(
|
||||
slug="neon-white",
|
||||
@@ -519,6 +551,7 @@ def neon() -> list[TagColorGroup]:
|
||||
name="Neon White",
|
||||
primary="#131315",
|
||||
secondary="#F2F1F8",
|
||||
color_border=True,
|
||||
)
|
||||
return [
|
||||
neon_red,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -48,6 +49,7 @@ from src.qt.translations import Translations
|
||||
from ...constants import (
|
||||
BACKUP_FOLDER_NAME,
|
||||
LEGACY_TAG_FIELD_IDS,
|
||||
RESERVED_NAMESPACE_PREFIX,
|
||||
RESERVED_TAG_END,
|
||||
RESERVED_TAG_START,
|
||||
TAG_ARCHIVED,
|
||||
@@ -69,6 +71,10 @@ 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("""
|
||||
@@ -84,7 +90,16 @@ SELECT * FROM ChildTags;
|
||||
""") # noqa: E501
|
||||
|
||||
|
||||
def slugify(input_string: str) -> str:
|
||||
class ReservedNamespaceError(Exception):
|
||||
"""Raise during an unauthorized attempt to create or modify a reserved namespace value.
|
||||
|
||||
Reserved namespace prefix: "tagstudio".
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def slugify(input_string: str, allow_reserved: bool = False) -> str:
|
||||
# Convert to lowercase and normalize unicode characters
|
||||
slug = unicodedata.normalize("NFKD", input_string.lower())
|
||||
|
||||
@@ -94,6 +109,9 @@ def slugify(input_string: str) -> str:
|
||||
# Replace spaces with hyphens
|
||||
slug = re.sub(r"[-\s]+", "-", slug)
|
||||
|
||||
if not allow_reserved and slug.startswith(RESERVED_NAMESPACE_PREFIX):
|
||||
raise ReservedNamespaceError
|
||||
|
||||
return slug
|
||||
|
||||
|
||||
@@ -174,7 +192,7 @@ class Library:
|
||||
|
||||
library_dir: Path | None = None
|
||||
storage_path: Path | str | None
|
||||
engine: Engine | None
|
||||
engine: Engine | None = None
|
||||
folder: Folder | None
|
||||
included_files: set[Path] = set()
|
||||
|
||||
@@ -259,7 +277,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
|
||||
@@ -386,12 +404,13 @@ class Library:
|
||||
tag_colors += default_color_groups.grayscale()
|
||||
tag_colors += default_color_groups.earth_tones()
|
||||
tag_colors += default_color_groups.neon()
|
||||
try:
|
||||
session.add_all(tag_colors)
|
||||
session.commit()
|
||||
except IntegrityError as e:
|
||||
logger.error("[Library] Couldn't add default tag colors", error=e)
|
||||
session.rollback()
|
||||
if is_new:
|
||||
try:
|
||||
session.add_all(tag_colors)
|
||||
session.commit()
|
||||
except IntegrityError as e:
|
||||
logger.error("[Library] Couldn't add default tag colors", error=e)
|
||||
session.rollback()
|
||||
|
||||
# Add default tags.
|
||||
if is_new:
|
||||
@@ -441,14 +460,14 @@ class Library:
|
||||
|
||||
# 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
|
||||
if db_version >= 6 and db_version < 8:
|
||||
self.apply_db7_patches(session)
|
||||
|
||||
# Update DB_VERSION
|
||||
self.set_prefs(LibraryPrefs.DB_VERSION, LibraryPrefs.DB_VERSION.default)
|
||||
if LibraryPrefs.DB_VERSION.default > db_version:
|
||||
self.set_prefs(LibraryPrefs.DB_VERSION, LibraryPrefs.DB_VERSION.default)
|
||||
|
||||
# everything is fine, set the library path
|
||||
self.library_dir = library_dir
|
||||
@@ -457,9 +476,9 @@ class Library:
|
||||
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.
|
||||
DB_VERSION 6 was only used in v9.5.0-pr1.
|
||||
"""
|
||||
logger.info("[Library] Applying patches to DB_VERSION: 6 library...")
|
||||
logger.info("[Library][Migration] Applying patches to DB_VERSION: 6 library...")
|
||||
with session:
|
||||
# Repair "Description" fields with a TEXT_LINE key instead of a TEXT_BOX key.
|
||||
desc_stmd = (
|
||||
@@ -482,6 +501,75 @@ class Library:
|
||||
|
||||
session.commit()
|
||||
|
||||
def apply_db7_patches(self, session: Session):
|
||||
"""Apply migration patches to a library with DB_VERSION 7 or earlier.
|
||||
|
||||
DB_VERSION 7 was used from v9.5.0-pr2 to v9.5.0-pr3.
|
||||
"""
|
||||
# TODO: Use Alembic for this part instead
|
||||
# Add the missing color_border column to the TagColorGroups table.
|
||||
color_border_stmt = text(
|
||||
"ALTER TABLE tag_colors ADD COLUMN color_border BOOLEAN DEFAULT FALSE NOT NULL"
|
||||
)
|
||||
try:
|
||||
session.execute(color_border_stmt)
|
||||
session.commit()
|
||||
logger.info("[Library][Migration] Added color_border column to tag_colors table")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"[Library][Migration] Could not create color_border column in tag_colors table!",
|
||||
error=e,
|
||||
)
|
||||
session.rollback()
|
||||
|
||||
tag_colors: list[TagColorGroup] = default_color_groups.standard()
|
||||
tag_colors += default_color_groups.pastels()
|
||||
tag_colors += default_color_groups.shades()
|
||||
tag_colors += default_color_groups.grayscale()
|
||||
tag_colors += default_color_groups.earth_tones()
|
||||
# tag_colors += default_color_groups.neon() # NOTE: Neon is handled separately
|
||||
|
||||
# Add any new default colors introduced in DB_VERSION 8
|
||||
for color in tag_colors:
|
||||
try:
|
||||
session.add(color)
|
||||
logger.info(
|
||||
"[Library][Migration] Migrated tag color to DB_VERSION 8+",
|
||||
color_name=color.name,
|
||||
)
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
|
||||
# Update Neon colors to use the the color_border property
|
||||
for color in default_color_groups.neon():
|
||||
try:
|
||||
neon_stmt = (
|
||||
update(TagColorGroup)
|
||||
.where(
|
||||
and_(
|
||||
TagColorGroup.namespace == color.namespace,
|
||||
TagColorGroup.slug == color.slug,
|
||||
)
|
||||
)
|
||||
.values(
|
||||
slug=color.slug,
|
||||
namespace=color.namespace,
|
||||
name=color.name,
|
||||
primary=color.primary,
|
||||
secondary=color.secondary,
|
||||
color_border=color.color_border,
|
||||
)
|
||||
)
|
||||
session.execute(neon_stmt)
|
||||
session.commit()
|
||||
except IntegrityError as e:
|
||||
logger.error(
|
||||
"[Library] Could not migrate Neon colors to DB_VERSION 8+!",
|
||||
error=e,
|
||||
)
|
||||
session.rollback()
|
||||
|
||||
@property
|
||||
def default_fields(self) -> list[BaseField]:
|
||||
with Session(self.engine) as session:
|
||||
@@ -513,30 +601,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]:
|
||||
@@ -692,13 +799,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()
|
||||
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
|
||||
return path_strings
|
||||
|
||||
def search_library(
|
||||
self,
|
||||
@@ -765,16 +874,16 @@ class Library:
|
||||
|
||||
return res
|
||||
|
||||
def search_tags(self, name: str | None) -> list[set[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(
|
||||
@@ -806,6 +915,7 @@ class Library:
|
||||
logger.info(
|
||||
"searching tags",
|
||||
search=name,
|
||||
limit=limit,
|
||||
statement=str(query),
|
||||
results=len(res),
|
||||
)
|
||||
@@ -1061,6 +1171,80 @@ class Library:
|
||||
session.commit()
|
||||
return tags
|
||||
|
||||
def add_namespace(self, namespace: Namespace) -> bool:
|
||||
"""Add a namespace value to the library.
|
||||
|
||||
Args:
|
||||
namespace(str): The namespace slug. No special characters
|
||||
"""
|
||||
with Session(self.engine) as session:
|
||||
if not namespace.namespace:
|
||||
logger.warning("[LIBRARY][add_namespace] Namespace slug must not be empty")
|
||||
return False
|
||||
|
||||
slug = namespace.namespace
|
||||
try:
|
||||
slug = slugify(namespace.namespace)
|
||||
except ReservedNamespaceError:
|
||||
logger.error(
|
||||
f"[LIBRARY][add_namespace] Will not add a namespace with the reserved prefix:"
|
||||
f"{RESERVED_NAMESPACE_PREFIX}",
|
||||
namespace=namespace,
|
||||
)
|
||||
logger.error("Should not see me")
|
||||
|
||||
namespace_obj = Namespace(
|
||||
namespace=slug,
|
||||
name=namespace.name,
|
||||
)
|
||||
|
||||
try:
|
||||
session.add(namespace_obj)
|
||||
session.commit()
|
||||
return True
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
logger.error("IntegrityError")
|
||||
return False
|
||||
|
||||
def delete_namespace(self, namespace: Namespace | str):
|
||||
"""Delete a namespace and any connected data from the library."""
|
||||
if isinstance(namespace, str):
|
||||
if namespace.startswith(RESERVED_NAMESPACE_PREFIX):
|
||||
raise ReservedNamespaceError
|
||||
else:
|
||||
if namespace.namespace.startswith(RESERVED_NAMESPACE_PREFIX):
|
||||
raise ReservedNamespaceError
|
||||
|
||||
with Session(self.engine, expire_on_commit=False) as session:
|
||||
try:
|
||||
namespace_: Namespace | None = None
|
||||
if isinstance(namespace, str):
|
||||
namespace_ = session.scalar(
|
||||
select(Namespace).where(Namespace.namespace == namespace)
|
||||
)
|
||||
else:
|
||||
namespace_ = namespace
|
||||
|
||||
if not namespace_:
|
||||
raise Exception
|
||||
session.delete(namespace_)
|
||||
session.flush()
|
||||
|
||||
colors = session.scalars(
|
||||
select(TagColorGroup).where(TagColorGroup.namespace == namespace_.namespace)
|
||||
)
|
||||
for color in colors:
|
||||
session.delete(color)
|
||||
session.flush()
|
||||
|
||||
session.commit()
|
||||
|
||||
except IntegrityError as e:
|
||||
logger.error(e)
|
||||
session.rollback()
|
||||
return None
|
||||
|
||||
def add_tag(
|
||||
self,
|
||||
tag: Tag,
|
||||
@@ -1088,41 +1272,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:
|
||||
@@ -1130,6 +1322,33 @@ class Library:
|
||||
session.rollback()
|
||||
return False
|
||||
|
||||
def add_color(self, color_group: TagColorGroup) -> TagColorGroup | None:
|
||||
with Session(self.engine, expire_on_commit=False) as session:
|
||||
try:
|
||||
session.add(color_group)
|
||||
session.commit()
|
||||
session.expunge(color_group)
|
||||
return color_group
|
||||
|
||||
except IntegrityError as e:
|
||||
logger.error(
|
||||
"[Library] Could not add color, trying to update existing value instead.",
|
||||
error=e,
|
||||
)
|
||||
session.rollback()
|
||||
return None
|
||||
|
||||
def delete_color(self, color: TagColorGroup):
|
||||
with Session(self.engine, expire_on_commit=False) as session:
|
||||
try:
|
||||
session.delete(color)
|
||||
session.commit()
|
||||
|
||||
except IntegrityError as e:
|
||||
logger.error(e)
|
||||
session.rollback()
|
||||
return None
|
||||
|
||||
def save_library_backup_to_disk(self) -> Path:
|
||||
assert isinstance(self.library_dir, Path)
|
||||
makedirs(str(self.library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME), exist_ok=True)
|
||||
@@ -1245,6 +1464,55 @@ class Library:
|
||||
"""Edit a Tag in the Library."""
|
||||
self.add_tag(tag, parent_ids, alias_names, alias_ids)
|
||||
|
||||
def update_color(self, old_color_group: TagColorGroup, new_color_group: TagColorGroup) -> None:
|
||||
"""Update a TagColorGroup in the Library. If it doesn't already exist, create it."""
|
||||
with Session(self.engine) as session:
|
||||
existing_color = session.scalar(
|
||||
select(TagColorGroup).where(
|
||||
and_(
|
||||
TagColorGroup.namespace == old_color_group.namespace,
|
||||
TagColorGroup.slug == old_color_group.slug,
|
||||
)
|
||||
)
|
||||
)
|
||||
if existing_color:
|
||||
update_color_stmt = (
|
||||
update(TagColorGroup)
|
||||
.where(
|
||||
and_(
|
||||
TagColorGroup.namespace == old_color_group.namespace,
|
||||
TagColorGroup.slug == old_color_group.slug,
|
||||
)
|
||||
)
|
||||
.values(
|
||||
slug=new_color_group.slug,
|
||||
namespace=new_color_group.namespace,
|
||||
name=new_color_group.name,
|
||||
primary=new_color_group.primary,
|
||||
secondary=new_color_group.secondary,
|
||||
color_border=new_color_group.color_border,
|
||||
)
|
||||
)
|
||||
session.execute(update_color_stmt)
|
||||
session.flush()
|
||||
update_tags_stmt = (
|
||||
update(Tag)
|
||||
.where(
|
||||
and_(
|
||||
Tag.color_namespace == old_color_group.namespace,
|
||||
Tag.color_slug == old_color_group.slug,
|
||||
)
|
||||
)
|
||||
.values(
|
||||
color_namespace=new_color_group.namespace,
|
||||
color_slug=new_color_group.slug,
|
||||
)
|
||||
)
|
||||
session.execute(update_tags_stmt)
|
||||
session.commit()
|
||||
else:
|
||||
self.add_color(new_color_group)
|
||||
|
||||
def update_aliases(self, tag, alias_ids, alias_names, session):
|
||||
prev_aliases = session.scalars(select(TagAlias).where(TagAlias.tag_id == tag.id)).all()
|
||||
|
||||
@@ -1330,7 +1598,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
|
||||
@@ -1344,7 +1612,28 @@ class Library:
|
||||
color_groups[color.namespace] = []
|
||||
color_groups[color.namespace].append(color)
|
||||
session.expunge(color)
|
||||
return color_groups
|
||||
|
||||
# Add empty namespaces that are available for use.
|
||||
empty_namespaces = session.scalars(
|
||||
select(Namespace)
|
||||
.where(Namespace.namespace.not_in(color_groups.keys()))
|
||||
.order_by(asc(Namespace.namespace))
|
||||
)
|
||||
for en in empty_namespaces:
|
||||
if not color_groups.get(en.namespace):
|
||||
color_groups[en.namespace] = []
|
||||
session.expunge(en)
|
||||
|
||||
return dict(
|
||||
sorted(color_groups.items(), key=lambda kv: self.get_namespace_name(kv[0]).lower())
|
||||
)
|
||||
|
||||
@property
|
||||
def namespaces(self) -> list[Namespace]:
|
||||
"""Return every Namespace in the library."""
|
||||
with Session(self.engine) as session:
|
||||
namespaces = session.scalars(select(Namespace).order_by(asc(Namespace.name)))
|
||||
return list(namespaces)
|
||||
|
||||
def get_namespace_name(self, namespace: str) -> str:
|
||||
with Session(self.engine) as session:
|
||||
|
||||
@@ -63,6 +63,7 @@ class TagColorGroup(Base):
|
||||
name: Mapped[str] = mapped_column()
|
||||
primary: Mapped[str] = mapped_column(nullable=False)
|
||||
secondary: Mapped[str | None]
|
||||
color_border: Mapped[bool] = mapped_column(nullable=False, default=False)
|
||||
|
||||
# TODO: Determine if slug and namespace can be optional and generated/added here if needed.
|
||||
def __init__(
|
||||
@@ -72,6 +73,7 @@ class TagColorGroup(Base):
|
||||
name: str,
|
||||
primary: str,
|
||||
secondary: str | None = None,
|
||||
color_border: bool = False,
|
||||
):
|
||||
self.slug = slug
|
||||
self.namespace = namespace
|
||||
@@ -79,6 +81,7 @@ class TagColorGroup(Base):
|
||||
self.primary = primary
|
||||
if secondary:
|
||||
self.secondary = secondary
|
||||
self.color_border = color_border
|
||||
super().__init__()
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
|
||||
30
tagstudio/src/qt/helpers/file_deleter.py
Normal file
30
tagstudio/src/qt/helpers/file_deleter.py
Normal 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
|
||||
@@ -45,6 +45,7 @@ class AboutModal(QWidget):
|
||||
self.content_widget = QLabel()
|
||||
self.content_widget.setObjectName("contentLabel")
|
||||
self.content_widget.setWordWrap(True)
|
||||
self.content_widget.setOpenExternalLinks(True)
|
||||
ff_version = self.fc.version()
|
||||
ffmpeg = '<span style="color:red">Missing</span>'
|
||||
if ff_version["ffmpeg"] is not None:
|
||||
|
||||
@@ -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)
|
||||
|
||||
413
tagstudio/src/qt/modals/build_color.py
Normal file
413
tagstudio/src/qt/modals/build_color.py
Normal file
@@ -0,0 +1,413 @@
|
||||
# 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.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QColor
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QColorDialog,
|
||||
QFormLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from src.core import palette
|
||||
from src.core.library import Library
|
||||
from src.core.library.alchemy.enums import TagColorEnum
|
||||
from src.core.library.alchemy.library import slugify
|
||||
from src.core.library.alchemy.models import TagColorGroup
|
||||
from src.core.palette import ColorType, UiColor, get_tag_color, get_ui_color
|
||||
from src.qt.translations import Translations
|
||||
from src.qt.widgets.panel import PanelWidget
|
||||
from src.qt.widgets.tag import (
|
||||
get_border_color,
|
||||
get_highlight_color,
|
||||
get_text_color,
|
||||
)
|
||||
from src.qt.widgets.tag_color_preview import TagColorPreview
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class BuildColorPanel(PanelWidget):
|
||||
on_edit = Signal(TagColorGroup)
|
||||
|
||||
def __init__(self, library: Library, color_group: TagColorGroup):
|
||||
super().__init__()
|
||||
self.lib = library
|
||||
self.color_group: TagColorGroup = color_group
|
||||
self.tag_color_namespace: str | None
|
||||
self.tag_color_slug: str | None
|
||||
self.disambiguation_id: int | None
|
||||
|
||||
self.known_colors: set[str]
|
||||
self.update_known_colors()
|
||||
|
||||
self.setMinimumSize(340, 240)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(6, 0, 6, 0)
|
||||
self.root_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
self.form_container = QWidget()
|
||||
self.form_layout = QFormLayout(self.form_container)
|
||||
self.form_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
self.form_layout.setFormAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
|
||||
# Preview Tag ----------------------------------------------------------
|
||||
self.preview_widget = QWidget()
|
||||
self.preview_layout = QVBoxLayout(self.preview_widget)
|
||||
self.preview_layout.setStretch(1, 1)
|
||||
self.preview_layout.setContentsMargins(0, 0, 0, 6)
|
||||
self.preview_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.preview_button = TagColorPreview(self.lib, None)
|
||||
self.preview_button.setEnabled(False)
|
||||
self.preview_layout.addWidget(self.preview_button)
|
||||
|
||||
# Name -----------------------------------------------------------------
|
||||
self.name_title = QLabel()
|
||||
Translations.translate_qobject(self.name_title, "library_object.name")
|
||||
self.name_field = QLineEdit()
|
||||
self.name_field.setFixedHeight(24)
|
||||
self.name_field.textChanged.connect(self.on_text_changed)
|
||||
Translations.translate_with_setter(
|
||||
self.name_field.setPlaceholderText, "library_object.name_required"
|
||||
)
|
||||
self.form_layout.addRow(self.name_title, self.name_field)
|
||||
|
||||
# Slug -----------------------------------------------------------------
|
||||
self.slug_title = QLabel()
|
||||
Translations.translate_qobject(self.slug_title, "library_object.slug")
|
||||
self.slug_field = QLineEdit()
|
||||
self.slug_field.setEnabled(False)
|
||||
self.slug_field.setFixedHeight(24)
|
||||
Translations.translate_with_setter(
|
||||
self.slug_field.setPlaceholderText, "library_object.slug_required"
|
||||
)
|
||||
self.form_layout.addRow(self.slug_title, self.slug_field)
|
||||
|
||||
# Primary --------------------------------------------------------------
|
||||
self.primary_title = QLabel()
|
||||
Translations.translate_qobject(self.primary_title, "color.primary")
|
||||
self.primary_button = QPushButton()
|
||||
self.primary_button.setMinimumSize(44, 22)
|
||||
self.primary_button.setMaximumHeight(22)
|
||||
self.edit_primary_modal = QColorDialog()
|
||||
self.primary_button.clicked.connect(self.primary_color_callback)
|
||||
self.form_layout.addRow(self.primary_title, self.primary_button)
|
||||
|
||||
# Secondary ------------------------------------------------------------
|
||||
self.secondary_widget = QWidget()
|
||||
self.secondary_layout = QHBoxLayout(self.secondary_widget)
|
||||
self.secondary_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.secondary_layout.setSpacing(6)
|
||||
self.secondary_title = QLabel()
|
||||
Translations.translate_qobject(self.secondary_title, "color.secondary")
|
||||
self.secondary_button = QPushButton()
|
||||
self.secondary_button.setMinimumSize(44, 22)
|
||||
self.secondary_button.setMaximumHeight(22)
|
||||
self.edit_secondary_modal = QColorDialog()
|
||||
self.secondary_button.clicked.connect(self.secondary_color_callback)
|
||||
self.secondary_layout.addWidget(self.secondary_button)
|
||||
|
||||
self.secondary_reset_button = QPushButton()
|
||||
Translations.translate_qobject(self.secondary_reset_button, "generic.reset")
|
||||
self.secondary_reset_button.clicked.connect(self.update_secondary)
|
||||
self.secondary_layout.addWidget(self.secondary_reset_button)
|
||||
self.secondary_layout.setStretch(0, 3)
|
||||
self.secondary_layout.setStretch(1, 1)
|
||||
self.form_layout.addRow(self.secondary_title, self.secondary_widget)
|
||||
|
||||
# Color Border ---------------------------------------------------------
|
||||
self.border_widget = QWidget()
|
||||
self.border_layout = QHBoxLayout(self.border_widget)
|
||||
self.border_layout.setStretch(1, 1)
|
||||
self.border_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.border_layout.setSpacing(6)
|
||||
self.border_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
self.border_checkbox = QCheckBox()
|
||||
self.border_checkbox.setFixedSize(22, 22)
|
||||
self.border_checkbox.clicked.connect(
|
||||
lambda checked: self.update_secondary(
|
||||
color=QColor(self.preview_button.tag_color_group.secondary)
|
||||
if self.preview_button.tag_color_group.secondary
|
||||
else None,
|
||||
color_border=checked,
|
||||
)
|
||||
)
|
||||
self.border_layout.addWidget(self.border_checkbox)
|
||||
self.border_label = QLabel()
|
||||
Translations.translate_qobject(self.border_label, "color.color_border")
|
||||
self.border_layout.addWidget(self.border_label)
|
||||
|
||||
primary_color = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT))
|
||||
border_color = get_border_color(primary_color)
|
||||
highlight_color = get_highlight_color(primary_color)
|
||||
text_color: QColor = get_text_color(primary_color, highlight_color)
|
||||
self.border_checkbox.setStyleSheet(
|
||||
f"QCheckBox{{"
|
||||
f"background: rgba{primary_color.toTuple()};"
|
||||
f"color: rgba{text_color.toTuple()};"
|
||||
f"border-color: rgba{border_color.toTuple()};"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: 2px;"
|
||||
f"}}"
|
||||
f"QCheckBox::indicator{{"
|
||||
f"width: 10px;"
|
||||
f"height: 10px;"
|
||||
f"border-radius: 2px;"
|
||||
f"margin: 4px;"
|
||||
f"}}"
|
||||
f"QCheckBox::indicator:checked{{"
|
||||
f"background: rgba{text_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QCheckBox::hover{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QCheckBox::focus{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"outline:none;"
|
||||
f"}}"
|
||||
)
|
||||
|
||||
# Add Widgets to Layout ================================================
|
||||
self.root_layout.addWidget(self.preview_widget)
|
||||
self.root_layout.addWidget(self.form_container)
|
||||
self.root_layout.addWidget(self.border_widget)
|
||||
|
||||
self.set_color(color_group or TagColorGroup("", "", Translations["color.new"], ""))
|
||||
self.update_primary(QColor(color_group.primary))
|
||||
self.update_secondary(None if not color_group.secondary else QColor(color_group.secondary))
|
||||
self.on_text_changed()
|
||||
|
||||
def set_color(self, color_group: TagColorGroup):
|
||||
logger.info("[BuildColorPanel] Setting Color", color=color_group)
|
||||
self.color_group = color_group
|
||||
|
||||
self.preview_button.set_tag_color_group(color_group)
|
||||
self.name_field.setText(color_group.name)
|
||||
self.primary_button.setText(color_group.primary)
|
||||
self.edit_primary_modal.setCurrentColor(color_group.primary)
|
||||
self.secondary_button.setText(
|
||||
Translations["color.title.no_color"]
|
||||
if not color_group.secondary
|
||||
else str(color_group.secondary)
|
||||
)
|
||||
self.edit_secondary_modal.setCurrentColor(color_group.secondary or QColor(0, 0, 0, 255))
|
||||
self.border_checkbox.setChecked(color_group.color_border)
|
||||
|
||||
def primary_color_callback(self) -> None:
|
||||
initial = (
|
||||
self.primary_button.text()
|
||||
if self.primary_button.text().startswith("#")
|
||||
else self.color_group.primary
|
||||
)
|
||||
color = self.edit_primary_modal.getColor(initial=initial)
|
||||
if color.isValid():
|
||||
self.update_primary(color)
|
||||
self.preview_button.set_tag_color_group(self.build_color()[1])
|
||||
else:
|
||||
logger.info("[BuildColorPanel] Primary color selection was cancelled!")
|
||||
|
||||
def secondary_color_callback(self) -> None:
|
||||
initial = (
|
||||
self.secondary_button.text()
|
||||
if self.secondary_button.text().startswith("#")
|
||||
else (self.color_group.secondary or QColor())
|
||||
)
|
||||
color = self.edit_secondary_modal.getColor(initial=initial)
|
||||
if color.isValid():
|
||||
self.update_secondary(color)
|
||||
self.preview_button.set_tag_color_group(self.build_color()[1])
|
||||
else:
|
||||
logger.info("[BuildColorPanel] Secondary color selection was cancelled!")
|
||||
|
||||
def update_primary(self, color: QColor):
|
||||
logger.info("[BuildColorPanel] Updating Primary", primary_color=color)
|
||||
|
||||
highlight_color = get_highlight_color(color)
|
||||
text_color = get_text_color(color, highlight_color)
|
||||
border_color = get_border_color(color)
|
||||
|
||||
hex_code = color.name().upper()
|
||||
self.primary_button.setText(hex_code)
|
||||
self.primary_button.setStyleSheet(
|
||||
f"QPushButton{{"
|
||||
f"background: rgba{color.toTuple()};"
|
||||
f"color: rgba{text_color.toTuple()};"
|
||||
f"font-weight: 600;"
|
||||
f"border-color: rgba{border_color.toTuple()};"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: 2px;"
|
||||
f"padding-right: 4px;"
|
||||
f"padding-bottom: 1px;"
|
||||
f"padding-left: 4px;"
|
||||
f"font-size: 13px"
|
||||
f"}}"
|
||||
f"QPushButton::hover{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QPushButton::pressed{{"
|
||||
f"background: rgba{highlight_color.toTuple()};"
|
||||
f"color: rgba{color.toTuple()};"
|
||||
f"border-color: rgba{color.toTuple()};"
|
||||
f"}}"
|
||||
f"QPushButton::focus{{"
|
||||
f"padding-right: 0px;"
|
||||
f"padding-left: 0px;"
|
||||
f"outline-style: solid;"
|
||||
f"outline-width: 1px;"
|
||||
f"outline-radius: 4px;"
|
||||
f"outline-color: rgba{text_color.toTuple()};"
|
||||
f"}}"
|
||||
)
|
||||
self.preview_button.set_tag_color_group(self.build_color()[1])
|
||||
|
||||
def update_secondary(self, color: QColor | None = None, color_border: bool = False):
|
||||
logger.info("[BuildColorPanel] Updating Secondary", color=color)
|
||||
|
||||
color_ = color or QColor(palette.get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT))
|
||||
|
||||
highlight_color = get_highlight_color(color_)
|
||||
text_color = get_text_color(color_, highlight_color)
|
||||
border_color = get_border_color(color_)
|
||||
|
||||
hex_code = "" if not color else color.name().upper()
|
||||
self.secondary_button.setText(
|
||||
Translations["color.title.no_color"] if not color else hex_code
|
||||
)
|
||||
self.secondary_button.setStyleSheet(
|
||||
f"QPushButton{{"
|
||||
f"background: rgba{color_.toTuple()};"
|
||||
f"color: rgba{text_color.toTuple()};"
|
||||
f"font-weight: 600;"
|
||||
f"border-color: rgba{border_color.toTuple()};"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: 2px;"
|
||||
f"padding-right: 4px;"
|
||||
f"padding-bottom: 1px;"
|
||||
f"padding-left: 4px;"
|
||||
f"font-size: 13px"
|
||||
f"}}"
|
||||
f"QPushButton::hover{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QPushButton::pressed{{"
|
||||
f"background: rgba{highlight_color.toTuple()};"
|
||||
f"color: rgba{color_.toTuple()};"
|
||||
f"border-color: rgba{color_.toTuple()};"
|
||||
f"}}"
|
||||
f"QPushButton::focus{{"
|
||||
f"padding-right: 0px;"
|
||||
f"padding-left: 0px;"
|
||||
f"outline-style: solid;"
|
||||
f"outline-width: 1px;"
|
||||
f"outline-radius: 4px;"
|
||||
f"outline-color: rgba{text_color.toTuple()};"
|
||||
f"}}"
|
||||
)
|
||||
self.preview_button.set_tag_color_group(self.build_color()[1])
|
||||
|
||||
def update_known_colors(self):
|
||||
groups = self.lib.tag_color_groups
|
||||
colors = groups.get(self.color_group.namespace, [])
|
||||
self.known_colors = {c.slug for c in colors}
|
||||
self.known_colors = self.known_colors.difference(self.color_group.slug)
|
||||
|
||||
def update_preview_text(self):
|
||||
self.preview_button.button.setText(
|
||||
f"{self.name_field.text().strip() or Translations["color.placeholder"]} "
|
||||
f"({self.lib.get_namespace_name(self.color_group.namespace)})"
|
||||
)
|
||||
self.preview_button.button.setMaximumWidth(self.preview_button.button.sizeHint().width())
|
||||
|
||||
def no_collide(self, slug: str) -> str:
|
||||
"""Return a slug name that's verified not to collide with other known color slugs."""
|
||||
if slug and slug in self.known_colors:
|
||||
split_slug: list[str] = slug.rsplit("-", 1)
|
||||
suffix: str = ""
|
||||
if len(split_slug) > 1:
|
||||
suffix = split_slug[1]
|
||||
|
||||
if suffix:
|
||||
try:
|
||||
suffix_num: int = int(suffix)
|
||||
return self.no_collide(f"{split_slug[0]}-{suffix_num+1}")
|
||||
except ValueError:
|
||||
return self.no_collide(f"{slug}-2")
|
||||
else:
|
||||
return self.no_collide(f"{slug}-2")
|
||||
return slug
|
||||
|
||||
def on_text_changed(self):
|
||||
slug = self.no_collide(slugify(self.name_field.text().strip(), allow_reserved=True))
|
||||
|
||||
is_name_empty = not self.name_field.text().strip()
|
||||
is_slug_empty = not slug
|
||||
is_invalid = False
|
||||
|
||||
self.name_field.setStyleSheet(
|
||||
f"border: 1px solid {get_ui_color(ColorType.PRIMARY, UiColor.RED)}; border-radius: 2px"
|
||||
if is_name_empty
|
||||
else ""
|
||||
)
|
||||
|
||||
self.slug_field.setStyleSheet(
|
||||
f"border: 1px solid {get_ui_color(ColorType.PRIMARY, UiColor.RED)}; border-radius: 2px"
|
||||
if is_slug_empty or is_invalid
|
||||
else ""
|
||||
)
|
||||
|
||||
self.slug_field.setText(slug)
|
||||
self.update_preview_text()
|
||||
|
||||
if self.panel_save_button is not None:
|
||||
self.panel_save_button.setDisabled(is_name_empty)
|
||||
|
||||
def build_color(self) -> tuple[TagColorGroup, TagColorGroup]:
|
||||
name = self.name_field.text()
|
||||
slug = self.slug_field.text()
|
||||
primary: str = self.primary_button.text()
|
||||
secondary: str | None = (
|
||||
self.secondary_button.text() if self.secondary_button.text().startswith("#") else None
|
||||
)
|
||||
color_border: bool = self.border_checkbox.isChecked()
|
||||
|
||||
new_color = TagColorGroup(
|
||||
slug=slug,
|
||||
namespace=self.color_group.namespace,
|
||||
name=name,
|
||||
primary=primary,
|
||||
secondary=secondary,
|
||||
color_border=color_border,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"[BuildColorPanel] Built Color",
|
||||
slug=new_color.slug,
|
||||
namespace=new_color.namespace,
|
||||
name=new_color.name,
|
||||
primary=new_color.primary,
|
||||
secondary=new_color.secondary,
|
||||
color_border=new_color.color_border,
|
||||
)
|
||||
return (self.color_group, new_color)
|
||||
|
||||
def parent_post_init(self):
|
||||
# self.setTabOrder(self.name_field, self.shorthand_field)
|
||||
# self.setTabOrder(self.shorthand_field, self.aliases_add_button)
|
||||
# self.setTabOrder(self.aliases_add_button, self.parent_tags_add_button)
|
||||
# self.setTabOrder(self.parent_tags_add_button, self.color_button)
|
||||
# self.setTabOrder(self.color_button, self.panel_cancel_button)
|
||||
# self.setTabOrder(self.panel_cancel_button, self.panel_save_button)
|
||||
# self.setTabOrder(self.panel_save_button, self.aliases_table.cellWidget(0, 1))
|
||||
self.name_field.selectAll()
|
||||
self.name_field.setFocus()
|
||||
176
tagstudio/src/qt/modals/build_namespace.py
Normal file
176
tagstudio/src/qt/modals/build_namespace.py
Normal file
@@ -0,0 +1,176 @@
|
||||
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import structlog
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from src.core.constants import RESERVED_NAMESPACE_PREFIX
|
||||
from src.core.library import Library
|
||||
from src.core.library.alchemy.library import ReservedNamespaceError, slugify
|
||||
from src.core.library.alchemy.models import Namespace
|
||||
from src.core.palette import ColorType, UiColor, get_ui_color
|
||||
from src.qt.translations import Translations
|
||||
from src.qt.widgets.panel import PanelWidget
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class BuildNamespacePanel(PanelWidget):
|
||||
on_edit = Signal(Namespace)
|
||||
|
||||
def __init__(self, library: Library, namespace: Namespace | None = None):
|
||||
super().__init__()
|
||||
self.lib = library
|
||||
self.namespace: Namespace | None = namespace
|
||||
|
||||
self.known_namespaces: set[str]
|
||||
self.update_known_namespaces()
|
||||
|
||||
self.setMinimumSize(360, 260)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(6, 0, 6, 0)
|
||||
self.root_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
# Name -----------------------------------------------------------------
|
||||
self.name_widget = QWidget()
|
||||
self.name_layout = QVBoxLayout(self.name_widget)
|
||||
self.name_layout.setStretch(1, 1)
|
||||
self.name_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.name_layout.setSpacing(0)
|
||||
self.name_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
self.name_title = QLabel()
|
||||
Translations.translate_qobject(self.name_title, "library_object.name")
|
||||
self.name_layout.addWidget(self.name_title)
|
||||
self.name_field = QLineEdit()
|
||||
self.name_field.setFixedHeight(24)
|
||||
self.name_field.textChanged.connect(self.on_text_changed)
|
||||
Translations.translate_with_setter(
|
||||
self.name_field.setPlaceholderText, "library_object.name_required"
|
||||
)
|
||||
self.name_layout.addWidget(self.name_field)
|
||||
|
||||
# Slug -----------------------------------------------------------------
|
||||
self.slug_widget = QWidget()
|
||||
self.slug_layout = QVBoxLayout(self.slug_widget)
|
||||
self.slug_layout.setStretch(1, 1)
|
||||
self.slug_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.slug_layout.setSpacing(0)
|
||||
self.slug_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
self.slug_title = QLabel()
|
||||
Translations.translate_qobject(self.slug_title, "library_object.slug")
|
||||
self.slug_layout.addWidget(self.slug_title)
|
||||
self.slug_field = QLineEdit()
|
||||
self.slug_field.setFixedHeight(24)
|
||||
self.slug_field.setEnabled(False)
|
||||
Translations.translate_with_setter(
|
||||
self.slug_field.setPlaceholderText, "library_object.slug_required"
|
||||
)
|
||||
self.slug_layout.addWidget(self.slug_field)
|
||||
|
||||
# Description ----------------------------------------------------------
|
||||
self.desc_label = QLabel()
|
||||
self.desc_label.setWordWrap(True)
|
||||
Translations.translate_with_setter(self.desc_label.setText, "namespace.create.description")
|
||||
self.desc_color_label = QLabel()
|
||||
self.desc_color_label.setWordWrap(True)
|
||||
Translations.translate_with_setter(
|
||||
self.desc_color_label.setText, "namespace.create.description_color"
|
||||
)
|
||||
|
||||
# Add Widgets to Layout ================================================
|
||||
self.root_layout.addWidget(self.name_widget)
|
||||
self.root_layout.addWidget(self.slug_widget)
|
||||
self.root_layout.addSpacing(12)
|
||||
self.root_layout.addWidget(self.desc_label)
|
||||
self.root_layout.addSpacing(6)
|
||||
self.root_layout.addWidget(self.desc_color_label)
|
||||
|
||||
self.set_namespace(namespace)
|
||||
|
||||
def set_namespace(self, namespace: Namespace | None):
|
||||
logger.info("[BuildNamespacePanel] Setting Namespace", namespace=namespace)
|
||||
self.namespace = namespace
|
||||
|
||||
if namespace:
|
||||
self.name_field.setText(namespace.name)
|
||||
self.slug_field.setText(namespace.namespace)
|
||||
else:
|
||||
self.name_field.setText("User Colors")
|
||||
|
||||
def update_known_namespaces(self):
|
||||
namespaces = self.lib.namespaces
|
||||
self.known_namespaces = {n.namespace for n in namespaces}
|
||||
if self.namespace:
|
||||
self.known_namespaces = self.known_namespaces.difference(self.namespace.namespace)
|
||||
|
||||
def on_text_changed(self):
|
||||
slug = ""
|
||||
try:
|
||||
slug = self.no_collide(slugify(self.name_field.text().strip()))
|
||||
except ReservedNamespaceError:
|
||||
raw_name = self.name_field.text().strip().lower()
|
||||
raw_name = raw_name.replace(RESERVED_NAMESPACE_PREFIX, str(uuid4()).split("-", 1)[0])
|
||||
slug = self.no_collide(slugify(raw_name))
|
||||
|
||||
is_name_empty = not self.name_field.text().strip()
|
||||
is_slug_empty = not slug
|
||||
is_invalid = False
|
||||
|
||||
self.name_field.setStyleSheet(
|
||||
f"border: 1px solid {get_ui_color(ColorType.PRIMARY, UiColor.RED)}; border-radius: 2px"
|
||||
if is_name_empty
|
||||
else ""
|
||||
)
|
||||
|
||||
self.slug_field.setStyleSheet(
|
||||
f"border: 1px solid {get_ui_color(ColorType.PRIMARY, UiColor.RED)}; border-radius: 2px"
|
||||
if is_slug_empty or is_invalid
|
||||
else ""
|
||||
)
|
||||
|
||||
self.slug_field.setText(slug)
|
||||
|
||||
if self.panel_save_button is not None:
|
||||
self.panel_save_button.setDisabled(is_name_empty)
|
||||
|
||||
def no_collide(self, slug: str) -> str:
|
||||
"""Return a slug name that's verified not to collide with other known namespace slugs."""
|
||||
if slug and slug in self.known_namespaces:
|
||||
split_slug: list[str] = slug.rsplit("-", 1)
|
||||
suffix: str = ""
|
||||
if len(split_slug) > 1:
|
||||
suffix = split_slug[1]
|
||||
|
||||
if suffix:
|
||||
try:
|
||||
suffix_num: int = int(suffix)
|
||||
return self.no_collide(f"{split_slug[0]}-{suffix_num+1}")
|
||||
except ValueError:
|
||||
return self.no_collide(f"{slug}-2")
|
||||
else:
|
||||
return self.no_collide(f"{slug}-2")
|
||||
return slug
|
||||
|
||||
def build_namespace(self) -> Namespace:
|
||||
name = self.name_field.text()
|
||||
slug_raw = self.slug_field.text()
|
||||
slug = slugify(slug_raw)
|
||||
|
||||
namespace = Namespace(namespace=slug, name=name)
|
||||
|
||||
logger.info("[BuildNamespacePanel] Built Namespace", slug=slug, name=name)
|
||||
return namespace
|
||||
|
||||
def parent_post_init(self):
|
||||
self.setTabOrder(self.name_field, self.slug_field)
|
||||
self.name_field.selectAll()
|
||||
self.name_field.setFocus()
|
||||
@@ -189,11 +189,11 @@ class BuildTagPanel(PanelWidget):
|
||||
self.color_layout.addWidget(self.color_title)
|
||||
self.color_button: TagColorPreview
|
||||
try:
|
||||
self.color_button = TagColorPreview(tag.color)
|
||||
self.color_button = TagColorPreview(self.lib, tag.color)
|
||||
except Exception as e:
|
||||
# TODO: Investigate why this happens during tests
|
||||
logger.error("[BuildTag] Could not access Tag member attributes", error=e)
|
||||
self.color_button = TagColorPreview(None)
|
||||
self.color_button = TagColorPreview(self.lib, None)
|
||||
self.tag_color_selection = TagColorSelection(self.lib)
|
||||
chose_tag_color_title = Translations.translate_formatted("tag.choose_color")
|
||||
self.choose_color_modal = PanelModal(
|
||||
@@ -215,7 +215,7 @@ class BuildTagPanel(PanelWidget):
|
||||
self.cat_layout.setSpacing(6)
|
||||
self.cat_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
self.cat_title = QLabel()
|
||||
self.cat_title.setText("Is Category")
|
||||
Translations.translate_qobject(self.cat_title, "tag.is_category")
|
||||
self.cat_checkbox = QCheckBox()
|
||||
self.cat_checkbox.setFixedSize(22, 22)
|
||||
|
||||
@@ -245,6 +245,10 @@ class BuildTagPanel(PanelWidget):
|
||||
f"QCheckBox::hover{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QCheckBox::focus{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"outline:none;"
|
||||
f"}}"
|
||||
)
|
||||
self.cat_layout.addWidget(self.cat_checkbox)
|
||||
self.cat_layout.addWidget(self.cat_title)
|
||||
@@ -372,7 +376,7 @@ class BuildTagPanel(PanelWidget):
|
||||
primary_color = get_primary_color(tag)
|
||||
border_color = (
|
||||
get_border_color(primary_color)
|
||||
if not (tag.color and tag.color.secondary)
|
||||
if not (tag.color and tag.color.secondary and tag.color.color_border)
|
||||
else (QColor(tag.color.secondary))
|
||||
)
|
||||
highlight_color = get_highlight_color(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
73
tagstudio/src/qt/modals/settings_panel.py
Normal file
73
tagstudio/src/qt/modals/settings_panel.py
Normal file
@@ -0,0 +1,73 @@
|
||||
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QComboBox, QFormLayout, QLabel, QVBoxLayout, QWidget
|
||||
from src.core.enums import SettingItems
|
||||
from src.qt.translations import Translations
|
||||
from src.qt.widgets.panel import PanelWidget
|
||||
|
||||
|
||||
class SettingsPanel(PanelWidget):
|
||||
def __init__(self, driver):
|
||||
super().__init__()
|
||||
self.driver = driver
|
||||
self.setMinimumSize(320, 200)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(6, 0, 6, 0)
|
||||
|
||||
self.form_container = QWidget()
|
||||
self.form_layout = QFormLayout(self.form_container)
|
||||
self.form_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.restart_label = QLabel()
|
||||
self.restart_label.setHidden(True)
|
||||
Translations.translate_qobject(self.restart_label, "settings.restart_required")
|
||||
self.restart_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
language_label = QLabel()
|
||||
Translations.translate_qobject(language_label, "settings.language")
|
||||
self.languages = {
|
||||
# "Cantonese (Traditional)": "yue_Hant", # Empty
|
||||
"Chinese (Traditional)": "zh_Hant",
|
||||
# "Czech": "cs", # Minimal
|
||||
# "Danish": "da", # Minimal
|
||||
"Dutch": "nl",
|
||||
"English": "en",
|
||||
"Filipino": "fil",
|
||||
"French": "fr",
|
||||
"German": "de",
|
||||
"Hungarian": "hu",
|
||||
# "Italian": "it", # Minimal
|
||||
"Norwegian Bokmål": "nb_NO",
|
||||
"Polish": "pl",
|
||||
"Portuguese (Brazil)": "pt_BR",
|
||||
# "Portuguese (Portugal)": "pt", # Empty
|
||||
"Russian": "ru",
|
||||
"Spanish": "es",
|
||||
"Swedish": "sv",
|
||||
"Tamil": "ta",
|
||||
"Toki Pona": "tok",
|
||||
"Turkish": "tr",
|
||||
}
|
||||
self.language_combobox = QComboBox()
|
||||
self.language_combobox.addItems(list(self.languages.keys()))
|
||||
current_lang: str = str(
|
||||
driver.settings.value(SettingItems.LANGUAGE, defaultValue="en", type=str)
|
||||
)
|
||||
current_lang = "en" if current_lang not in self.languages.values() else current_lang
|
||||
self.language_combobox.setCurrentIndex(list(self.languages.values()).index(current_lang))
|
||||
self.language_combobox.currentIndexChanged.connect(
|
||||
lambda: self.restart_label.setHidden(False)
|
||||
)
|
||||
self.form_layout.addRow(language_label, self.language_combobox)
|
||||
|
||||
self.root_layout.addWidget(self.form_container)
|
||||
self.root_layout.addStretch(1)
|
||||
self.root_layout.addWidget(self.restart_label)
|
||||
|
||||
def get_language(self) -> str:
|
||||
values: list[str] = list(self.languages.values())
|
||||
return values[self.language_combobox.currentIndex()]
|
||||
226
tagstudio/src/qt/modals/tag_color_manager.py
Normal file
226
tagstudio/src/qt/modals/tag_color_manager.py
Normal file
@@ -0,0 +1,226 @@
|
||||
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
from typing import TYPE_CHECKING, Callable, override
|
||||
|
||||
import structlog
|
||||
from PySide6 import QtCore, QtGui
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QGuiApplication
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from src.core.constants import RESERVED_NAMESPACE_PREFIX
|
||||
from src.core.enums import Theme
|
||||
from src.qt.modals.build_namespace import BuildNamespacePanel
|
||||
from src.qt.translations import Translations
|
||||
from src.qt.widgets.color_box import ColorBoxWidget
|
||||
from src.qt.widgets.fields import FieldContainer
|
||||
from src.qt.widgets.panel import PanelModal
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
if TYPE_CHECKING:
|
||||
from src.qt.ts_qt import QtDriver
|
||||
|
||||
|
||||
class TagColorManager(QWidget):
|
||||
create_namespace_modal: PanelModal | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
driver: "QtDriver",
|
||||
):
|
||||
super().__init__()
|
||||
self.driver = driver
|
||||
self.lib = driver.lib
|
||||
Translations.translate_with_setter(self.setWindowTitle, "color_manager.title")
|
||||
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
self.setMinimumSize(800, 600)
|
||||
self.is_initialized = False
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(6, 6, 6, 6)
|
||||
|
||||
panel_bg_color = (
|
||||
Theme.COLOR_BG_DARK.value
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else Theme.COLOR_BG_LIGHT.value
|
||||
)
|
||||
|
||||
self.title_label = QLabel()
|
||||
self.title_label.setObjectName("titleLabel")
|
||||
self.title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.title_label.setText(f"<h3>{Translations["color_manager.title"]}</h3>")
|
||||
|
||||
self.scroll_layout = QVBoxLayout()
|
||||
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
self.scroll_layout.setContentsMargins(3, 3, 3, 3)
|
||||
self.scroll_layout.setSpacing(0)
|
||||
|
||||
scroll_container: QWidget = QWidget()
|
||||
scroll_container.setObjectName("entryScrollContainer")
|
||||
scroll_container.setLayout(self.scroll_layout)
|
||||
|
||||
self.scroll_area = QScrollArea()
|
||||
self.scroll_area.setObjectName("entryScrollArea")
|
||||
self.scroll_area.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignCenter)
|
||||
self.scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
self.scroll_area.setWidgetResizable(True)
|
||||
self.scroll_area.setFrameShadow(QFrame.Shadow.Plain)
|
||||
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
|
||||
|
||||
self.scroll_area.setStyleSheet(
|
||||
"QWidget#entryScrollContainer{" f"background:{panel_bg_color};" "border-radius:6px;" "}"
|
||||
)
|
||||
self.scroll_area.setWidget(scroll_container)
|
||||
|
||||
self.setup_color_groups()
|
||||
|
||||
self.button_container = QWidget()
|
||||
self.button_layout = QHBoxLayout(self.button_container)
|
||||
self.button_layout.setContentsMargins(6, 6, 6, 6)
|
||||
|
||||
self.new_namespace_button = QPushButton()
|
||||
Translations.translate_qobject(self.new_namespace_button, "namespace.new.button")
|
||||
self.new_namespace_button.clicked.connect(self.create_namespace)
|
||||
self.button_layout.addWidget(self.new_namespace_button)
|
||||
|
||||
# self.import_pack_button = QPushButton()
|
||||
# Translations.translate_qobject(self.import_pack_button, "color.import_pack")
|
||||
# self.button_layout.addWidget(self.import_pack_button)
|
||||
|
||||
self.button_layout.addStretch(1)
|
||||
|
||||
self.done_button = QPushButton()
|
||||
Translations.translate_qobject(self.done_button, "generic.done_alt")
|
||||
self.done_button.clicked.connect(self.hide)
|
||||
self.button_layout.addWidget(self.done_button)
|
||||
|
||||
self.root_layout.addWidget(self.title_label)
|
||||
self.root_layout.addWidget(self.scroll_area)
|
||||
self.root_layout.addWidget(self.button_container)
|
||||
logger.info(self.root_layout.dumpObjectTree())
|
||||
|
||||
def setup_color_groups(self):
|
||||
all_default = True
|
||||
if self.driver.lib.engine:
|
||||
for group, colors in self.driver.lib.tag_color_groups.items():
|
||||
if not group.startswith(RESERVED_NAMESPACE_PREFIX):
|
||||
all_default = False
|
||||
color_box = ColorBoxWidget(group, colors, self.driver.lib)
|
||||
color_box.updated.connect(
|
||||
lambda: (
|
||||
self.reset(),
|
||||
self.setup_color_groups(),
|
||||
()
|
||||
if len(self.driver.selected) < 1
|
||||
else self.driver.preview_panel.fields.update_from_entry(
|
||||
self.driver.selected[0], update_badges=False
|
||||
),
|
||||
)
|
||||
)
|
||||
field_container = FieldContainer(self.driver.lib.get_namespace_name(group))
|
||||
field_container.set_inner_widget(color_box)
|
||||
if not group.startswith(RESERVED_NAMESPACE_PREFIX):
|
||||
field_container.set_remove_callback(
|
||||
lambda checked=False, g=group: self.delete_namespace_dialog(
|
||||
prompt=Translations["color.namespace.delete.prompt"],
|
||||
callback=lambda namespace=g: (
|
||||
self.lib.delete_namespace(namespace),
|
||||
self.reset(),
|
||||
self.setup_color_groups(),
|
||||
()
|
||||
if len(self.driver.selected) < 1
|
||||
else self.driver.preview_panel.fields.update_from_entry(
|
||||
self.driver.selected[0], update_badges=False
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
self.scroll_layout.addWidget(field_container)
|
||||
|
||||
if all_default:
|
||||
ns_container = QWidget()
|
||||
ns_layout = QHBoxLayout(ns_container)
|
||||
ns_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
ns_layout.setContentsMargins(0, 18, 0, 18)
|
||||
namespace_prompt = QPushButton()
|
||||
Translations.translate_qobject(namespace_prompt, "namespace.new.prompt")
|
||||
namespace_prompt.setFixedSize(namespace_prompt.sizeHint().width() + 8, 24)
|
||||
namespace_prompt.clicked.connect(self.create_namespace)
|
||||
ns_layout.addWidget(namespace_prompt)
|
||||
self.scroll_layout.addWidget(ns_container)
|
||||
|
||||
self.is_initialized = True
|
||||
|
||||
def reset(self):
|
||||
while self.scroll_layout.count():
|
||||
widget = self.scroll_layout.itemAt(0).widget()
|
||||
self.scroll_layout.removeWidget(widget)
|
||||
widget.deleteLater()
|
||||
self.is_initialized = False
|
||||
|
||||
def create_namespace(self):
|
||||
build_namespace_panel = BuildNamespacePanel(self.lib)
|
||||
|
||||
self.create_namespace_modal = PanelModal(
|
||||
build_namespace_panel,
|
||||
Translations["namespace.create.title"],
|
||||
Translations["namespace.create.title"],
|
||||
has_save=True,
|
||||
)
|
||||
|
||||
self.create_namespace_modal.saved.connect(
|
||||
lambda: (
|
||||
self.lib.add_namespace(build_namespace_panel.build_namespace()),
|
||||
self.reset(),
|
||||
self.setup_color_groups(),
|
||||
)
|
||||
)
|
||||
|
||||
self.create_namespace_modal.show()
|
||||
|
||||
def delete_namespace_dialog(self, prompt: str, callback: Callable) -> None:
|
||||
message_box = QMessageBox()
|
||||
message_box.setText(prompt)
|
||||
Translations.translate_with_setter(
|
||||
message_box.setWindowTitle, "color.namespace.delete.title"
|
||||
)
|
||||
message_box.setIcon(QMessageBox.Icon.Warning)
|
||||
cancel_button = message_box.addButton(
|
||||
Translations["generic.cancel_alt"], QMessageBox.ButtonRole.RejectRole
|
||||
)
|
||||
message_box.addButton(
|
||||
Translations["generic.delete_alt"], QMessageBox.ButtonRole.DestructiveRole
|
||||
)
|
||||
message_box.setEscapeButton(cancel_button)
|
||||
result = message_box.exec_()
|
||||
if result != QMessageBox.ButtonRole.ActionRole.value:
|
||||
return
|
||||
callback()
|
||||
|
||||
@override
|
||||
def showEvent(self, event: QtGui.QShowEvent) -> None: # noqa N802
|
||||
if not self.is_initialized:
|
||||
self.setup_color_groups()
|
||||
return super().showEvent(event)
|
||||
|
||||
@override
|
||||
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
|
||||
if event.key() == QtCore.Qt.Key.Key_Escape: # noqa SIM114
|
||||
self.done_button.click()
|
||||
elif event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter:
|
||||
self.done_button.click()
|
||||
return super().keyPressEvent(event)
|
||||
@@ -8,21 +8,25 @@ from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QColor
|
||||
from PySide6.QtWidgets import (
|
||||
QButtonGroup,
|
||||
QFrame,
|
||||
QLabel,
|
||||
QRadioButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QSpacerItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from src.core.library import Library
|
||||
from src.core.library.alchemy.enums import TagColorEnum
|
||||
from src.core.library.alchemy.models import TagColorGroup
|
||||
from src.core.palette import ColorType, get_tag_color
|
||||
from src.qt.flowlayout import FlowLayout
|
||||
from src.qt.translations import Translations
|
||||
from src.qt.widgets.panel import PanelWidget
|
||||
from src.qt.widgets.tag_color_preview import (
|
||||
from src.qt.widgets.tag import (
|
||||
get_border_color,
|
||||
get_highlight_color,
|
||||
get_primary_color,
|
||||
get_text_color,
|
||||
)
|
||||
|
||||
@@ -37,19 +41,37 @@ class TagColorSelection(PanelWidget):
|
||||
|
||||
self.setMinimumSize(308, 540)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(6, 0, 6, 0)
|
||||
self.root_layout.setSpacing(6)
|
||||
self.root_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
self.root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.root_layout.setSpacing(0)
|
||||
|
||||
self.scroll_layout = QVBoxLayout()
|
||||
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
self.scroll_layout.setContentsMargins(6, 0, 6, 0)
|
||||
self.scroll_layout.setSpacing(3)
|
||||
|
||||
scroll_container: QWidget = QWidget()
|
||||
scroll_container.setObjectName("entryScrollContainer")
|
||||
scroll_container.setLayout(self.scroll_layout)
|
||||
|
||||
self.scroll_area = QScrollArea()
|
||||
self.scroll_area.setObjectName("entryScrollArea")
|
||||
self.scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
self.scroll_area.setWidgetResizable(True)
|
||||
self.scroll_area.setFrameShadow(QFrame.Shadow.Plain)
|
||||
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
|
||||
self.scroll_area.setWidget(scroll_container)
|
||||
self.root_layout.addWidget(self.scroll_area)
|
||||
|
||||
# Add Widgets to Layout ================================================
|
||||
tag_color_groups = self.lib.tag_color_groups
|
||||
self.button_group = QButtonGroup(self)
|
||||
|
||||
self.add_no_color_widget()
|
||||
self.root_layout.addSpacerItem(QSpacerItem(1, 12))
|
||||
self.scroll_layout.addSpacerItem(QSpacerItem(1, 6))
|
||||
for group, colors in tag_color_groups.items():
|
||||
display_name: str = self.lib.get_namespace_name(group)
|
||||
self.root_layout.addWidget(
|
||||
self.scroll_layout.addWidget(
|
||||
QLabel(f"<h4>{display_name if display_name else group}</h4>")
|
||||
)
|
||||
color_box_widget = QWidget()
|
||||
@@ -59,10 +81,10 @@ class TagColorSelection(PanelWidget):
|
||||
color_group_layout.setContentsMargins(0, 0, 0, 0)
|
||||
color_box_widget.setLayout(color_group_layout)
|
||||
for color in colors:
|
||||
primary_color = get_primary_color(color)
|
||||
primary_color = self._get_primary_color(color)
|
||||
border_color = (
|
||||
get_border_color(primary_color)
|
||||
if not (color and color.secondary)
|
||||
if not (color and color.secondary and color.color_border)
|
||||
else (QColor(color.secondary))
|
||||
)
|
||||
highlight_color = get_highlight_color(
|
||||
@@ -78,11 +100,15 @@ class TagColorSelection(PanelWidget):
|
||||
radio_button.setObjectName(f"{color.namespace}.{color.slug}")
|
||||
radio_button.setToolTip(color.name)
|
||||
radio_button.setFixedSize(24, 24)
|
||||
bottom_color: str = (
|
||||
f"border-bottom-color: rgba{text_color.toTuple()};" if color.secondary else ""
|
||||
)
|
||||
radio_button.setStyleSheet(
|
||||
f"QRadioButton{{"
|
||||
f"background: rgba{primary_color.toTuple()};"
|
||||
f"color: rgba{text_color.toTuple()};"
|
||||
f"border-color: rgba{border_color.toTuple()};"
|
||||
f"{bottom_color}"
|
||||
f"border-radius: 3px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: 2px;"
|
||||
@@ -99,16 +125,22 @@ class TagColorSelection(PanelWidget):
|
||||
f"QRadioButton::hover{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QRadioButton::focus{{"
|
||||
f"outline-style: solid;"
|
||||
f"outline-width: 2px;"
|
||||
f"outline-radius: 3px;"
|
||||
f"outline-color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
)
|
||||
radio_button.clicked.connect(lambda checked=False, x=color: self.select_color(x))
|
||||
color_group_layout.addWidget(radio_button)
|
||||
self.button_group.addButton(radio_button)
|
||||
self.root_layout.addWidget(color_box_widget)
|
||||
self.root_layout.addSpacerItem(QSpacerItem(1, 12))
|
||||
self.scroll_layout.addWidget(color_box_widget)
|
||||
self.scroll_layout.addSpacerItem(QSpacerItem(1, 6))
|
||||
|
||||
def add_no_color_widget(self):
|
||||
no_color_str: str = Translations.translate_formatted("color.title.no_color")
|
||||
self.root_layout.addWidget(QLabel(f"<h4>{no_color_str}</h4>"))
|
||||
self.scroll_layout.addWidget(QLabel(f"<h4>{no_color_str}</h4>"))
|
||||
color_box_widget = QWidget()
|
||||
color_group_layout = FlowLayout()
|
||||
color_group_layout.setSpacing(4)
|
||||
@@ -116,11 +148,11 @@ class TagColorSelection(PanelWidget):
|
||||
color_group_layout.setContentsMargins(0, 0, 0, 0)
|
||||
color_box_widget.setLayout(color_group_layout)
|
||||
color = None
|
||||
primary_color = get_primary_color(color)
|
||||
primary_color = self._get_primary_color(color)
|
||||
border_color = get_border_color(primary_color)
|
||||
highlight_color = get_highlight_color(primary_color)
|
||||
text_color: QColor
|
||||
if color and color.secondary:
|
||||
if color and color.secondary and color.color_border:
|
||||
text_color = QColor(color.secondary)
|
||||
else:
|
||||
text_color = get_text_color(primary_color, highlight_color)
|
||||
@@ -150,13 +182,19 @@ class TagColorSelection(PanelWidget):
|
||||
f"QRadioButton::hover{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QRadioButton::focus{{"
|
||||
f"outline-style: solid;"
|
||||
f"outline-width: 2px;"
|
||||
f"outline-radius: 3px;"
|
||||
f"outline-color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
)
|
||||
radio_button.clicked.connect(lambda checked=False, x=color: self.select_color(x))
|
||||
color_group_layout.addWidget(radio_button)
|
||||
self.button_group.addButton(radio_button)
|
||||
self.root_layout.addWidget(color_box_widget)
|
||||
self.scroll_layout.addWidget(color_box_widget)
|
||||
|
||||
def select_color(self, color: TagColorGroup):
|
||||
def select_color(self, color: TagColorGroup | None):
|
||||
self.selected_color = color
|
||||
|
||||
def select_radio_button(self, color: TagColorGroup | None):
|
||||
@@ -164,4 +202,13 @@ class TagColorSelection(PanelWidget):
|
||||
for button in self.button_group.buttons():
|
||||
if button.objectName() == object_name:
|
||||
button.setChecked(True)
|
||||
self.select_color(color)
|
||||
break
|
||||
|
||||
def _get_primary_color(self, tag_color_group: TagColorGroup | None) -> QColor:
|
||||
primary_color = QColor(
|
||||
get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)
|
||||
if not tag_color_group
|
||||
else tag_color_group.primary
|
||||
)
|
||||
return primary_color
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -57,7 +57,7 @@ class TagDatabasePanel(TagSearchPanel):
|
||||
)
|
||||
self.modal.show()
|
||||
|
||||
def remove_tag(self, tag: Tag):
|
||||
def delete_tag(self, tag: Tag):
|
||||
if tag.id in range(RESERVED_TAG_START, RESERVED_TAG_END):
|
||||
return
|
||||
|
||||
|
||||
@@ -1,9 +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
|
||||
import contextlib
|
||||
from typing import TYPE_CHECKING, override
|
||||
from warnings import catch_warnings
|
||||
|
||||
import src.qt.modals.build_tag as build_tag
|
||||
import structlog
|
||||
@@ -11,8 +13,10 @@ from PySide6 import QtCore, QtGui
|
||||
from PySide6.QtCore import QSize, Qt, Signal
|
||||
from PySide6.QtGui import QShowEvent
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
@@ -21,7 +25,7 @@ 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
|
||||
@@ -32,7 +36,7 @@ from src.qt.widgets.tag import (
|
||||
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
|
||||
|
||||
|
||||
@@ -44,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,
|
||||
@@ -52,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))
|
||||
@@ -79,53 +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_tag_widget(self, tag: Tag):
|
||||
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)),
|
||||
# )
|
||||
# )
|
||||
|
||||
tag_id = tag.id
|
||||
tag_widget.bg_button.clicked.connect(lambda: self.tag_chosen.emit(tag_id))
|
||||
return tag_widget
|
||||
|
||||
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(
|
||||
@@ -156,10 +154,7 @@ class TagSearchPanel(PanelWidget):
|
||||
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."""
|
||||
@@ -188,26 +183,34 @@ 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")
|
||||
"""Update the tag list given a search query."""
|
||||
logger.info("[TagSearchPanel] Updating Tags")
|
||||
|
||||
# TODO: Look at recycling rather than deleting and re-initializing
|
||||
while self.scroll_layout.count():
|
||||
self.scroll_layout.takeAt(0).widget().deleteLater()
|
||||
# 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()
|
||||
tag_results: list[set[Tag]] = self.lib.search_tags(name=query)
|
||||
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}
|
||||
# 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)[:100]
|
||||
raw_results = list(results_0 + results_1)
|
||||
priority_results: set[Tag] = set()
|
||||
all_results: list[Tag] = []
|
||||
|
||||
@@ -219,18 +222,99 @@ class TagSearchPanel(PanelWidget):
|
||||
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
|
||||
self.first_tag_id = all_results[0].id if len(all_results) > 0 else all_results[0].id
|
||||
for tag in all_results:
|
||||
self.scroll_layout.addWidget(self.__build_tag_widget(tag))
|
||||
|
||||
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():
|
||||
c = self.build_create_tag_button(query)
|
||||
self.scroll_layout.addWidget(c)
|
||||
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.delete_tag(t))
|
||||
tag_widget.bg_button.clicked.connect(lambda: self.tag_chosen.emit(tag_id))
|
||||
|
||||
if self.driver:
|
||||
tag_widget.search_for_tag_action.triggered.connect(
|
||||
lambda checked=False, tag_id=tag.id: (
|
||||
self.driver.main_window.searchField.setText(f"tag_id:{tag_id}"),
|
||||
self.driver.filter_items(FilterState.from_tag_id(tag_id)),
|
||||
)
|
||||
)
|
||||
tag_widget.search_for_tag_action.setEnabled(True)
|
||||
else:
|
||||
tag_widget.search_for_tag_action.setEnabled(False)
|
||||
|
||||
def update_limit(self, index: int):
|
||||
logger.info("[TagSearchPanel] Updating tag limit")
|
||||
TagSearchPanel.cur_limit_idx = index
|
||||
|
||||
if index < len(self._limit_items) - 1:
|
||||
TagSearchPanel.tag_limit = int(self._limit_items[index])
|
||||
else:
|
||||
TagSearchPanel.tag_limit = -1
|
||||
|
||||
# Method was called outside the limit_combobox callback
|
||||
if index != self.limit_combobox.currentIndex():
|
||||
self.limit_combobox.setCurrentIndex(index)
|
||||
|
||||
if self.previous_limit == TagSearchPanel.tag_limit:
|
||||
return
|
||||
|
||||
self.update_tags(self.search_field.text())
|
||||
|
||||
def on_return(self, text: str):
|
||||
if text:
|
||||
@@ -246,23 +330,25 @@ class TagSearchPanel(PanelWidget):
|
||||
self.parentWidget().hide()
|
||||
|
||||
def showEvent(self, event: QShowEvent) -> None: # noqa N802
|
||||
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():
|
||||
self.parentWidget().hide()
|
||||
return super().keyPressEvent(event)
|
||||
else:
|
||||
self.search_field.setFocus()
|
||||
self.search_field.selectAll()
|
||||
return super().keyPressEvent(event)
|
||||
|
||||
def remove_tag(self, tag: Tag):
|
||||
def delete_tag(self, tag: Tag):
|
||||
pass
|
||||
|
||||
def edit_tag(self, tag: Tag):
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -70,16 +70,20 @@ class Translator:
|
||||
|
||||
Also formats the translation with the given keyword arguments.
|
||||
"""
|
||||
if key in self._strings:
|
||||
self._strings[key].changed.connect(lambda text: setter(self.__format(text, **kwargs)))
|
||||
# TODO: Fix so deleted Qt objects aren't referenced any longer
|
||||
# if key in self._strings:
|
||||
# self._strings[key].changed.connect(lambda text: setter(self.__format(text, **kwargs)))
|
||||
setter(self.translate_formatted(key, **kwargs))
|
||||
|
||||
def __format(self, text: str, **kwargs) -> str:
|
||||
try:
|
||||
return text.format(**kwargs)
|
||||
except KeyError:
|
||||
logger.warning(
|
||||
"Error while formatting translation.", text=text, kwargs=kwargs, language=self._lang
|
||||
except (KeyError, ValueError):
|
||||
logger.error(
|
||||
"[Translations] Error while formatting translation.",
|
||||
text=text,
|
||||
kwargs=kwargs,
|
||||
language=self._lang,
|
||||
)
|
||||
return text
|
||||
|
||||
@@ -87,9 +91,7 @@ class Translator:
|
||||
return self.__format(self[key], **kwargs)
|
||||
|
||||
def __getitem__(self, key: str) -> str:
|
||||
# return "???"
|
||||
return self._strings[key].value if key in self._strings else "Not Translated"
|
||||
return self._strings[key].value if key in self._strings else f"[{key}]"
|
||||
|
||||
|
||||
Translations = Translator()
|
||||
# Translations.change_language("de")
|
||||
|
||||
@@ -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,6 +68,7 @@ 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
|
||||
@@ -73,6 +76,7 @@ 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
|
||||
@@ -83,8 +87,11 @@ from src.qt.modals.file_extension import FileExtensionModal
|
||||
from src.qt.modals.fix_dupes import FixDupeFilesModal
|
||||
from src.qt.modals.fix_unlinked import FixUnlinkedEntriesModal
|
||||
from src.qt.modals.folders_to_tags import FoldersToTagsModal
|
||||
from src.qt.modals.settings_panel import SettingsPanel
|
||||
from src.qt.modals.tag_color_manager import TagColorManager
|
||||
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,9 +142,12 @@ class QtDriver(DriverMixin, QObject):
|
||||
|
||||
SIGTERM = Signal()
|
||||
|
||||
preview_panel: PreviewPanel
|
||||
tag_search_panel: TagSearchPanel
|
||||
add_tag_modal: PanelModal
|
||||
preview_panel: PreviewPanel | None = None
|
||||
tag_manager_panel: PanelModal | None = None
|
||||
color_manager_panel: TagColorManager | None = None
|
||||
file_extension_panel: PanelModal | None = None
|
||||
tag_search_panel: TagSearchPanel | None = None
|
||||
add_tag_modal: PanelModal | None = None
|
||||
|
||||
lib: Library
|
||||
|
||||
@@ -188,6 +198,10 @@ class QtDriver(DriverMixin, QObject):
|
||||
)
|
||||
self.config_path = self.settings.fileName()
|
||||
|
||||
Translations.change_language(
|
||||
str(self.settings.value(SettingItems.LANGUAGE, defaultValue="en", type=str))
|
||||
)
|
||||
|
||||
# NOTE: This should be a per-library setting rather than an application setting.
|
||||
thumb_cache_size_limit: int = int(
|
||||
str(
|
||||
@@ -211,7 +225,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}")
|
||||
@@ -291,8 +305,23 @@ 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 Color Group Manager panel
|
||||
self.color_manager_panel = TagColorManager(self)
|
||||
|
||||
# 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(
|
||||
widget=self.tag_search_panel,
|
||||
title=Translations.translate_formatted("tag.add.plural"),
|
||||
@@ -342,6 +371,30 @@ class QtDriver(DriverMixin, QObject):
|
||||
file_menu.addMenu(self.open_recent_library_menu)
|
||||
self.update_recent_lib_menu()
|
||||
|
||||
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)
|
||||
)
|
||||
self.save_library_backup_action.setShortcut(
|
||||
QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier(
|
||||
QtCore.Qt.KeyboardModifier.ControlModifier
|
||||
| QtCore.Qt.KeyboardModifier.ShiftModifier
|
||||
),
|
||||
QtCore.Qt.Key.Key_S,
|
||||
)
|
||||
)
|
||||
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()
|
||||
settings_action = QAction(self)
|
||||
Translations.translate_qobject(settings_action, "menu.settings")
|
||||
settings_action.triggered.connect(self.open_settings_modal)
|
||||
file_menu.addAction(settings_action)
|
||||
|
||||
open_on_start_action = QAction(self)
|
||||
Translations.translate_qobject(open_on_start_action, "settings.open_library_on_start")
|
||||
open_on_start_action.setCheckable(True)
|
||||
@@ -355,79 +408,65 @@ 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(
|
||||
lambda: self.callback_library_needed_check(self.backup_library)
|
||||
)
|
||||
save_library_backup_action.setShortcut(
|
||||
QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier(
|
||||
QtCore.Qt.KeyboardModifier.ControlModifier
|
||||
| QtCore.Qt.KeyboardModifier.ShiftModifier
|
||||
),
|
||||
QtCore.Qt.Key.Key_S,
|
||||
)
|
||||
)
|
||||
save_library_backup_action.setStatusTip("Ctrl+Shift+S")
|
||||
file_menu.addAction(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": []}
|
||||
|
||||
@@ -477,24 +516,42 @@ 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())
|
||||
tag_database_action.setShortcut(
|
||||
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,
|
||||
)
|
||||
)
|
||||
save_library_backup_action.setStatusTip("Ctrl+M")
|
||||
edit_menu.addAction(tag_database_action)
|
||||
self.tag_manager_action.setEnabled(False)
|
||||
self.tag_manager_action.setToolTip("Ctrl+M")
|
||||
edit_menu.addAction(self.tag_manager_action)
|
||||
|
||||
self.color_manager_action = QAction(menu_bar)
|
||||
Translations.translate_qobject(self.color_manager_action, "edit.color_manager")
|
||||
self.color_manager_action.triggered.connect(self.color_manager_panel.show)
|
||||
self.color_manager_action.setEnabled(False)
|
||||
edit_menu.addAction(self.color_manager_action)
|
||||
|
||||
# View Menu ============================================================
|
||||
show_libs_list_action = QAction(menu_bar)
|
||||
@@ -524,32 +581,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())
|
||||
@@ -570,10 +632,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():
|
||||
@@ -736,6 +799,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)
|
||||
@@ -790,13 +874,34 @@ 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]
|
||||
if self.color_manager_panel:
|
||||
self.color_manager_panel.reset()
|
||||
|
||||
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.color_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:
|
||||
@@ -855,13 +960,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)
|
||||
|
||||
@@ -870,30 +975,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."""
|
||||
@@ -1164,7 +1381,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)
|
||||
@@ -1244,7 +1461,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()
|
||||
|
||||
@@ -1261,14 +1478,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(
|
||||
@@ -1302,7 +1528,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,
|
||||
@@ -1376,6 +1604,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()
|
||||
@@ -1421,6 +1652,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
|
||||
@@ -1438,14 +1674,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")
|
||||
@@ -1576,6 +1839,24 @@ class QtDriver(DriverMixin, QObject):
|
||||
self.settings.sync()
|
||||
self.update_recent_lib_menu()
|
||||
|
||||
def open_settings_modal(self):
|
||||
# TODO: Implement a proper settings panel, and don't re-create it each time it's opened.
|
||||
settings_panel = SettingsPanel(self)
|
||||
modal = PanelModal(
|
||||
widget=settings_panel,
|
||||
done_callback=lambda: self.update_language_settings(settings_panel.get_language()),
|
||||
has_save=False,
|
||||
)
|
||||
Translations.translate_with_setter(modal.setTitle, "settings.title")
|
||||
Translations.translate_with_setter(modal.setWindowTitle, "settings.title")
|
||||
modal.show()
|
||||
|
||||
def update_language_settings(self, language: str):
|
||||
Translations.change_language(language)
|
||||
|
||||
self.settings.setValue(SettingItems.LANGUAGE, language)
|
||||
self.settings.sync()
|
||||
|
||||
def open_library(self, path: Path) -> None:
|
||||
"""Open a TagStudio library."""
|
||||
translation_params = {"key": "splash.opening_library", "library_path": str(path)}
|
||||
@@ -1632,8 +1913,22 @@ 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.color_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
|
||||
|
||||
166
tagstudio/src/qt/widgets/color_box.py
Normal file
166
tagstudio/src/qt/widgets/color_box.py
Normal file
@@ -0,0 +1,166 @@
|
||||
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
import typing
|
||||
from collections.abc import Iterable
|
||||
|
||||
import structlog
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import QMessageBox, QPushButton
|
||||
from src.core.constants import RESERVED_NAMESPACE_PREFIX
|
||||
from src.core.library.alchemy.enums import TagColorEnum
|
||||
from src.core.library.alchemy.models import TagColorGroup
|
||||
from src.core.palette import ColorType, get_tag_color
|
||||
from src.qt.flowlayout import FlowLayout
|
||||
from src.qt.modals.build_color import BuildColorPanel
|
||||
from src.qt.translations import Translations
|
||||
from src.qt.widgets.fields import FieldWidget
|
||||
from src.qt.widgets.panel import PanelModal
|
||||
from src.qt.widgets.tag_color_label import TagColorLabel
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from src.core.library import Library
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class ColorBoxWidget(FieldWidget):
|
||||
updated = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
group: str,
|
||||
colors: list["TagColorGroup"],
|
||||
library: "Library",
|
||||
) -> None:
|
||||
self.namespace = group
|
||||
self.colors: list[TagColorGroup] = colors
|
||||
self.lib: Library = library
|
||||
|
||||
title = "" if not self.lib.engine else self.lib.get_namespace_name(group)
|
||||
super().__init__(title)
|
||||
|
||||
self.add_button_stylesheet = (
|
||||
f"QPushButton{{"
|
||||
f"background: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)};"
|
||||
f"color: {get_tag_color(ColorType.TEXT, TagColorEnum.DEFAULT)};"
|
||||
f"font-weight: 600;"
|
||||
f"border-color:{get_tag_color(ColorType.BORDER, TagColorEnum.DEFAULT)};"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: 2px;"
|
||||
f"padding-right: 4px;"
|
||||
f"padding-bottom: 2px;"
|
||||
f"padding-left: 4px;"
|
||||
f"font-size: 15px"
|
||||
f"}}"
|
||||
f"QPushButton::hover{{"
|
||||
f"border-color:{get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};"
|
||||
f"}}"
|
||||
f"QPushButton::pressed{{"
|
||||
f"background: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};"
|
||||
f"color: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)};"
|
||||
f"border-color: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)};"
|
||||
f"}}"
|
||||
f"QPushButton::focus{{"
|
||||
f"border-color: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};"
|
||||
f"outline:none;"
|
||||
f"}}"
|
||||
)
|
||||
|
||||
self.setObjectName("colorBox")
|
||||
self.base_layout = FlowLayout()
|
||||
self.base_layout.enable_grid_optimizations(value=True)
|
||||
self.base_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.setLayout(self.base_layout)
|
||||
|
||||
self.set_colors(self.colors)
|
||||
|
||||
def set_colors(self, colors: Iterable[TagColorGroup]):
|
||||
colors_ = sorted(
|
||||
list(colors), key=lambda color: self.lib.get_namespace_name(color.namespace)
|
||||
)
|
||||
is_mutable = not self.namespace.startswith(RESERVED_NAMESPACE_PREFIX)
|
||||
max_width = 60
|
||||
color_widgets: list[TagColorLabel] = []
|
||||
|
||||
while self.base_layout.itemAt(0):
|
||||
self.base_layout.takeAt(0).widget().deleteLater()
|
||||
|
||||
for color in colors_:
|
||||
color_widget = TagColorLabel(
|
||||
color=color,
|
||||
has_edit=is_mutable,
|
||||
has_remove=is_mutable,
|
||||
library=self.lib,
|
||||
)
|
||||
hint = color_widget.sizeHint().width()
|
||||
if hint > max_width:
|
||||
max_width = hint
|
||||
color_widget.on_click.connect(lambda c=color: self.edit_color(c))
|
||||
color_widget.on_remove.connect(lambda c=color: self.delete_color(c))
|
||||
|
||||
color_widgets.append(color_widget)
|
||||
self.base_layout.addWidget(color_widget)
|
||||
|
||||
for color_widget in color_widgets:
|
||||
color_widget.setFixedWidth(max_width)
|
||||
|
||||
if is_mutable:
|
||||
add_button = QPushButton()
|
||||
add_button.setText("+")
|
||||
add_button.setFlat(True)
|
||||
add_button.setFixedSize(22, 22)
|
||||
add_button.setStyleSheet(self.add_button_stylesheet)
|
||||
add_button.clicked.connect(
|
||||
lambda: self.edit_color(
|
||||
TagColorGroup(
|
||||
slug="slug",
|
||||
namespace=self.namespace,
|
||||
name="Color",
|
||||
primary="#000000",
|
||||
secondary=None,
|
||||
)
|
||||
)
|
||||
)
|
||||
self.base_layout.addWidget(add_button)
|
||||
|
||||
def edit_color(self, color_group: TagColorGroup):
|
||||
build_color_panel = BuildColorPanel(self.lib, color_group)
|
||||
|
||||
self.edit_modal = PanelModal(
|
||||
build_color_panel,
|
||||
"Edit Color",
|
||||
"Edit Color",
|
||||
has_save=True,
|
||||
)
|
||||
|
||||
self.edit_modal.saved.connect(
|
||||
lambda: (self.lib.update_color(*build_color_panel.build_color()), self.updated.emit())
|
||||
)
|
||||
self.edit_modal.show()
|
||||
|
||||
def delete_color(self, color_group: TagColorGroup):
|
||||
message_box = QMessageBox()
|
||||
Translations.translate_with_setter(message_box.setWindowTitle, "color.delete")
|
||||
Translations.translate_qobject(
|
||||
message_box, "color.confirm_delete", color_name=color_group.name
|
||||
)
|
||||
message_box.setIcon(QMessageBox.Icon.Warning)
|
||||
cancel_button = message_box.addButton(
|
||||
Translations["generic.cancel_alt"], QMessageBox.ButtonRole.RejectRole
|
||||
)
|
||||
message_box.addButton(
|
||||
Translations["generic.delete_alt"], QMessageBox.ButtonRole.DestructiveRole
|
||||
)
|
||||
message_box.setEscapeButton(cancel_button)
|
||||
result = message_box.exec_()
|
||||
logger.info(QMessageBox.ButtonRole.DestructiveRole.value)
|
||||
if result != QMessageBox.ButtonRole.ActionRole.value:
|
||||
return
|
||||
|
||||
logger.info("[ColorBoxWidget] Removing color", color=color_group)
|
||||
self.lib.delete_color(color_group)
|
||||
self.updated.emit()
|
||||
@@ -8,12 +8,15 @@ from pathlib import Path
|
||||
from typing import Callable
|
||||
from warnings import catch_warnings
|
||||
|
||||
import structlog
|
||||
from PIL import Image, ImageQt
|
||||
from PySide6.QtCore import QEvent, Qt
|
||||
from PySide6.QtGui import QEnterEvent, QPixmap
|
||||
from PySide6.QtGui import QEnterEvent, QPixmap, QResizeEvent
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
|
||||
from src.core.enums import Theme
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class FieldContainer(QWidget):
|
||||
# TODO: reference a resources folder rather than path.parents[3]?
|
||||
@@ -78,7 +81,6 @@ 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)
|
||||
@@ -123,6 +125,7 @@ class FieldContainer(QWidget):
|
||||
self.field.setLayout(self.field_layout)
|
||||
self.inner_layout.addWidget(self.field)
|
||||
|
||||
self.set_title(title)
|
||||
self.setStyleSheet(FieldContainer.container_style)
|
||||
|
||||
def set_copy_callback(self, callback: Callable | None = None):
|
||||
@@ -188,6 +191,10 @@ class FieldContainer(QWidget):
|
||||
self.remove_button.setHidden(True)
|
||||
return super().leaveEvent(event)
|
||||
|
||||
def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802
|
||||
self.title_widget.setFixedWidth(int(event.size().width() // 1.5))
|
||||
return super().resizeEvent(event)
|
||||
|
||||
|
||||
class FieldWidget(QWidget):
|
||||
def __init__(self, title) -> None:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,14 +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()
|
||||
@@ -96,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
|
||||
@@ -125,12 +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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -23,6 +23,7 @@ from src.core.enums import Theme
|
||||
from src.core.library.alchemy.library import Library
|
||||
from src.core.media_types import MediaCategories
|
||||
from src.qt.helpers.file_opener import FileOpenerHelper, FileOpenerLabel
|
||||
from src.qt.translations import Translations
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from src.qt.ts_qt import QtDriver
|
||||
@@ -108,16 +109,22 @@ class FileAttributes(QWidget):
|
||||
created = dt.fromtimestamp(filepath.stat().st_ctime)
|
||||
modified: dt = dt.fromtimestamp(filepath.stat().st_mtime)
|
||||
self.date_created_label.setText(
|
||||
f"<b>Date Created:</b> {dt.strftime(created, "%a, %x, %X")}" # TODO: Translate
|
||||
f"<b>{Translations["file.date_created"]}:</b> "
|
||||
f"{dt.strftime(created, "%a, %x, %X")}"
|
||||
)
|
||||
self.date_modified_label.setText(
|
||||
f"<b>Date Modified:</b> {dt.strftime(modified, "%a, %x, %X")}" # TODO: Translate
|
||||
f"<b>{Translations["file.date_modified"]}:</b> "
|
||||
f"{dt.strftime(modified, "%a, %x, %X")}"
|
||||
)
|
||||
self.date_created_label.setHidden(False)
|
||||
self.date_modified_label.setHidden(False)
|
||||
elif filepath:
|
||||
self.date_created_label.setText("<b>Date Created:</b> <i>N/A</i>") # TODO: Translate
|
||||
self.date_modified_label.setText("<b>Date Modified:</b> <i>N/A</i>") # TODO: Translate
|
||||
self.date_created_label.setText(
|
||||
f"<b>{Translations["file.date_created"]}:</b> <i>N/A</i>"
|
||||
)
|
||||
self.date_modified_label.setText(
|
||||
f"<b>{Translations["file.date_modified"]}:</b> <i>N/A</i>"
|
||||
)
|
||||
self.date_created_label.setHidden(False)
|
||||
self.date_modified_label.setHidden(False)
|
||||
else:
|
||||
@@ -132,7 +139,7 @@ class FileAttributes(QWidget):
|
||||
if not filepath:
|
||||
self.layout().setSpacing(0)
|
||||
self.file_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.file_label.setText("<i>No Items Selected</i>") # TODO: Translate
|
||||
self.file_label.setText(f"<i>{Translations["preview.no_selection"]}</i>")
|
||||
self.file_label.set_file_path("")
|
||||
self.file_label.setCursor(Qt.CursorShape.ArrowCursor)
|
||||
self.dimensions_label.setText("")
|
||||
@@ -221,7 +228,7 @@ class FileAttributes(QWidget):
|
||||
"""Format attributes for multiple selected items."""
|
||||
self.layout().setSpacing(0)
|
||||
self.file_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.file_label.setText(f"<b>{count}</b> Items Selected") # TODO: Translate
|
||||
Translations.translate_qobject(self.file_label, "preview.multiple_selection", count=count)
|
||||
self.file_label.setCursor(Qt.CursorShape.ArrowCursor)
|
||||
self.file_label.set_file_path("")
|
||||
self.dimensions_label.setText("")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -105,14 +105,14 @@ class PreviewPanel(QWidget):
|
||||
self.add_tag_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.add_tag_button.setMinimumHeight(28)
|
||||
self.add_tag_button.setStyleSheet(PreviewPanel.button_style)
|
||||
self.add_tag_button.setText("Add Tag") # TODO: Translate
|
||||
Translations.translate_qobject(self.add_tag_button, "tag.add")
|
||||
|
||||
self.add_field_button = QPushButton()
|
||||
self.add_field_button.setEnabled(False)
|
||||
self.add_field_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.add_field_button.setMinimumHeight(28)
|
||||
self.add_field_button.setStyleSheet(PreviewPanel.button_style)
|
||||
self.add_field_button.setText("Add Field") # TODO: Translate
|
||||
Translations.translate_qobject(self.add_field_button, "library.field.add")
|
||||
|
||||
add_buttons_layout.addWidget(self.add_tag_button)
|
||||
add_buttons_layout.addWidget(self.add_field_button)
|
||||
@@ -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)
|
||||
|
||||
@@ -105,7 +105,7 @@ class TagWidget(QWidget):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tag: Tag,
|
||||
tag: Tag | None,
|
||||
has_edit: bool,
|
||||
has_remove: bool,
|
||||
library: "Library | None" = None,
|
||||
@@ -127,10 +127,7 @@ class TagWidget(QWidget):
|
||||
|
||||
self.bg_button = QPushButton(self)
|
||||
self.bg_button.setFlat(True)
|
||||
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_edit:
|
||||
edit_action = QAction(self)
|
||||
edit_action.setText(Translations.translate_formatted("generic.edit"))
|
||||
@@ -153,13 +150,42 @@ class TagWidget(QWidget):
|
||||
self.inner_layout.setObjectName("innerLayout")
|
||||
self.inner_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.remove_button = QPushButton(self)
|
||||
self.remove_button.setFlat(True)
|
||||
self.remove_button.setText("–")
|
||||
self.remove_button.setHidden(True)
|
||||
self.remove_button.setMinimumSize(22, 22)
|
||||
self.remove_button.setMaximumSize(22, 22)
|
||||
self.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(44, 22)
|
||||
|
||||
self.bg_button.setMinimumHeight(22)
|
||||
self.bg_button.setMaximumHeight(22)
|
||||
|
||||
self.base_layout.addWidget(self.bg_button)
|
||||
|
||||
# NOTE: Do this if you don't want the tag to stretch, like in a search.
|
||||
# self.bg_button.setMaximumWidth(self.bg_button.sizeHint().width())
|
||||
|
||||
self.bg_button.clicked.connect(self.on_click.emit)
|
||||
|
||||
self.set_tag(tag)
|
||||
|
||||
def set_tag(self, tag: Tag | None) -> None:
|
||||
self.tag = tag
|
||||
|
||||
if not tag:
|
||||
return
|
||||
|
||||
primary_color = get_primary_color(tag)
|
||||
border_color = (
|
||||
get_border_color(primary_color)
|
||||
if not (tag.color and tag.color.secondary)
|
||||
if not (tag.color and tag.color.secondary and tag.color.color_border)
|
||||
else (QColor(tag.color.secondary))
|
||||
)
|
||||
highlight_color = get_highlight_color(
|
||||
@@ -183,7 +209,6 @@ class TagWidget(QWidget):
|
||||
f"border-style:solid;"
|
||||
f"border-width: 2px;"
|
||||
f"padding-right: 4px;"
|
||||
f"padding-bottom: 1px;"
|
||||
f"padding-left: 4px;"
|
||||
f"font-size: 13px"
|
||||
f"}}"
|
||||
@@ -196,59 +221,50 @@ class TagWidget(QWidget):
|
||||
f"border-color: rgba{primary_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QPushButton::focus{{"
|
||||
f"padding-right: 0px;"
|
||||
f"padding-left: 0px;"
|
||||
f"outline-style: solid;"
|
||||
f"outline-width: 1px;"
|
||||
f"outline-radius: 4px;"
|
||||
f"outline-color: rgba{text_color.toTuple()};"
|
||||
f"}}"
|
||||
)
|
||||
|
||||
self.remove_button.setStyleSheet(
|
||||
f"QPushButton{{"
|
||||
f"color: rgba{primary_color.toTuple()};"
|
||||
f"background: rgba{text_color.toTuple()};"
|
||||
f"font-weight: 800;"
|
||||
f"border-radius: 5px;"
|
||||
f"border-width: 4;"
|
||||
f"border-color: rgba(0,0,0,0);"
|
||||
f"padding-bottom: 4px;"
|
||||
f"font-size: 14px"
|
||||
f"}}"
|
||||
f"QPushButton::hover{{"
|
||||
f"background: rgba{primary_color.toTuple()};"
|
||||
f"color: rgba{text_color.toTuple()};"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"border-width: 2;"
|
||||
f"border-radius: 6px;"
|
||||
f"}}"
|
||||
f"QPushButton::pressed{{"
|
||||
f"background: rgba{border_color.toTuple()};"
|
||||
f"color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QPushButton::focus{{"
|
||||
f"background: rgba{border_color.toTuple()};"
|
||||
f"outline:none;"
|
||||
f"}}"
|
||||
)
|
||||
self.bg_button.setMinimumHeight(22)
|
||||
self.bg_button.setMaximumHeight(22)
|
||||
|
||||
self.base_layout.addWidget(self.bg_button)
|
||||
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.remove_button = QPushButton(self)
|
||||
self.remove_button.setFlat(True)
|
||||
self.remove_button.setText("–")
|
||||
self.remove_button.setHidden(True)
|
||||
self.remove_button.setStyleSheet(
|
||||
f"QPushButton{{"
|
||||
f"color: rgba{primary_color.toTuple()};"
|
||||
f"background: rgba{text_color.toTuple()};"
|
||||
f"font-weight: 800;"
|
||||
f"border-radius: 5px;"
|
||||
f"border-width: 4;"
|
||||
f"border-color: rgba(0,0,0,0);"
|
||||
f"padding-bottom: 4px;"
|
||||
f"font-size: 14px"
|
||||
f"}}"
|
||||
f"QPushButton::hover{{"
|
||||
f"background: rgba{primary_color.toTuple()};"
|
||||
f"color: rgba{text_color.toTuple()};"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"border-width: 2;"
|
||||
f"border-radius: 6px;"
|
||||
f"}}"
|
||||
f"QPushButton::pressed{{"
|
||||
f"background: rgba{border_color.toTuple()};"
|
||||
f"color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QPushButton::focus{{"
|
||||
f"background: rgba{border_color.toTuple()};"
|
||||
f"outline:none;"
|
||||
f"}}"
|
||||
)
|
||||
self.remove_button.setMinimumSize(22, 22)
|
||||
self.remove_button.setMaximumSize(22, 22)
|
||||
self.remove_button.clicked.connect(self.on_remove.emit)
|
||||
|
||||
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:
|
||||
@@ -292,6 +308,7 @@ def get_highlight_color(primary_color: QColor) -> QColor:
|
||||
|
||||
|
||||
def get_text_color(primary_color: QColor, highlight_color: QColor) -> QColor:
|
||||
# logger.info("[TagWidget] Evaluating tag text color", lightness=primary_color.lightness())
|
||||
if primary_color.lightness() > 120:
|
||||
text_color = QColor(primary_color)
|
||||
text_color = text_color.toHsl()
|
||||
|
||||
@@ -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()
|
||||
|
||||
201
tagstudio/src/qt/widgets/tag_color_label.py
Normal file
201
tagstudio/src/qt/widgets/tag_color_label.py
Normal file
@@ -0,0 +1,201 @@
|
||||
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
import typing
|
||||
|
||||
import structlog
|
||||
from PySide6.QtCore import QEvent, Qt, Signal
|
||||
from PySide6.QtGui import QAction, QColor, QEnterEvent
|
||||
from PySide6.QtWidgets import (
|
||||
QHBoxLayout,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from src.core.library.alchemy.models import TagColorGroup
|
||||
from src.qt.helpers.escape_text import escape_text
|
||||
from src.qt.translations import Translations
|
||||
from src.qt.widgets.tag import (
|
||||
get_border_color,
|
||||
get_highlight_color,
|
||||
get_text_color,
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
if typing.TYPE_CHECKING:
|
||||
from src.core.library.alchemy import Library
|
||||
|
||||
|
||||
class TagColorLabel(QWidget):
|
||||
"""A widget for displaying a tag color's name.
|
||||
|
||||
Not to be confused with a tag color swatch widget.
|
||||
"""
|
||||
|
||||
on_remove = Signal()
|
||||
on_click = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
color: TagColorGroup | None,
|
||||
has_edit: bool,
|
||||
has_remove: bool,
|
||||
library: "Library | None" = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.color = color
|
||||
self.lib: Library | None = library
|
||||
self.has_edit = has_edit
|
||||
self.has_remove = has_remove
|
||||
|
||||
self.base_layout = QVBoxLayout(self)
|
||||
self.base_layout.setObjectName("baseLayout")
|
||||
self.base_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.bg_button = QPushButton(self)
|
||||
self.bg_button.setFlat(True)
|
||||
|
||||
edit_action = QAction(self)
|
||||
edit_action.setText(Translations.translate_formatted("generic.edit"))
|
||||
edit_action.triggered.connect(self.on_click.emit)
|
||||
self.bg_button.addAction(edit_action)
|
||||
self.bg_button.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
|
||||
if has_edit:
|
||||
self.bg_button.clicked.connect(self.on_click.emit)
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
else:
|
||||
edit_action.setEnabled(False)
|
||||
|
||||
self.inner_layout = QHBoxLayout()
|
||||
self.inner_layout.setObjectName("innerLayout")
|
||||
self.inner_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.remove_button = QPushButton(self)
|
||||
self.remove_button.setFlat(True)
|
||||
self.remove_button.setText("–")
|
||||
self.remove_button.setHidden(True)
|
||||
self.remove_button.setMinimumSize(22, 22)
|
||||
self.remove_button.setMaximumSize(22, 22)
|
||||
self.inner_layout.addWidget(self.remove_button)
|
||||
self.inner_layout.addStretch(1)
|
||||
if self.has_remove:
|
||||
self.remove_button.clicked.connect(self.on_remove.emit)
|
||||
else:
|
||||
self.remove_button.setHidden(True)
|
||||
|
||||
self.bg_button.setLayout(self.inner_layout)
|
||||
self.bg_button.setMinimumSize(44, 22)
|
||||
self.bg_button.setMaximumHeight(22)
|
||||
|
||||
self.base_layout.addWidget(self.bg_button)
|
||||
|
||||
# NOTE: Do this if you don't want the tag to stretch, like in a search.
|
||||
# self.bg_button.setMaximumWidth(self.bg_button.sizeHint().width())
|
||||
|
||||
self.set_color(color)
|
||||
|
||||
def set_color(self, color: TagColorGroup | None) -> None:
|
||||
self.color = color
|
||||
|
||||
if not color:
|
||||
return
|
||||
|
||||
primary_color = self._get_primary_color(color)
|
||||
border_color = (
|
||||
get_border_color(primary_color)
|
||||
if not (color and color.secondary and color.color_border)
|
||||
else (QColor(color.secondary))
|
||||
)
|
||||
highlight_color = get_highlight_color(
|
||||
primary_color if not (color and color.secondary) else QColor(color.secondary)
|
||||
)
|
||||
text_color: QColor
|
||||
if color and color.secondary:
|
||||
text_color = QColor(color.secondary)
|
||||
else:
|
||||
text_color = get_text_color(primary_color, highlight_color)
|
||||
|
||||
self.bg_button.setStyleSheet(
|
||||
f"QPushButton{{"
|
||||
f"background: rgba{primary_color.toTuple()};"
|
||||
f"color: rgba{text_color.toTuple()};"
|
||||
f"font-weight: 600;"
|
||||
f"border-color: rgba{border_color.toTuple()};"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: 2px;"
|
||||
f"padding-right: 4px;"
|
||||
f"padding-left: 4px;"
|
||||
f"font-size: 13px"
|
||||
f"}}"
|
||||
f"QPushButton::hover{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QPushButton::pressed{{"
|
||||
f"background: rgba{highlight_color.toTuple()};"
|
||||
f"color: rgba{primary_color.toTuple()};"
|
||||
f"border-color: rgba{primary_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QPushButton::focus{{"
|
||||
f"padding-right: 0px;"
|
||||
f"padding-left: 0px;"
|
||||
f"outline-style: solid;"
|
||||
f"outline-width: 1px;"
|
||||
f"outline-radius: 4px;"
|
||||
f"outline-color: rgba{text_color.toTuple()};"
|
||||
f"}}"
|
||||
)
|
||||
|
||||
self.remove_button.setStyleSheet(
|
||||
f"QPushButton{{"
|
||||
f"color: rgba{primary_color.toTuple()};"
|
||||
f"background: rgba{text_color.toTuple()};"
|
||||
f"font-weight: 800;"
|
||||
f"border-radius: 5px;"
|
||||
f"border-width: 4;"
|
||||
f"border-color: rgba(0,0,0,0);"
|
||||
f"padding-bottom: 4px;"
|
||||
f"font-size: 14px"
|
||||
f"}}"
|
||||
f"QPushButton::hover{{"
|
||||
f"background: rgba{primary_color.toTuple()};"
|
||||
f"color: rgba{text_color.toTuple()};"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"border-width: 2;"
|
||||
f"border-radius: 6px;"
|
||||
f"}}"
|
||||
f"QPushButton::pressed{{"
|
||||
f"background: rgba{border_color.toTuple()};"
|
||||
f"color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QPushButton::focus{{"
|
||||
f"background: rgba{border_color.toTuple()};"
|
||||
f"outline:none;"
|
||||
f"}}"
|
||||
)
|
||||
|
||||
self.bg_button.setText(escape_text(color.name))
|
||||
|
||||
def _get_primary_color(self, color: TagColorGroup) -> QColor:
|
||||
primary_color = QColor(color.primary)
|
||||
|
||||
return primary_color
|
||||
|
||||
def set_has_remove(self, has_remove: bool):
|
||||
self.has_remove = has_remove
|
||||
|
||||
def enterEvent(self, event: QEnterEvent) -> None: # noqa: N802
|
||||
if self.has_remove:
|
||||
self.remove_button.setHidden(False)
|
||||
self.update()
|
||||
return super().enterEvent(event)
|
||||
|
||||
def leaveEvent(self, event: QEvent) -> None: # noqa: N802
|
||||
if self.has_remove:
|
||||
self.remove_button.setHidden(True)
|
||||
self.update()
|
||||
return super().leaveEvent(event)
|
||||
@@ -3,6 +3,8 @@
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
import typing
|
||||
|
||||
import structlog
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QColor
|
||||
@@ -15,6 +17,14 @@ from src.core.library.alchemy.enums import TagColorEnum
|
||||
from src.core.library.alchemy.models import TagColorGroup
|
||||
from src.core.palette import ColorType, get_tag_color
|
||||
from src.qt.translations import Translations
|
||||
from src.qt.widgets.tag import (
|
||||
get_border_color,
|
||||
get_highlight_color,
|
||||
get_text_color,
|
||||
)
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from src.core.library import Library
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -24,9 +34,11 @@ class TagColorPreview(QWidget):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
library: "Library",
|
||||
tag_color_group: TagColorGroup | None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.lib: Library = library
|
||||
self.tag_color_group = tag_color_group
|
||||
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
@@ -44,28 +56,36 @@ class TagColorPreview(QWidget):
|
||||
|
||||
self.set_tag_color_group(tag_color_group)
|
||||
|
||||
def set_tag_color_group(self, tag_color_group: TagColorGroup | None):
|
||||
self.tag_color_group = tag_color_group
|
||||
def set_tag_color_group(self, color_group: TagColorGroup | None):
|
||||
logger.info(
|
||||
"[TagColorPreview] Setting tag color",
|
||||
primary=color_group.primary if color_group else None,
|
||||
secondary=color_group.secondary if color_group else None,
|
||||
)
|
||||
self.tag_color_group = color_group
|
||||
|
||||
if tag_color_group:
|
||||
self.button.setText(tag_color_group.name)
|
||||
if color_group:
|
||||
self.button.setText(color_group.name)
|
||||
self.button.setText(
|
||||
f"{color_group.name} ({self.lib.get_namespace_name(color_group.namespace)})"
|
||||
)
|
||||
else:
|
||||
Translations.translate_qobject(self.button, "generic.none")
|
||||
Translations.translate_qobject(self.button, "color.title.no_color")
|
||||
|
||||
primary_color = get_primary_color(tag_color_group)
|
||||
primary_color = self._get_primary_color(color_group)
|
||||
border_color = (
|
||||
get_border_color(primary_color)
|
||||
if not (tag_color_group and tag_color_group.secondary)
|
||||
else (QColor(tag_color_group.secondary))
|
||||
if not (color_group and color_group.secondary and color_group.color_border)
|
||||
else (QColor(color_group.secondary))
|
||||
)
|
||||
highlight_color = get_highlight_color(
|
||||
primary_color
|
||||
if not (tag_color_group and tag_color_group.secondary)
|
||||
else QColor(tag_color_group.secondary)
|
||||
if not (color_group and color_group.secondary)
|
||||
else QColor(color_group.secondary)
|
||||
)
|
||||
text_color: QColor
|
||||
if tag_color_group and tag_color_group.secondary:
|
||||
text_color = QColor(tag_color_group.secondary)
|
||||
if color_group and color_group.secondary:
|
||||
text_color = QColor(color_group.secondary)
|
||||
else:
|
||||
text_color = get_text_color(primary_color, highlight_color)
|
||||
|
||||
@@ -79,50 +99,31 @@ class TagColorPreview(QWidget):
|
||||
f"border-style:solid;"
|
||||
f"border-width: 2px;"
|
||||
f"padding-right: 8px;"
|
||||
f"padding-bottom: 1px;"
|
||||
f"padding-left: 8px;"
|
||||
f"font-size: 14px"
|
||||
f"}}"
|
||||
f"QPushButton::hover{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QPushButton::focus{{"
|
||||
f"padding-right: 0px;"
|
||||
f"padding-left: 0px;"
|
||||
f"outline-style: solid;"
|
||||
f"outline-width: 1px;"
|
||||
f"outline-radius: 4px;"
|
||||
f"outline-color: rgba{text_color.toTuple()};"
|
||||
f"}}"
|
||||
)
|
||||
# Add back the padding if the hint is generated while the button has focus (no padding)
|
||||
self.button.setMinimumWidth(
|
||||
self.button.sizeHint().width() + (16 if self.button.hasFocus() else 0)
|
||||
)
|
||||
self.button.setMaximumWidth(self.button.sizeHint().width())
|
||||
|
||||
def _get_primary_color(self, tag_color_group: TagColorGroup | None) -> QColor:
|
||||
primary_color = QColor(
|
||||
get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)
|
||||
if not tag_color_group
|
||||
else tag_color_group.primary
|
||||
)
|
||||
|
||||
def get_primary_color(tag_color_group: TagColorGroup | None) -> QColor:
|
||||
primary_color = QColor(
|
||||
get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)
|
||||
if not tag_color_group
|
||||
else tag_color_group.primary
|
||||
)
|
||||
|
||||
return primary_color
|
||||
|
||||
|
||||
def get_border_color(primary_color: QColor) -> QColor:
|
||||
border_color: QColor = QColor(primary_color)
|
||||
border_color.setRed(min(border_color.red() + 20, 255))
|
||||
border_color.setGreen(min(border_color.green() + 20, 255))
|
||||
border_color.setBlue(min(border_color.blue() + 20, 255))
|
||||
|
||||
return border_color
|
||||
|
||||
|
||||
def get_highlight_color(primary_color: QColor) -> QColor:
|
||||
highlight_color: QColor = QColor(primary_color)
|
||||
highlight_color = highlight_color.toHsl()
|
||||
highlight_color.setHsl(highlight_color.hue(), min(highlight_color.saturation(), 200), 225, 255)
|
||||
highlight_color = highlight_color.toRgb()
|
||||
|
||||
return highlight_color
|
||||
|
||||
|
||||
def get_text_color(primary_color: QColor, highlight_color: QColor) -> QColor:
|
||||
if primary_color.lightness() > 120:
|
||||
text_color = QColor(primary_color)
|
||||
text_color = text_color.toHsl()
|
||||
text_color.setHsl(text_color.hue(), text_color.saturation(), 50, 255)
|
||||
return text_color.toRgb()
|
||||
else:
|
||||
return highlight_color
|
||||
return primary_color
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user