Compare commits

..

15 Commits

Author SHA1 Message Date
Travis Abendshien
2d107ab00d chore: bump version to v9.5.5 2025-09-08 14:18:02 -07:00
Weblate (bot)
2d652c83d4 translations: update from Hosted Weblate (#1090)
* Translated using Weblate (Russian)

Currently translated at 88.4% (314 of 355 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: purpletennisball <droll_togas.6i@icloud.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/ru/
Translation: TagStudio/Strings

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (356 of 356 strings)

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

* Update translation files

Updated by "Remove blank strings" add-on in Weblate.

Translated using Weblate (Spanish)

Currently translated at 96.6% (343 of 355 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (356 of 356 strings)

Translated using Weblate (French)

Currently translated at 99.4% (353 of 355 strings)

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

* Translated using Weblate (Toki Pona)

Currently translated at 86.7% (308 of 355 strings)

Co-authored-by: Bee Crankson <ProfB.crankson@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/tok/
Translation: TagStudio/Strings

---------

Co-authored-by: purpletennisball <droll_togas.6i@icloud.com>
Co-authored-by: Szíjártó Levente Pál <szijartoleventepal@gmail.com>
Co-authored-by: danpg94 <danpalma94@gmail.com>
Co-authored-by: Med <45147847+kitsumed@users.noreply.github.com>
Co-authored-by: RustyNova <rusty.nova.jsb@gmail.com>
Co-authored-by: Bee Crankson <ProfB.crankson@gmail.com>
2025-09-08 13:48:54 -07:00
Sola-ris
bbfc27285e feat: render .cbt thumbnails. (#1116) 2025-09-08 13:47:23 -07:00
Travis Abendshien
2df92f2115 docs: update install page 2025-09-08 13:11:31 -07:00
Sola-ris
2eb9aad12d feat: render .cbr thumbnails. (#1112) 2025-09-08 12:51:01 -07:00
Xarvex
d9c7d58e89 fix(nix/package): temporary test ignore 2025-09-08 10:56:11 -05:00
Travis Abendshien
71d04254cf fix: properly delete tag_parents row when deleting tag (#1107) 2025-09-07 23:59:52 -07:00
Sola-ris
b216490311 fix: use first file as epub thumbnail instead of the last. (#1111) 2025-09-07 13:45:47 -07:00
Travis Abendshien
1c5e0016cc fix: prevent mnemonic removal from removing escaped ampersands (#1110)
* fix: revert mnemonic removal logic

* refactor: fix newer logic to keep '&&'
2025-09-07 13:37:11 -07:00
Sola-ris
f258578f7b feat: read epub cover from ComicInfo.xml, if available. (#1109) 2025-09-07 13:09:10 -07:00
Travis Abendshien
19cdb80b57 fix: use correct units in cache size log message 2025-09-06 20:10:26 -07:00
Travis Abendshien
47baa6f09e fix: always show first frame of video; autoplay will always play (#1104)
Co-authored-by: Sumith <109025648+sumithsudheer@users.noreply.github.com>
2025-09-06 15:00:50 -07:00
Travis Abendshien
cee64a8c31 refactor: fix most pyright issues in library/alchemy/ (#1103)
* refactor: fix most pyright issues in library/alchemy/

* chore: implement review feedback
2025-09-06 14:20:05 -07:00
Travis Abendshien
f49cb4fade refactor!: restructure qt layout, untangle from backend (#1095)
* refactor: untangle backend and frontend files

* refactor: more structure organizing

* refactor: rename silent_subprocess.py

* refactor: update qt ui structure to pure mvc + temporarily mixed

* refactor: pluralize MVC folders
2025-09-06 14:10:36 -07:00
Travis Abendshien
fff967617b feat: add cached thumb quaity and resolution settings (#1101) 2025-09-06 13:31:49 -07:00
130 changed files with 2045 additions and 1777 deletions

View File

@@ -21,11 +21,11 @@ As of writing this section, the QT part of the code base is quite unstructured a
The general structure of the QT code base should look like this:
```
qt
├── controller
├── controllers
│ ├── widgets
│ │ └── preview_panel_controller.py
│ └── main_window_controller.py
├── view
├── views
│ ├── widgets
│ │ └── preview_panel_view.py
│ └── main_window_view.py
@@ -33,7 +33,7 @@ qt
└── mixed.py
```
In this structure there are the `view` and `controller` sub-directories. They have the exact same structure and for every `<component>_view.py` there is a `<component>_controller.py` at the same location in the other subdirectory and vice versa.
In this structure there are the `views` and `controllers` sub-directories. They have the exact same structure and for every `<component>_view.py` there is a `<component>_controller.py` at the same location in the other subdirectory and vice versa.
Typically the classes should look like this:
```py

View File

@@ -10,14 +10,14 @@ TagStudio provides [releases](https://github.com/TagStudioDev/TagStudio/releases
To download executable builds of TagStudio, visit the [Releases](https://github.com/TagStudioDev/TagStudio/releases) page of the GitHub repository and download the latest release for your system under the "Assets" section at the bottom of the release.
TagStudio has builds for **Windows**, **macOS** _(Apple Silicon & Intel)_, and **Linux**. We also offer portable releases for Windows and Linux which are self-contained and easier to move around.
TagStudio has builds for :fontawesome-brands-windows: **Windows**, :fontawesome-brands-apple: **macOS** _(Apple Silicon & Intel)_, and :material-penguin: **Linux**. We also offer portable releases for Windows and Linux which are self-contained and easier to move around.
<!-- prettier-ignore -->
!!! info "Third-Party Dependencies"
You may need to install [third-party dependencies](#third-party-dependencies) such as [FFmpeg](https://ffmpeg.org/download.html) to use the full feature set of TagStudio.
<!-- prettier-ignore -->
!!! warning "For macOS Users"
!!! warning ":fontawesome-brands-apple: macOS "Privacy & Security" Popup"
On macOS, you may be met with a message saying "**"TagStudio" can't be opened because Apple cannot check it for malicious software.**" If you encounter this, then you'll need to go to the "Settings" app, navigate to "Privacy & Security", and scroll down to a section that says "**"TagStudio" was blocked from use because it is not from an identified developer.**" Click the "Open Anyway" button to allow TagStudio to run. You should only have to do this once after downloading the application.
---
@@ -30,7 +30,7 @@ TagStudio has builds for **Windows**, **macOS** _(Apple Silicon & Intel)_, and *
Installation support will not be given to users installing from unofficial sources. Use these versions at your own risk!
### Installing with PIP
### :fontawesome-brands-python: Installing with PIP
TagStudio is installable via [PIP](https://pip.pypa.io/). Note that since we don't currently distribute on PyPI, the repository needs to be cloned and installed locally. Make sure you have Python 3.12 and PIP installed if you choose to install using this method.
@@ -58,7 +58,7 @@ TagStudio can now be launched via the `tagstudio` command in your terminal.
---
### Linux
### :material-penguin: Linux
Some external dependencies are required for TagStudio to execute. Below is a table of known packages that will be necessary.
@@ -79,7 +79,7 @@ Some external dependencies are required for TagStudio to execute. Below is a tab
| [qt-multimedia](https://repology.org/project/qt) | required |
| [qt-wayland](https://repology.org/project/qt) | Wayland support |
### Nix(OS)
### :material-nix: Nix(OS)
For [Nix(OS)](https://nixos.org/), the TagStudio repository includes a [flake](https://wiki.nixos.org/wiki/Flakes) that provides some outputs such as a development shell and package.
@@ -213,12 +213,30 @@ Don't forget to rebuild!
<!-- prettier-ignore -->
!!! tip
You can check to see if any of these dependencies are correctly located by launching TagStudio and going to "About TagStudio" in the menu bar.
You can check to see if these dependencies are correctly located by launching TagStudio and going to "About TagStudio" in the menu bar.
### FFmpeg/FFprobe
For audio/video thumbnails and playback you'll need [FFmpeg](https://ffmpeg.org/download.html) installed on your system. If you encounter any issues with this, please reference our [FFmpeg Help](./help/ffmpeg.md) guide.
### RAR Extractor
To generate thumbnails for RAR-based files (like `.cbr`) you'll need an extractor capable of handling them.
- :material-penguin: On Linux you'll need to install either `unrar` (likely in you distro's non-free repository) or `unrar-free` from your package manager.
- :fontawesome-brands-apple: On macOS `unrar` can be installed through Homebrew's [`rar`](https://formulae.brew.sh/cask/rar) formula.
<!-- prettier-ignore -->
!!! warning ":fontawesome-brands-apple: macOS "Privacy & Security" Popup"
On macOS, you may be met with a message similar to "**"unrar" Not Opened. Apple could not verify "unrar" is free of malware that may harm your Mac or compromise your privacy**" If you encounter this, then you'll need to go to the "Settings" app, navigate to "Privacy & Security", and scroll down to a section that says "**"unrar" was blocked from use because it is not from an identified developer.**" Click the "Open Anyway" button to allow unrar to be used.
- :fontawesome-brands-windows: On Windows you'll need to install either [`WinRAR`](https://www.rarlab.com/download.htm) or [`7-zip`](https://www.7-zip.org/) and add their folder to you `PATH`.
<!-- prettier-ignore -->
!!! tip "WinRAR License"
Both `unrar` and `WinRAR` require a license, but since the evaluation copy has no time limit you can simply dismiss the prompt.
### ripgrep
A recommended tool to improve the performance of directory scanning is [`ripgrep`](https://github.com/BurntSushi/ripgrep), a Rust-based directory walker that natively integrates with our [`.ts_ignore`](./utilities/ignore.md) (`.gitignore`-style) pattern matching system for excluding files and directories. Ripgrep is already pre-installed on some Linux distributions and also available from several package managers.

View File

@@ -14,9 +14,9 @@ Legacy (JSON) library save format versions were tied to the release version of t
### Versions 1.0.0 - 9.4.2
| Used From | Used Until | Format | Location |
| --------- | ----------------------------------------------------------------------- | ------ | --------------------------------------------- |
| v1.0.0 | [v9.4.2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.4.2) | JSON | `<Library Folder>`/.TagStudio/ts_library.json |
| Used From | Format | Location |
| --------- | ------ | --------------------------------------------- |
| v1.0.0 | JSON | `<Library Folder>`/.TagStudio/ts_library.json |
The legacy database format for public TagStudio releases [v9.1](https://github.com/TagStudioDev/TagStudio/tree/Alpha-v9.1) through [v9.4.2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.4.2). Variations of this format had been used privately since v1.0.0.
@@ -48,9 +48,9 @@ These versions were used while developing the new SQLite file format, outside an
### Version 6
| Used From | Used Until | Format | Location |
| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
| [v9.5.0-pr1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr1) | [v9.5.0-pr1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr1) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
| Used From | Format | Location |
| ------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
| [v9.5.0-pr1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr1) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
The first public version of the SQLite save file format.
@@ -60,9 +60,9 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
### Version 7
| Used From | Used Until | Format | Location |
| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
| [v9.5.0-pr2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr2) | [v9.5.0-pr3](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr3) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
| Used From | Format | Location |
| ------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
| [v9.5.0-pr2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr2) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
- Repairs "Description" fields to use a TEXT_LINE key instead of a TEXT_BOX key.
- Repairs tags that may have a disambiguation_id pointing towards a deleted tag.
@@ -71,9 +71,9 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
### Version 8
| Used From | Used Until | Format | Location |
| ------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | ------ | ----------------------------------------------- |
| [v9.5.0-pr4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr4) | [v9.5.1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.1) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
| Used From | Format | Location |
| ------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
| [v9.5.0-pr4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr4) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
- Adds the `color_border` column to the `tag_colors` table. Used for instructing the [secondary color](../library/tag_color.md#secondary-color) to apply to a tag's border as a new optional behavior.
- Adds three new default colors: "Burgundy (TagStudio Shades)", "Dark Teal (TagStudio Shades)", and "Dark Lavender (TagStudio Shades)".
@@ -83,9 +83,9 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
### Version 9
| Used From | Used Until | Format | Location |
| ----------------------------------------------------------------------- | ----------------------------------------------------------------------- | ------ | ----------------------------------------------- |
| [v9.5.2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.2) | [v9.5.3](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.3) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
| Used From | Format | Location |
| ----------------------------------------------------------------------- | ------ | ----------------------------------------------- |
| [v9.5.2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.2) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
- Adds the `filename` column to the `entries` table. Used for sorting entries by filename in search results.
@@ -93,20 +93,20 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
### Version 100
| Used From | Used Until | Format | Location |
| ---------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | ------ | ----------------------------------------------- |
| [74383e3](https://github.com/TagStudioDev/TagStudio/commit/74383e3c3c12f72be1481ab0b86c7360b95c2d85) | [v9.5.4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.4) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
| Used From | Format | Location |
| ---------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
| [74383e3](https://github.com/TagStudioDev/TagStudio/commit/74383e3c3c12f72be1481ab0b86c7360b95c2d85) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
- Introduces built-in minor versioning
- The version number divided by 100 (and floored) constitutes the **major** version. Major version indicate breaking changes that prevent libraries from being opened in TagStudio versions older than the ones they were created in.
- Values more precise than this ("ones" through "tens" columns) constitute the **minor** version. These indicate minor changes that don't prevent a newer library from being opened in an older version of TagStudio, as long as the major version is not also increased.
- Swaps `parent_id` and `child_id` values in the `tag_parents` table, which have erroneously been flipped since the first SQLite DB version.
- Swaps `parent_id` and `child_id` values in the `tag_parents` table
#### Version 101
| Used From | Used Until | Format | Location |
| ----------------------------------------------------------------------- | ---------- | ------ | ----------------------------------------------- |
| [v9.5.4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.4) | _Current_ | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
| Used From | Format | Location |
| ----------------------------------------------------------------------- | ------ | ----------------------------------------------- |
| [v9.5.4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.4) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
- Deprecates the `preferences` table, set to be removed in a future TagStudio version.
- Introduces the `versions` table
@@ -115,3 +115,11 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
- `'INITIAL'` stores the database version number in which in was created
- Pre-existing databases set this number to `100`
- `'CURRENT'` stores the current database version number
#### Version 102
| Used From | Format | Location |
| ----------------------------------------------------------------------- | ------ | ----------------------------------------------- |
| [v9.5.4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.4) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
- Applies repairs to the `tag_parents` table created in [version 100](#version-100), removing rows that reference tags that have been deleted.

View File

@@ -116,6 +116,7 @@ python3Packages.buildPythonApplication {
"test_badge_visual_state"
"test_browsing_state_update"
"test_flow_layout_happy_path"
"test_get" # TODO: Look further into, might be possible to run.
"test_json_migration"
"test_library_migrations"
"test_update_tags"

View File

@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
[project]
name = "TagStudio"
description = "A User-Focused Photo & File Management System."
version = "9.5.4"
version = "9.5.5"
license = "GPL-3.0-only"
readme = "README.md"
requires-python = ">=3.12,<3.13"
@@ -23,6 +23,7 @@ dependencies = [
"pydantic~=2.10",
"pydub~=0.25",
"PySide6==6.8.0.*",
"rarfile==4.2",
"rawpy~=0.24",
"Send2Trash~=1.8",
"SQLAlchemy~=2.0",
@@ -88,7 +89,7 @@ qt_api = "pyside6"
ignore = [
".venv/**",
"src/tagstudio/core/library/json/",
"src/tagstudio/qt/helpers/vendored/pydub/",
"src/tagstudio/qt/previews/vendored/pydub/",
]
include = ["src/tagstudio", "tests"]
reportAny = false
@@ -113,7 +114,7 @@ ignore = ["D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107"]
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["D", "E402"]
"src/tagstudio/qt/helpers/vendored/**" = ["B", "E", "N", "UP", "SIM115"]
"src/tagstudio/qt/previews/vendored/**" = ["B", "E", "N", "UP", "SIM115"]
[tool.ruff.lint.pydocstyle]
convention = "google"

View File

@@ -2,7 +2,7 @@
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
VERSION: str = "9.5.4" # Major.Minor.Patch
VERSION: str = "9.5.5" # Major.Minor.Patch
VERSION_BRANCH: str = "" # Usually "" or "Pre-Release"
# The folder & file names where TagStudio keeps its data relative to a library.

View File

@@ -5,14 +5,16 @@ from PySide6.QtCore import QSettings
from tagstudio.core.constants import TS_FOLDER_NAME
from tagstudio.core.enums import SettingItems
from tagstudio.core.global_settings import GlobalSettings
from tagstudio.core.library.alchemy.library import LibraryStatus
from tagstudio.qt.global_settings import GlobalSettings
logger = structlog.get_logger(__name__)
class DriverMixin:
cached_values: QSettings
# TODO: GlobalSettings has become closely tied to Qt.
# Should there be a base Settings class?
settings: GlobalSettings
def evaluate_path(self, open_path: str | None) -> LibraryStatus:

View File

@@ -1,28 +0,0 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
class FieldTemplate:
"""A TagStudio Library Field Template object."""
def __init__(self, id: int, name: str, type: str) -> None:
self.id = id
self.name = name
self.type = type
def __str__(self) -> str:
return f"\nID: {self.id}\nName: {self.name}\nType: {self.type}\n"
def __repr__(self) -> str:
return self.__str__()
def to_compressed_obj(self) -> dict:
"""An alternative to __dict__ that only includes fields containing non-default data."""
obj: dict = {}
# All Field fields (haha) are mandatory, so no value checks are done.
obj["id"] = self.id
obj["name"] = self.name
obj["type"] = self.type
return obj

View File

@@ -11,7 +11,7 @@ JSON_FILENAME: str = "ts_library.json"
DB_VERSION_LEGACY_KEY: str = "DB_VERSION"
DB_VERSION_CURRENT_KEY: str = "CURRENT"
DB_VERSION_INITIAL_KEY: str = "INITIAL"
DB_VERSION: int = 101
DB_VERSION: int = 102
TAG_CHILDREN_QUERY = text("""
WITH RECURSIVE ChildTags AS (

View File

@@ -0,0 +1,572 @@
# Copyright (C) 2025
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import structlog
from tagstudio.core.library.alchemy.models import Namespace, TagColorGroup
logger = structlog.get_logger(__name__)
def namespaces() -> list[Namespace]:
tagstudio_standard = Namespace("tagstudio-standard", "TagStudio Standard")
tagstudio_pastels = Namespace("tagstudio-pastels", "TagStudio Pastels")
tagstudio_shades = Namespace("tagstudio-shades", "TagStudio Shades")
tagstudio_earth_tones = Namespace("tagstudio-earth-tones", "TagStudio Earth Tones")
tagstudio_grayscale = Namespace("tagstudio-grayscale", "TagStudio Grayscale")
tagstudio_neon = Namespace("tagstudio-neon", "TagStudio Neon")
return [
tagstudio_standard,
tagstudio_pastels,
tagstudio_shades,
tagstudio_earth_tones,
tagstudio_grayscale,
tagstudio_neon,
]
def json_to_sql_color(json_color: str) -> tuple[str | None, str | None]:
"""Convert a color string from a <=9.4 JSON library to a 9.5+ (namespace, slug) tuple."""
json_color_ = json_color.lower()
match json_color_:
case "black":
return ("tagstudio-grayscale", "black")
case "dark gray":
return ("tagstudio-grayscale", "dark-gray")
case "gray":
return ("tagstudio-grayscale", "gray")
case "light gray":
return ("tagstudio-grayscale", "light-gray")
case "white":
return ("tagstudio-grayscale", "white")
case "light pink":
return ("tagstudio-pastels", "light-pink")
case "pink":
return ("tagstudio-standard", "pink")
case "magenta":
return ("tagstudio-standard", "magenta")
case "red":
return ("tagstudio-standard", "red")
case "red orange":
return ("tagstudio-standard", "red-orange")
case "salmon":
return ("tagstudio-pastels", "salmon")
case "orange":
return ("tagstudio-standard", "orange")
case "yellow orange":
return ("tagstudio-standard", "amber")
case "yellow":
return ("tagstudio-standard", "yellow")
case "mint":
return ("tagstudio-pastels", "mint")
case "lime":
return ("tagstudio-standard", "lime")
case "light green":
return ("tagstudio-pastels", "light-green")
case "green":
return ("tagstudio-standard", "green")
case "teal":
return ("tagstudio-standard", "teal")
case "cyan":
return ("tagstudio-standard", "cyan")
case "light blue":
return ("tagstudio-pastels", "light-blue")
case "blue":
return ("tagstudio-standard", "blue")
case "blue violet":
return ("tagstudio-shades", "navy")
case "violet":
return ("tagstudio-standard", "indigo")
case "purple":
return ("tagstudio-standard", "purple")
case "peach":
return ("tagstudio-earth-tones", "peach")
case "brown":
return ("tagstudio-earth-tones", "brown")
case "lavender":
return ("tagstudio-pastels", "lavender")
case "blonde":
return ("tagstudio-earth-tones", "blonde")
case "auburn":
return ("tagstudio-shades", "auburn")
case "light brown":
return ("tagstudio-earth-tones", "light-brown")
case "dark brown":
return ("tagstudio-earth-tones", "dark-brown")
case "cool gray":
return ("tagstudio-earth-tones", "cool-gray")
case "warm gray":
return ("tagstudio-earth-tones", "warm-gray")
case "olive":
return ("tagstudio-shades", "olive")
case "berry":
return ("tagstudio-shades", "berry")
case _:
return (None, None)
def standard() -> list[TagColorGroup]:
red = TagColorGroup(
slug="red",
namespace="tagstudio-standard",
name="Red",
primary="#E22C3C",
)
red_orange = TagColorGroup(
slug="red-orange",
namespace="tagstudio-standard",
name="Red Orange",
primary="#E83726",
)
orange = TagColorGroup(
slug="orange",
namespace="tagstudio-standard",
name="Orange",
primary="#ED6022",
)
amber = TagColorGroup(
slug="amber",
namespace="tagstudio-standard",
name="Amber",
primary="#FA9A2C",
)
yellow = TagColorGroup(
slug="yellow",
namespace="tagstudio-standard",
name="Yellow",
primary="#FFD63D",
)
lime = TagColorGroup(
slug="lime",
namespace="tagstudio-standard",
name="Lime",
primary="#92E649",
)
green = TagColorGroup(
slug="green",
namespace="tagstudio-standard",
name="Green",
primary="#45D649",
)
teal = TagColorGroup(
slug="teal",
namespace="tagstudio-standard",
name="Teal",
primary="#22D589",
)
cyan = TagColorGroup(
slug="cyan",
namespace="tagstudio-standard",
name="Cyan",
primary="#3DDBDB",
)
blue = TagColorGroup(
slug="blue",
namespace="tagstudio-standard",
name="Blue",
primary="#3B87F0",
)
indigo = TagColorGroup(
slug="indigo",
namespace="tagstudio-standard",
name="Indigo",
primary="#874FF5",
)
purple = TagColorGroup(
slug="purple",
namespace="tagstudio-standard",
name="Purple",
primary="#BB4FF0",
)
magenta = TagColorGroup(
slug="magenta",
namespace="tagstudio-standard",
name="Magenta",
primary="#F64680",
)
pink = TagColorGroup(
slug="pink",
namespace="tagstudio-standard",
name="Pink",
primary="#FF62AF",
)
return [
red,
red_orange,
orange,
amber,
yellow,
lime,
green,
teal,
cyan,
blue,
indigo,
purple,
pink,
magenta,
]
def pastels() -> list[TagColorGroup]:
coral = TagColorGroup(
slug="coral",
namespace="tagstudio-pastels",
name="Coral",
primary="#F2525F",
)
salmon = TagColorGroup(
slug="salmon",
namespace="tagstudio-pastels",
name="Salmon",
primary="#F66348",
)
light_orange = TagColorGroup(
slug="light-orange",
namespace="tagstudio-pastels",
name="Light Orange",
primary="#FF9450",
)
light_amber = TagColorGroup(
slug="light-amber",
namespace="tagstudio-pastels",
name="Light Amber",
primary="#FFBA57",
)
light_yellow = TagColorGroup(
slug="light-yellow",
namespace="tagstudio-pastels",
name="Light Yellow",
primary="#FFE173",
)
light_lime = TagColorGroup(
slug="light-lime",
namespace="tagstudio-pastels",
name="Light Lime",
primary="#C9FF7A",
)
light_green = TagColorGroup(
slug="light-green",
namespace="tagstudio-pastels",
name="Light Green",
primary="#81FF76",
)
mint = TagColorGroup(
slug="mint",
namespace="tagstudio-pastels",
name="Mint",
primary="#68FFB4",
)
sky_blue = TagColorGroup(
slug="sky-blue",
namespace="tagstudio-pastels",
name="Sky Blue",
primary="#8EFFF4",
)
light_blue = TagColorGroup(
slug="light-blue",
namespace="tagstudio-pastels",
name="Light Blue",
primary="#64C6FF",
)
lavender = TagColorGroup(
slug="lavender",
namespace="tagstudio-pastels",
name="Lavender",
primary="#908AF6",
)
lilac = TagColorGroup(
slug="lilac",
namespace="tagstudio-pastels",
name="Lilac",
primary="#DF95FF",
)
light_pink = TagColorGroup(
slug="light-pink",
namespace="tagstudio-pastels",
name="Light Pink",
primary="#FF87BA",
)
return [
coral,
salmon,
light_orange,
light_amber,
light_yellow,
light_lime,
light_green,
mint,
sky_blue,
light_blue,
lavender,
lilac,
light_pink,
]
def shades() -> list[TagColorGroup]:
burgundy = TagColorGroup(
slug="burgundy",
namespace="tagstudio-shades",
name="Burgundy",
primary="#6E1C24",
)
auburn = TagColorGroup(
slug="auburn",
namespace="tagstudio-shades",
name="Auburn",
primary="#A13220",
)
olive = TagColorGroup(
slug="olive",
namespace="tagstudio-shades",
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 [burgundy, auburn, olive, dark_teal, navy, dark_lavender, berry]
def earth_tones() -> list[TagColorGroup]:
dark_brown = TagColorGroup(
slug="dark-brown",
namespace="tagstudio-earth-tones",
name="Dark Brown",
primary="#4C2315",
)
brown = TagColorGroup(
slug="brown",
namespace="tagstudio-earth-tones",
name="Brown",
primary="#823216",
)
light_brown = TagColorGroup(
slug="light-brown",
namespace="tagstudio-earth-tones",
name="Light Brown",
primary="#BE5B2D",
)
blonde = TagColorGroup(
slug="blonde",
namespace="tagstudio-earth-tones",
name="Blonde",
primary="#EFC664",
)
peach = TagColorGroup(
slug="peach",
namespace="tagstudio-earth-tones",
name="Peach",
primary="#F1C69C",
)
warm_gray = TagColorGroup(
slug="warm-gray",
namespace="tagstudio-earth-tones",
name="Warm Gray",
primary="#625550",
)
cool_gray = TagColorGroup(
slug="cool-gray",
namespace="tagstudio-earth-tones",
name="Cool Gray",
primary="#515768",
)
return [dark_brown, brown, light_brown, blonde, peach, warm_gray, cool_gray]
def grayscale() -> list[TagColorGroup]:
black = TagColorGroup(
slug="black",
namespace="tagstudio-grayscale",
name="Black",
primary="#111018",
)
dark_gray = TagColorGroup(
slug="dark-gray",
namespace="tagstudio-grayscale",
name="Dark Gray",
primary="#242424",
)
gray = TagColorGroup(
slug="gray",
namespace="tagstudio-grayscale",
name="Gray",
primary="#53525A",
)
light_gray = TagColorGroup(
slug="light-gray",
namespace="tagstudio-grayscale",
name="Light Gray",
primary="#AAAAAA",
)
white = TagColorGroup(
slug="white",
namespace="tagstudio-grayscale",
name="White",
primary="#F2F1F8",
)
return [black, dark_gray, gray, light_gray, white]
def neon() -> list[TagColorGroup]:
neon_red = TagColorGroup(
slug="neon-red",
namespace="tagstudio-neon",
name="Neon Red",
primary="#180607",
secondary="#E22C3C",
color_border=True,
)
neon_red_orange = TagColorGroup(
slug="neon-red-orange",
namespace="tagstudio-neon",
name="Neon Red Orange",
primary="#220905",
secondary="#E83726",
color_border=True,
)
neon_orange = TagColorGroup(
slug="neon-orange",
namespace="tagstudio-neon",
name="Neon Orange",
primary="#1F0D05",
secondary="#ED6022",
color_border=True,
)
neon_amber = TagColorGroup(
slug="neon-amber",
namespace="tagstudio-neon",
name="Neon Amber",
primary="#251507",
secondary="#FA9A2C",
color_border=True,
)
neon_yellow = TagColorGroup(
slug="neon-yellow",
namespace="tagstudio-neon",
name="Neon Yellow",
primary="#2B1C0B",
secondary="#FFD63D",
color_border=True,
)
neon_lime = TagColorGroup(
slug="neon-lime",
namespace="tagstudio-neon",
name="Neon Lime",
primary="#1B220C",
secondary="#92E649",
color_border=True,
)
neon_green = TagColorGroup(
slug="neon-green",
namespace="tagstudio-neon",
name="Neon Green",
primary="#091610",
secondary="#45D649",
color_border=True,
)
neon_teal = TagColorGroup(
slug="neon-teal",
namespace="tagstudio-neon",
name="Neon Teal",
primary="#09191D",
secondary="#22D589",
color_border=True,
)
neon_cyan = TagColorGroup(
slug="neon-cyan",
namespace="tagstudio-neon",
name="Neon Cyan",
primary="#0B191C",
secondary="#3DDBDB",
color_border=True,
)
neon_blue = TagColorGroup(
slug="neon-blue",
namespace="tagstudio-neon",
name="Neon Blue",
primary="#09101C",
secondary="#3B87F0",
color_border=True,
)
neon_indigo = TagColorGroup(
slug="neon-indigo",
namespace="tagstudio-neon",
name="Neon Indigo",
primary="#150B24",
secondary="#874FF5",
color_border=True,
)
neon_purple = TagColorGroup(
slug="neon-purple",
namespace="tagstudio-neon",
name="Neon Purple",
primary="#1E0B26",
secondary="#BB4FF0",
color_border=True,
)
neon_magenta = TagColorGroup(
slug="neon-magenta",
namespace="tagstudio-neon",
name="Neon Magenta",
primary="#220A13",
secondary="#F64680",
color_border=True,
)
neon_pink = TagColorGroup(
slug="neon-pink",
namespace="tagstudio-neon",
name="Neon Pink",
primary="#210E15",
secondary="#FF62AF",
color_border=True,
)
neon_white = TagColorGroup(
slug="neon-white",
namespace="tagstudio-neon",
name="Neon White",
primary="#131315",
secondary="#F2F1F8",
color_border=True,
)
return [
neon_red,
neon_red_orange,
neon_orange,
neon_amber,
neon_yellow,
neon_lime,
neon_green,
neon_teal,
neon_cyan,
neon_blue,
neon_indigo,
neon_purple,
neon_pink,
neon_magenta,
neon_white,
]

View File

@@ -0,0 +1,144 @@
# Copyright (C) 2025
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import TYPE_CHECKING, Any, override
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, declared_attr, mapped_column, relationship
from tagstudio.core.library.alchemy.db import Base
from tagstudio.core.library.alchemy.enums import FieldTypeEnum
if TYPE_CHECKING:
from tagstudio.core.library.alchemy.models import Entry, ValueType
class BaseField(Base):
__abstract__ = True
@declared_attr
def id(self) -> Mapped[int]:
return mapped_column(primary_key=True, autoincrement=True)
@declared_attr
def type_key(self) -> Mapped[str]:
return mapped_column(ForeignKey("value_type.key"))
@declared_attr
def type(self) -> Mapped[ValueType]:
return relationship(foreign_keys=[self.type_key], lazy=False) # type: ignore # pyright: ignore[reportArgumentType]
@declared_attr
def entry_id(self) -> Mapped[int]:
return mapped_column(ForeignKey("entries.id"))
@declared_attr
def entry(self) -> Mapped[Entry]:
return relationship(foreign_keys=[self.entry_id]) # type: ignore # pyright: ignore[reportArgumentType]
@declared_attr
def position(self) -> Mapped[int]:
return mapped_column(default=0)
@override
def __hash__(self):
return hash(self.__key())
def __key(self): # pyright: ignore[reportUnknownParameterType]
raise NotImplementedError
value: Any # pyright: ignore
class BooleanField(BaseField):
__tablename__ = "boolean_fields"
value: Mapped[bool]
def __key(self):
return (self.type, self.value)
@override
def __eq__(self, value: object) -> bool:
if isinstance(value, BooleanField):
return self.__key() == value.__key()
raise NotImplementedError
class TextField(BaseField):
__tablename__ = "text_fields"
value: Mapped[str | None]
def __key(self) -> tuple[ValueType, str | None]:
return self.type, self.value
@override
def __eq__(self, value: object) -> bool:
if isinstance(value, TextField):
return self.__key() == value.__key()
elif isinstance(value, DatetimeField):
return False
raise NotImplementedError
class DatetimeField(BaseField):
__tablename__ = "datetime_fields"
value: Mapped[str | None]
def __key(self):
return (self.type, self.value)
@override
def __eq__(self, value: object) -> bool:
if isinstance(value, DatetimeField):
return self.__key() == value.__key()
raise NotImplementedError
@dataclass
class DefaultField:
id: int
name: str
type: FieldTypeEnum
is_default: bool = field(default=False)
class FieldID(Enum):
"""Only for bootstrapping content of DB table."""
TITLE = DefaultField(id=0, name="Title", type=FieldTypeEnum.TEXT_LINE, is_default=True)
AUTHOR = DefaultField(id=1, name="Author", type=FieldTypeEnum.TEXT_LINE)
ARTIST = DefaultField(id=2, name="Artist", type=FieldTypeEnum.TEXT_LINE)
URL = DefaultField(id=3, name="URL", type=FieldTypeEnum.TEXT_LINE)
DESCRIPTION = DefaultField(id=4, name="Description", type=FieldTypeEnum.TEXT_BOX)
NOTES = DefaultField(id=5, name="Notes", type=FieldTypeEnum.TEXT_BOX)
COLLATION = DefaultField(id=9, name="Collation", type=FieldTypeEnum.TEXT_LINE)
DATE = DefaultField(id=10, name="Date", type=FieldTypeEnum.DATETIME)
DATE_CREATED = DefaultField(id=11, name="Date Created", type=FieldTypeEnum.DATETIME)
DATE_MODIFIED = DefaultField(id=12, name="Date Modified", type=FieldTypeEnum.DATETIME)
DATE_TAKEN = DefaultField(id=13, name="Date Taken", type=FieldTypeEnum.DATETIME)
DATE_PUBLISHED = DefaultField(id=14, name="Date Published", type=FieldTypeEnum.DATETIME)
# ARCHIVED = DefaultField(id=15, name="Archived", type=CheckboxField.checkbox)
# FAVORITE = DefaultField(id=16, name="Favorite", type=CheckboxField.checkbox)
BOOK = DefaultField(id=17, name="Book", type=FieldTypeEnum.TEXT_LINE)
COMIC = DefaultField(id=18, name="Comic", type=FieldTypeEnum.TEXT_LINE)
SERIES = DefaultField(id=19, name="Series", type=FieldTypeEnum.TEXT_LINE)
MANGA = DefaultField(id=20, name="Manga", type=FieldTypeEnum.TEXT_LINE)
SOURCE = DefaultField(id=21, name="Source", type=FieldTypeEnum.TEXT_LINE)
DATE_UPLOADED = DefaultField(id=22, name="Date Uploaded", type=FieldTypeEnum.DATETIME)
DATE_RELEASED = DefaultField(id=23, name="Date Released", type=FieldTypeEnum.DATETIME)
VOLUME = DefaultField(id=24, name="Volume", type=FieldTypeEnum.TEXT_LINE)
ANTHOLOGY = DefaultField(id=25, name="Anthology", type=FieldTypeEnum.TEXT_LINE)
MAGAZINE = DefaultField(id=26, name="Magazine", type=FieldTypeEnum.TEXT_LINE)
PUBLISHER = DefaultField(id=27, name="Publisher", type=FieldTypeEnum.TEXT_LINE)
GUEST_ARTIST = DefaultField(id=28, name="Guest Artist", type=FieldTypeEnum.TEXT_LINE)
COMPOSER = DefaultField(id=29, name="Composer", type=FieldTypeEnum.TEXT_LINE)
COMMENTS = DefaultField(id=30, name="Comments", type=FieldTypeEnum.TEXT_LINE)

View File

@@ -0,0 +1,23 @@
# Copyright (C) 2025
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from tagstudio.core.library.alchemy.db import Base
class TagParent(Base):
__tablename__ = "tag_parents"
parent_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True)
child_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True)
class TagEntry(Base):
__tablename__ = "tag_entries"
tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True)
entry_id: Mapped[int] = mapped_column(ForeignKey("entries.id"), primary_key=True)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,336 @@
# Copyright (C) 2025
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from datetime import datetime as dt
from pathlib import Path
from typing import override
from sqlalchemy import JSON, ForeignKey, ForeignKeyConstraint, Integer, event
from sqlalchemy.orm import Mapped, mapped_column, relationship
from typing_extensions import deprecated
from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE
from tagstudio.core.library.alchemy.db import Base, PathType
from tagstudio.core.library.alchemy.enums import FieldTypeEnum
from tagstudio.core.library.alchemy.fields import (
BaseField,
BooleanField,
DatetimeField,
TextField,
)
from tagstudio.core.library.alchemy.joins import TagParent
class Namespace(Base):
__tablename__ = "namespaces"
namespace: Mapped[str] = mapped_column(primary_key=True, nullable=False)
name: Mapped[str] = mapped_column(nullable=False)
def __init__(
self,
namespace: str,
name: str,
):
self.namespace = namespace
self.name = name
super().__init__()
class TagAlias(Base):
__tablename__ = "tag_aliases"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(nullable=False)
tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id"))
tag: Mapped["Tag"] = relationship(back_populates="aliases")
def __init__(self, name: str, tag_id: int | None = None):
self.name = name
if tag_id is not None:
self.tag_id = tag_id
super().__init__()
class TagColorGroup(Base):
__tablename__ = "tag_colors"
slug: Mapped[str] = mapped_column(primary_key=True, nullable=False)
namespace: Mapped[str] = mapped_column(
ForeignKey("namespaces.namespace"), primary_key=True, nullable=False
)
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__(
self,
slug: str,
namespace: str,
name: str,
primary: str,
secondary: str | None = None,
color_border: bool = False,
):
self.slug = slug
self.namespace = namespace
self.name = name
self.primary = primary
if secondary:
self.secondary = secondary
self.color_border = color_border
super().__init__()
class Tag(Base):
__tablename__ = "tags"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str]
shorthand: Mapped[str | None]
color_namespace: Mapped[str | None] = mapped_column()
color_slug: Mapped[str | None] = mapped_column()
color: Mapped[TagColorGroup | None] = relationship(lazy="joined")
is_category: Mapped[bool]
icon: Mapped[str | None]
aliases: Mapped[set[TagAlias]] = relationship(back_populates="tag")
parent_tags: Mapped[set["Tag"]] = relationship(
secondary=TagParent.__tablename__,
primaryjoin="Tag.id == TagParent.child_id",
secondaryjoin="Tag.id == TagParent.parent_id",
back_populates="parent_tags",
)
disambiguation_id: Mapped[int | None]
__table_args__ = (
ForeignKeyConstraint(
[color_namespace, color_slug], [TagColorGroup.namespace, TagColorGroup.slug]
),
{"sqlite_autoincrement": True},
)
@property
def parent_ids(self) -> list[int]:
return [tag.id for tag in self.parent_tags]
@property
def alias_strings(self) -> list[str]:
return [alias.name for alias in self.aliases]
@property
def alias_ids(self) -> list[int]:
return [tag.id for tag in self.aliases]
def __init__(
self,
name: str,
id: int | None = None,
shorthand: str | None = None,
aliases: set[TagAlias] | None = None,
parent_tags: set["Tag"] | None = None,
icon: str | None = None,
color_namespace: str | None = None,
color_slug: str | None = None,
disambiguation_id: int | None = None,
is_category: bool = False,
):
self.name = name
self.aliases = aliases or set()
self.parent_tags = parent_tags or set()
self.color_namespace = color_namespace
self.color_slug = color_slug
self.icon = icon
self.shorthand = shorthand
self.disambiguation_id = disambiguation_id
self.is_category = is_category
self.id = id # pyright: ignore[reportAttributeAccessIssue]
super().__init__()
@override
def __str__(self) -> str:
return f"<Tag ID: {self.id} Name: {self.name}>"
@override
def __repr__(self) -> str:
return self.__str__()
@override
def __hash__(self) -> int:
return hash(self.id)
def __lt__(self, other: "Tag") -> bool:
return self.name < other.name
def __le__(self, other: "Tag") -> bool:
return self.name <= other.name
def __gt__(self, other: "Tag") -> bool:
return self.name > other.name
def __ge__(self, other: "Tag") -> bool:
return self.name >= other.name
class Folder(Base):
__tablename__ = "folders"
# TODO - implement this
id: Mapped[int] = mapped_column(primary_key=True)
path: Mapped[Path] = mapped_column(PathType, unique=True)
uuid: Mapped[str] = mapped_column(unique=True)
class Entry(Base):
__tablename__ = "entries"
id: Mapped[int] = mapped_column(primary_key=True)
folder_id: Mapped[int] = mapped_column(ForeignKey("folders.id"))
folder: Mapped[Folder] = relationship("Folder")
path: Mapped[Path] = mapped_column(PathType, unique=True)
filename: Mapped[str] = mapped_column()
suffix: Mapped[str] = mapped_column()
date_created: Mapped[dt | None]
date_modified: Mapped[dt | None]
date_added: Mapped[dt | None]
tags: Mapped[set[Tag]] = relationship(secondary="tag_entries")
text_fields: Mapped[list[TextField]] = relationship(
back_populates="entry",
cascade="all, delete",
)
datetime_fields: Mapped[list[DatetimeField]] = relationship(
back_populates="entry",
cascade="all, delete",
)
@property
def fields(self) -> list[BaseField]:
fields: list[BaseField] = []
fields.extend(self.text_fields)
fields.extend(self.datetime_fields)
fields = sorted(fields, key=lambda field: field.type.position)
return fields
@property
def is_favorite(self) -> bool:
return any(tag.id == TAG_FAVORITE for tag in self.tags)
@property
def is_archived(self) -> bool:
return any(tag.id == TAG_ARCHIVED for tag in self.tags)
def __init__(
self,
path: Path,
folder: Folder,
fields: list[BaseField],
id: int | None = None,
date_created: dt | None = None,
date_modified: dt | None = None,
date_added: dt | None = None,
) -> None:
super().__init__()
self.path = path
self.folder = folder
self.id = id # pyright: ignore[reportAttributeAccessIssue]
self.filename = path.name
self.suffix = path.suffix.lstrip(".").lower()
# The date the file associated with this entry was created.
# st_birthtime on Windows and Mac, st_ctime on Linux.
self.date_created = date_created
# The date the file associated with this entry was last modified: st_mtime.
self.date_modified = date_modified
# The date this entry was added to the library.
self.date_added = date_added
for field in fields:
if isinstance(field, TextField):
self.text_fields.append(field)
elif isinstance(field, DatetimeField):
self.datetime_fields.append(field)
else:
raise ValueError(f"Invalid field type: {field}")
def has_tag(self, tag: Tag) -> bool:
return tag in self.tags
def remove_tag(self, tag: Tag) -> None:
"""Removes a Tag from the Entry."""
self.tags.remove(tag)
class ValueType(Base):
"""Define Field Types in the Library.
Example:
key: content_tags (this field is slugified `name`)
name: Content Tags (this field is human readable name)
kind: type of content (Text Line, Text Box, Tags, Datetime, Checkbox)
is_default: Should the field be present in new Entry?
order: position of the field widget in the Entry form
"""
__tablename__ = "value_type"
key: Mapped[str] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(nullable=False)
type: Mapped[FieldTypeEnum] = mapped_column(default=FieldTypeEnum.TEXT_LINE)
is_default: Mapped[bool] # pyright: ignore[reportUninitializedInstanceVariable]
position: Mapped[int] # pyright: ignore[reportUninitializedInstanceVariable]
# add relations to other tables
text_fields: Mapped[list[TextField]] = relationship("TextField", back_populates="type")
datetime_fields: Mapped[list[DatetimeField]] = relationship(
"DatetimeField", back_populates="type"
)
boolean_fields: Mapped[list[BooleanField]] = relationship("BooleanField", back_populates="type")
@property
def as_field(self) -> BaseField:
FieldClass = { # noqa: N806
FieldTypeEnum.TEXT_LINE: TextField,
FieldTypeEnum.TEXT_BOX: TextField,
FieldTypeEnum.DATETIME: DatetimeField,
FieldTypeEnum.BOOLEAN: BooleanField,
}
return FieldClass[self.type](
type_key=self.key,
position=self.position,
)
@event.listens_for(ValueType, "before_insert")
def slugify_field_key(mapper, connection, target): # pyright: ignore
"""Slugify the field key before inserting into the database."""
if not target.key:
from tagstudio.core.library.alchemy.library import slugify
target.key = slugify(target.tag)
# NOTE: The "Preferences" table has been depreciated as of TagStudio 9.5.4
# and is set to be removed in a future release.
@deprecated("Use `Version` for storing version, and `ts_ignore` system for file exclusion.")
class Preferences(Base):
__tablename__ = "preferences"
key: Mapped[str] = mapped_column(primary_key=True)
value: Mapped[dict] = mapped_column(JSON, nullable=False)
class Version(Base):
__tablename__ = "versions"
key: Mapped[str] = mapped_column(primary_key=True)
value: Mapped[int] = mapped_column(nullable=False, default=0)

View File

@@ -5,13 +5,14 @@ from pathlib import Path
import structlog
from tagstudio.core.library.alchemy.enums import BrowsingState
from tagstudio.core.library.alchemy.library import Entry, Library
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry
logger = structlog.get_logger()
@dataclass
class DupeRegistry:
class DupeFilesRegistry:
"""State handler for DupeGuru results."""
library: Library

View File

@@ -9,7 +9,8 @@ from pathlib import Path
import structlog
from tagstudio.core.library.alchemy.library import Entry, Library
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.library.ignore import Ignore
from tagstudio.core.utils.types import unwrap

View File

@@ -5,7 +5,8 @@ from pathlib import Path
import structlog
from wcmatch import pathlib
from tagstudio.core.library.alchemy.library import Entry, Library
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.library.ignore import PATH_GLOB_FLAGS, Ignore
from tagstudio.core.utils.types import unwrap

View File

@@ -0,0 +1,198 @@
# Copyright (C) 2025
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import re
from typing import TYPE_CHECKING, override
import structlog
from sqlalchemy import ColumnElement, and_, distinct, func, or_, select
from sqlalchemy.orm import Session
from sqlalchemy.sql.operators import ilike_op
from tagstudio.core.library.alchemy.constants import TAG_CHILDREN_ID_QUERY
from tagstudio.core.library.alchemy.joins import TagEntry
from tagstudio.core.library.alchemy.models import Entry, Tag, TagAlias
from tagstudio.core.media_types import FILETYPE_EQUIVALENTS, MediaCategories
from tagstudio.core.query_lang.ast import (
AST,
ANDList,
BaseVisitor,
Constraint,
ConstraintType,
Not,
ORList,
Property,
)
# Only import for type checking/autocompletion, will not be imported at runtime.
if TYPE_CHECKING:
from tagstudio.core.library.alchemy.library import Library
else:
Library = None # don't import library because of circular imports
logger = structlog.get_logger(__name__)
def get_filetype_equivalency_list(item: str) -> list[str] | set[str]:
for s in FILETYPE_EQUIVALENTS:
if item in s:
return s
return [item]
class SQLBoolExpressionBuilder(BaseVisitor[ColumnElement[bool]]):
def __init__(self, lib: Library) -> None:
super().__init__()
self.lib = lib
@override
def visit_or_list(self, node: ORList) -> ColumnElement[bool]: # type: ignore
tag_ids, bool_expressions = self.__separate_tags(node.elements, only_single=False)
if len(tag_ids) > 0:
bool_expressions.append(self.__entry_has_any_tags(tag_ids))
return or_(*bool_expressions)
@override
def visit_and_list(self, node: ANDList) -> ColumnElement[bool]: # type: ignore
tag_ids, bool_expressions = self.__separate_tags(node.terms, only_single=True)
if len(tag_ids) > 0:
bool_expressions.append(self.__entry_has_all_tags(tag_ids))
return and_(*bool_expressions)
@override
def visit_constraint(self, node: Constraint) -> ColumnElement[bool]: # type: ignore
"""Returns a Boolean Expression that is true, if the Entry satisfies the constraint."""
if len(node.properties) != 0:
raise NotImplementedError("Properties are not implemented yet") # TODO TSQLANG
if node.type == ConstraintType.Tag:
return self.__entry_has_any_tags(self.__get_tag_ids(node.value))
elif node.type == ConstraintType.TagID:
return self.__entry_has_any_tags([int(node.value)])
elif node.type == ConstraintType.Path:
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:
if node.value == media_cat.name:
extensions = extensions | media_cat.extensions
break
return Entry.suffix.in_(map(lambda x: x.replace(".", ""), extensions))
elif node.type == ConstraintType.FileType:
return or_(
*[Entry.suffix.ilike(ft) for ft in get_filetype_equivalency_list(node.value)]
)
elif node.type == ConstraintType.Special: # noqa: SIM102 unnecessary once there is a second special constraint
if node.value.lower() == "untagged":
return ~Entry.id.in_(select(Entry.id).join(TagEntry))
# raise exception if Constraint stays unhandled
raise NotImplementedError("This type of constraint is not implemented yet")
@override
def visit_property(self, node: Property) -> ColumnElement[bool]: # type: ignore
raise NotImplementedError("This should never be reached!")
@override
def visit_not(self, node: Not) -> ColumnElement[bool]: # type: ignore
return ~self.visit(node.child)
def __get_tag_ids(self, tag_name: str, include_children: bool = True) -> list[int]:
"""Given a tag name find the ids of all tags that this name could refer to."""
with Session(self.lib.engine) as session:
tag_ids = list(
session.scalars(
select(Tag.id)
.where(or_(Tag.name.ilike(tag_name), Tag.shorthand.ilike(tag_name)))
.union(select(TagAlias.tag_id).where(TagAlias.name.ilike(tag_name)))
)
)
if len(tag_ids) > 1:
logger.debug(
f'Tag Constraint "{tag_name}" is ambiguous, {len(tag_ids)} matching tags found',
tag_ids=tag_ids,
include_children=include_children,
)
if not include_children:
return tag_ids
outp: list[int] = []
for tag_id in tag_ids:
outp.extend(list(session.scalars(TAG_CHILDREN_ID_QUERY, {"tag_id": tag_id})))
return outp
def __separate_tags(
self, terms: list[AST], only_single: bool = True
) -> tuple[list[int], list[ColumnElement[bool]]]:
tag_ids: list[int] = []
bool_expressions: list[ColumnElement[bool]] = []
for term in terms:
if isinstance(term, Constraint) and len(term.properties) == 0:
match term.type:
case ConstraintType.TagID:
try:
tag_ids.append(int(term.value))
except ValueError:
logger.error(
"[SQLBoolExpressionBuilder] Could not cast value to an int Tag ID",
value=term.value,
)
continue
case ConstraintType.Tag:
ids = self.__get_tag_ids(term.value)
if not only_single:
tag_ids.extend(ids)
continue
elif len(ids) == 1:
tag_ids.append(ids[0])
continue
case ConstraintType.FileType:
pass
case ConstraintType.Path:
pass
case ConstraintType.Special:
pass
case _:
raise NotImplementedError(f"Unhandled constraint: '{term.type}'")
bool_expressions.append(self.visit(term))
return tag_ids, bool_expressions
def __entry_has_all_tags(self, tag_ids: list[int]) -> ColumnElement[bool]:
"""Returns Binary Expression that is true if the Entry has all provided tag ids."""
# Relational Division Query
return Entry.id.in_(
select(TagEntry.entry_id)
.where(TagEntry.tag_id.in_(tag_ids))
.group_by(TagEntry.entry_id)
.having(func.count(distinct(TagEntry.tag_id)) == len(tag_ids))
)
def __entry_has_any_tags(self, tag_ids: list[int]) -> ColumnElement[bool]:
"""Returns Binary Expression that is true if the Entry has any of the provided tag ids."""
return Entry.id.in_(
select(TagEntry.entry_id).where(TagEntry.tag_id.in_(tag_ids)).distinct()
)

View File

@@ -1,83 +0,0 @@
# Copyright (C) 2025
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
def json_to_sql_color(json_color: str) -> tuple[str | None, str | None]:
"""Convert a color string from a <=9.4 JSON library to a 9.5+ (namespace, slug) tuple."""
json_color_ = json_color.lower()
match json_color_:
case "black":
return ("tagstudio-grayscale", "black")
case "dark gray":
return ("tagstudio-grayscale", "dark-gray")
case "gray":
return ("tagstudio-grayscale", "gray")
case "light gray":
return ("tagstudio-grayscale", "light-gray")
case "white":
return ("tagstudio-grayscale", "white")
case "light pink":
return ("tagstudio-pastels", "light-pink")
case "pink":
return ("tagstudio-standard", "pink")
case "magenta":
return ("tagstudio-standard", "magenta")
case "red":
return ("tagstudio-standard", "red")
case "red orange":
return ("tagstudio-standard", "red-orange")
case "salmon":
return ("tagstudio-pastels", "salmon")
case "orange":
return ("tagstudio-standard", "orange")
case "yellow orange":
return ("tagstudio-standard", "amber")
case "yellow":
return ("tagstudio-standard", "yellow")
case "mint":
return ("tagstudio-pastels", "mint")
case "lime":
return ("tagstudio-standard", "lime")
case "light green":
return ("tagstudio-pastels", "light-green")
case "green":
return ("tagstudio-standard", "green")
case "teal":
return ("tagstudio-standard", "teal")
case "cyan":
return ("tagstudio-standard", "cyan")
case "light blue":
return ("tagstudio-pastels", "light-blue")
case "blue":
return ("tagstudio-standard", "blue")
case "blue violet":
return ("tagstudio-shades", "navy")
case "violet":
return ("tagstudio-standard", "indigo")
case "purple":
return ("tagstudio-standard", "purple")
case "peach":
return ("tagstudio-earth-tones", "peach")
case "brown":
return ("tagstudio-earth-tones", "brown")
case "lavender":
return ("tagstudio-pastels", "lavender")
case "blonde":
return ("tagstudio-earth-tones", "blonde")
case "auburn":
return ("tagstudio-shades", "auburn")
case "light brown":
return ("tagstudio-earth-tones", "light-brown")
case "dark brown":
return ("tagstudio-earth-tones", "dark-brown")
case "cool gray":
return ("tagstudio-earth-tones", "cool-gray")
case "warm gray":
return ("tagstudio-earth-tones", "warm-gray")
case "olive":
return ("tagstudio-shades", "olive")
case "berry":
return ("tagstudio-shades", "berry")
case _:
return (None, None)

View File

@@ -10,7 +10,7 @@ import wcmatch.fnmatch as fnmatch
from wcmatch import glob, pathlib
from tagstudio.core.constants import IGNORE_NAME, TS_FOLDER_NAME
from tagstudio.core.singleton import Singleton
from tagstudio.core.utils.singleton import Singleton
logger = structlog.get_logger()

View File

@@ -27,8 +27,7 @@ from tagstudio.core.constants import (
)
from tagstudio.core.enums import OpenStatus
from tagstudio.core.library.json.fields import DEFAULT_FIELDS, TEXT_FIELDS
from tagstudio.core.utils.str import strip_punctuation
from tagstudio.core.utils.web import strip_web_protocol
from tagstudio.core.utils.str_formatting import strip_punctuation, strip_web_protocol
TYPE = ["file", "meta", "alt", "mask"]

View File

@@ -13,15 +13,16 @@ from time import time
import structlog
from wcmatch import pathlib
from tagstudio.core.library.alchemy.library import Entry, Library
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.library.ignore import PATH_GLOB_FLAGS, Ignore, ignore_to_glob
from tagstudio.qt.helpers.silent_popen import silent_run # pyright: ignore
from tagstudio.core.utils.silent_subprocess import silent_run # pyright: ignore
logger = structlog.get_logger(__name__)
@dataclass
class RefreshDirTracker:
class RefreshTracker:
library: Library
files_not_in_library: list[Path] = field(default_factory=list)

View File

@@ -7,9 +7,14 @@
import json
from pathlib import Path
import structlog
from tagstudio.core.constants import TS_FOLDER_NAME
from tagstudio.core.library.alchemy.library import Entry, FieldID, Library
from tagstudio.core.utils.unlinked_registry import logger
from tagstudio.core.library.alchemy.fields import FieldID
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry
logger = structlog.get_logger(__name__)
class TagStudioCore:

View File

@@ -1,46 +1,51 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Copyright (C) 2025
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
# pyright: reportExplicitAny=false
import os
import subprocess
import sys
from collections.abc import Callable, Collection, Iterable
from typing import Any
"""Implementation of subprocess.Popen that does not spawn console windows or log output
and sanitizes pyinstall environment variables."""
and sanitizes pyinstaller environment variables."""
def silent_Popen( # noqa: N802
def silent_popen(
args,
bufsize=-1,
bufsize: int = -1,
executable=None,
stdin=None,
stdout=None,
stderr=None,
preexec_fn=None,
close_fds=True,
shell=False,
preexec_fn: Callable[[], Any] | None = None,
close_fds: bool = True,
shell: bool = False,
cwd=None,
env=None,
universal_newlines=None,
startupinfo=None,
creationflags=0,
restore_signals=True,
start_new_session=False,
pass_fds=(),
universal_newlines: bool | None = None,
startupinfo: Any | None = None,
creationflags: int = 0,
restore_signals: bool = True,
start_new_session: bool = False,
pass_fds: Collection[int] = (),
*,
group=None,
extra_groups=None,
user=None,
umask=-1,
encoding=None,
errors=None,
text=None,
pipesize=-1,
process_group=None,
text: bool | None = None,
encoding: str | None = None,
errors: str | None = None,
user: str | int | None = None,
group: str | int | None = None,
extra_groups: Iterable[str | int] | None = None,
umask: int = -1,
pipesize: int = -1,
process_group: int | None = None,
):
"""Call subprocess.Popen without creating a console window."""
current_env = env
if sys.platform == "win32":
creationflags |= subprocess.CREATE_NO_WINDOW
import ctypes
@@ -52,9 +57,9 @@ def silent_Popen( # noqa: N802
or sys.platform.startswith("openbsd")
):
# pass clean environment to the subprocess
env = os.environ
original_env = env.get("LD_LIBRARY_PATH_ORIG")
env["LD_LIBRARY_PATH"] = original_env if original_env else ""
current_env = os.environ
original_env = current_env.get("LD_LIBRARY_PATH_ORIG")
current_env["LD_LIBRARY_PATH"] = original_env if original_env else ""
return subprocess.Popen(
args=args,
@@ -67,20 +72,20 @@ def silent_Popen( # noqa: N802
close_fds=close_fds,
shell=shell,
cwd=cwd,
env=env,
env=current_env,
universal_newlines=universal_newlines,
startupinfo=startupinfo,
creationflags=creationflags,
restore_signals=restore_signals,
start_new_session=start_new_session,
pass_fds=pass_fds,
group=group,
extra_groups=extra_groups,
user=user,
umask=umask,
text=text,
encoding=encoding,
errors=errors,
text=text,
user=user,
group=group,
extra_groups=extra_groups,
umask=umask,
pipesize=pipesize,
process_group=process_group,
)

View File

@@ -1,4 +1,4 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
@@ -24,3 +24,11 @@ def strip_punctuation(string: str) -> str:
.replace(" ", "")
.replace(" ", "")
)
def strip_web_protocol(string: str) -> str:
r"""Strips a leading web protocol (ex. \"https://\") as well as \"www.\" from a string."""
prefixes = ["https://", "http://", "www.", "www2."]
for prefix in prefixes:
string = string.removeprefix(prefix)
return string

View File

@@ -1,11 +0,0 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
def strip_web_protocol(string: str) -> str:
r"""Strips a leading web protocol (ex. \"https://\") as well as \"www.\" from a string."""
prefixes = ["https://", "http://", "www.", "www2."]
for prefix in prefixes:
string = string.removeprefix(prefix)
return string

View File

@@ -12,7 +12,7 @@ import structlog
from PIL import Image
from tagstudio.core.constants import THUMB_CACHE_NAME, TS_FOLDER_NAME
from tagstudio.core.global_settings import DEFAULT_THUMB_CACHE_SIZE
from tagstudio.qt.global_settings import DEFAULT_CACHED_IMAGE_QUALITY, DEFAULT_THUMB_CACHE_SIZE
logger = structlog.get_logger(__name__)
@@ -24,19 +24,21 @@ class CacheFolder:
class CacheManager:
MAX_FOLDER_SIZE = 10 # Number in MiB
MAX_FOLDER_SIZE = 10 # Absolute maximum size of a folder, number in MiB
STAT_MULTIPLIER = 1_000_000 # Multiplier to apply to file stats (bytes) to get user units (MiB)
def __init__(
self,
library_dir: Path,
max_size: int | float = DEFAULT_THUMB_CACHE_SIZE,
img_quality: int = DEFAULT_CACHED_IMAGE_QUALITY,
):
"""A class for managing frontend caches, such as for file thumbnails.
Args:
library_dir(Path): The path of the folder containing the .TagStudio library folder.
max_size: (int | float) The maximum size of the cache, in MiB.
img_quality: (int) The image quality value to save PIL images (0-100, default=80)
"""
self._lock = RLock()
self.cache_path = library_dir / TS_FOLDER_NAME / THUMB_CACHE_NAME
@@ -44,6 +46,9 @@ class CacheManager:
math.floor(max_size * CacheManager.STAT_MULTIPLIER),
math.floor(CacheManager.MAX_FOLDER_SIZE * CacheManager.STAT_MULTIPLIER),
)
self.img_quality = (
img_quality if img_quality >= 0 and img_quality <= 100 else DEFAULT_CACHED_IMAGE_QUALITY
)
self.folders: list[CacheFolder] = []
self.current_size = 0
@@ -127,12 +132,20 @@ class CacheManager:
with self._lock as _lock:
cache_folder: CacheFolder = self._get_current_folder()
file_path = cache_folder.path / file_name
image.save(file_path, mode=mode)
try:
image.save(file_path, mode=mode, quality=self.img_quality)
size = file_path.stat().st_size
cache_folder.size += size
self.current_size += size
self._cull_folders()
size = file_path.stat().st_size
cache_folder.size += size
self.current_size += size
self._cull_folders()
except FileNotFoundError:
logger.warn(
"[CacheManager] Failed to save cached image, was the folder deleted on disk?",
folder=file_path,
)
if not cache_folder.path.exists():
self.folders.remove(cache_folder)
def _create_folder(self) -> CacheFolder:
with self._lock as _lock:

View File

@@ -5,14 +5,14 @@ from PySide6.QtCore import Qt, QUrl
from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import QMessageBox
from tagstudio.core.palette import ColorType, UiColor, get_ui_color
from tagstudio.qt.helpers.vendored.ffmpeg import FFMPEG_CMD, FFPROBE_CMD
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
from tagstudio.qt.previews.vendored.ffmpeg import FFMPEG_CMD, FFPROBE_CMD
from tagstudio.qt.translations import Translations
logger = structlog.get_logger(__name__)
class FfmpegChecker(QMessageBox):
class FfmpegMissingMessageBox(QMessageBox):
"""A warning dialog for if FFmpeg is missing."""
HELP_URL = "https://docs.tagstud.io/help/ffmpeg/"

View File

@@ -9,11 +9,11 @@ import structlog
from PySide6 import QtGui
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.utils.ignored_registry import IgnoredRegistry
from tagstudio.qt.modals.remove_ignored_modal import RemoveIgnoredModal
from tagstudio.core.library.alchemy.registries.ignored_registry import IgnoredRegistry
from tagstudio.qt.mixed.progress_bar import ProgressWidget
from tagstudio.qt.mixed.remove_ignored_modal import RemoveIgnoredModal
from tagstudio.qt.translations import Translations
from tagstudio.qt.view.widgets.fix_ignored_modal_view import FixIgnoredEntriesModalView
from tagstudio.qt.widgets.progress import ProgressWidget
from tagstudio.qt.views.fix_ignored_modal_view import FixIgnoredEntriesModalView
# Only import for type checking/autocompletion, will not be imported at runtime.
if TYPE_CHECKING:

View File

@@ -7,14 +7,15 @@ from pathlib import Path
from typing import override
import structlog
from PySide6 import QtGui
from PySide6.QtCore import Signal
from PySide6.QtGui import QShowEvent
from tagstudio.core.constants import IGNORE_NAME, TS_FOLDER_NAME
from tagstudio.core.library.alchemy.library import Library, Tag
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Tag
from tagstudio.core.library.ignore import Ignore
from tagstudio.qt.helpers import file_opener
from tagstudio.qt.view.widgets.ignore_modal_view import IgnoreModalView
from tagstudio.qt.utils.file_opener import open_file
from tagstudio.qt.views.ignore_modal_view import IgnoreModalView
logger = structlog.get_logger(__name__)
@@ -36,7 +37,7 @@ class IgnoreModal(IgnoreModalView):
if not self.lib.library_dir:
return
ts_ignore_path = Path(self.lib.library_dir / TS_FOLDER_NAME / IGNORE_NAME)
file_opener.open_file(ts_ignore_path, file_manager=True)
open_file(ts_ignore_path, file_manager=True)
def save(self):
if not self.lib.library_dir:
@@ -46,6 +47,6 @@ class IgnoreModal(IgnoreModalView):
Ignore.write_ignore_file(self.lib.library_dir, lines)
@override
def showEvent(self, event: QtGui.QShowEvent) -> None: # type: ignore
def showEvent(self, event: QShowEvent) -> None: # type: ignore
self.__load_file()
return super().showEvent(event)

View File

@@ -18,9 +18,9 @@ from tagstudio.core.library.alchemy.constants import (
)
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.helpers import file_opener
from tagstudio.qt.translations import Translations
from tagstudio.qt.view.widgets.library_info_window_view import LibraryInfoWindowView
from tagstudio.qt.utils import file_opener
from tagstudio.qt.views.library_info_window_view import LibraryInfoWindowView
# Only import for type checking/autocompletion, will not be imported at runtime.
if TYPE_CHECKING:

View File

@@ -10,7 +10,7 @@ from PySide6 import QtCore, QtGui
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget
from tagstudio.qt.widgets.paged_panel.paged_panel_state import PagedPanelState
from tagstudio.qt.controllers.paged_panel_state import PagedPanelState
logger = structlog.get_logger(__name__)

View File

@@ -5,7 +5,7 @@
from PySide6.QtWidgets import QPushButton
from tagstudio.qt.widgets.paged_panel.paged_body_wrapper import PagedBodyWrapper
from tagstudio.qt.views.paged_body_wrapper import PagedBodyWrapper
class PagedPanelState:

View File

@@ -7,9 +7,9 @@ from warnings import catch_warnings
from PySide6.QtWidgets import QListWidgetItem
from tagstudio.core.library.alchemy.library import Library
from tagstudio.qt.modals.add_field import AddFieldModal
from tagstudio.qt.modals.tag_search import TagSearchModal
from tagstudio.qt.view.widgets.preview_panel_view import PreviewPanelView
from tagstudio.qt.mixed.add_field import AddFieldModal
from tagstudio.qt.mixed.tag_search import TagSearchModal
from tagstudio.qt.views.preview_panel_view import PreviewPanelView
if typing.TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver

View File

@@ -14,10 +14,10 @@ from PySide6.QtCore import QSize
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.media_types import MediaCategories
from tagstudio.qt.helpers.file_opener import open_file
from tagstudio.qt.helpers.file_tester import is_readable_video
from tagstudio.qt.view.widgets.preview.preview_thumb_view import PreviewThumbView
from tagstudio.qt.widgets.preview.file_attributes import FileAttributeData
from tagstudio.qt.mixed.file_attributes import FileAttributeData
from tagstudio.qt.utils.file_opener import open_file
from tagstudio.qt.views.preview_thumb_view import PreviewThumbView
if TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver

View File

@@ -9,11 +9,11 @@ from PySide6.QtCore import Signal
from tagstudio.core.enums import TagClickActionOption
from tagstudio.core.library.alchemy.enums import BrowsingState
from tagstudio.core.library.alchemy.library import Tag
from tagstudio.core.library.alchemy.models import Tag
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.modals.build_tag import BuildTagPanel
from tagstudio.qt.view.components.tag_box_view import TagBoxWidgetView
from tagstudio.qt.widgets.panel import PanelModal
from tagstudio.qt.mixed.build_tag import BuildTagPanel
from tagstudio.qt.views.panel_modal import PanelModal
from tagstudio.qt.views.tag_box_view import TagBoxWidgetView
if TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver

View File

@@ -24,6 +24,10 @@ DEFAULT_GLOBAL_SETTINGS_PATH = (
DEFAULT_THUMB_CACHE_SIZE = 500 # Number in MiB
MIN_THUMB_CACHE_SIZE = 10 # Number in MiB
# See: https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#webp-saving
DEFAULT_CACHED_IMAGE_QUALITY = 80
DEFAULT_CACHED_IMAGE_RES = 256
class Theme(IntEnum):
DARK = 0
@@ -56,6 +60,8 @@ class GlobalSettings(BaseModel):
open_last_loaded_on_startup: bool = Field(default=True)
generate_thumbs: bool = Field(default=True)
thumb_cache_size: float = Field(default=DEFAULT_THUMB_CACHE_SIZE)
cached_thumb_quality: int = Field(default=DEFAULT_CACHED_IMAGE_QUALITY)
cached_thumb_resolution: int = Field(default=DEFAULT_CACHED_IMAGE_RES)
autoplay: bool = Field(default=True)
loop: bool = Field(default=True)
show_filenames_in_grid: bool = Field(default=True)

View File

@@ -7,7 +7,7 @@ from PIL import Image
from PySide6.QtCore import Qt
from PySide6.QtGui import QGuiApplication
from tagstudio.qt.helpers.gradient import linear_gradient
from tagstudio.qt.helpers.gradients import linear_gradient
# TODO: Consolidate the built-in QT theme values with the values
# here, in enums.py, and in palette.py.

View File

@@ -7,7 +7,9 @@ from pathlib import Path
import ffmpeg
from tagstudio.qt.helpers.vendored.ffmpeg import probe
from tagstudio.qt.previews.vendored.ffmpeg import (
probe, # pyright: ignore[reportUnknownVariableType]
)
def is_readable_video(filepath: Path | str):

View File

@@ -20,8 +20,8 @@ from PySide6.QtWidgets import (
from tagstudio.core.constants import VERSION, VERSION_BRANCH
from tagstudio.core.enums import Theme
from tagstudio.core.palette import ColorType, UiColor, get_ui_color
from tagstudio.qt.helpers.vendored import ffmpeg
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
from tagstudio.qt.previews.vendored import ffmpeg
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.translations import Translations

View File

@@ -20,18 +20,18 @@ from PySide6.QtWidgets import (
QWidget,
)
from tagstudio.core import palette
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.core.library.alchemy.library import Library, TagColorGroup, slugify
from tagstudio.core.palette import ColorType, UiColor, get_tag_color, get_ui_color
from tagstudio.qt.translations import Translations
from tagstudio.qt.widgets.panel import PanelWidget
from tagstudio.qt.widgets.tag import (
from tagstudio.core.library.alchemy.library import Library, slugify
from tagstudio.core.library.alchemy.models import TagColorGroup
from tagstudio.qt.mixed.tag_color_preview import TagColorPreview
from tagstudio.qt.mixed.tag_widget import (
get_border_color,
get_highlight_color,
get_text_color,
)
from tagstudio.qt.widgets.tag_color_preview import TagColorPreview
from tagstudio.qt.models.palette import ColorType, UiColor, get_tag_color, get_ui_color
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.panel_modal import PanelWidget
logger = structlog.get_logger(__name__)
@@ -264,7 +264,7 @@ class BuildColorPanel(PanelWidget):
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))
color_ = color or QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT))
highlight_color = get_highlight_color(color_)
text_color = get_text_color(color_, highlight_color)

View File

@@ -11,15 +11,11 @@ from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import QLabel, QLineEdit, QVBoxLayout, QWidget
from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX
from tagstudio.core.library.alchemy.library import (
Library,
Namespace,
ReservedNamespaceError,
slugify,
)
from tagstudio.core.palette import ColorType, UiColor, get_ui_color
from tagstudio.core.library.alchemy.library import Library, ReservedNamespaceError, slugify
from tagstudio.core.library.alchemy.models import Namespace
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
from tagstudio.qt.translations import Translations
from tagstudio.qt.widgets.panel import PanelWidget
from tagstudio.qt.views.panel_modal import PanelWidget
logger = structlog.get_logger(__name__)

View File

@@ -26,20 +26,21 @@ from PySide6.QtWidgets import (
)
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.core.library.alchemy.library import Library, Tag, TagColorGroup
from tagstudio.core.palette import ColorType, UiColor, get_tag_color, get_ui_color
from tagstudio.qt.modals.tag_color_selection import TagColorSelection
from tagstudio.qt.modals.tag_search import TagSearchModal, TagSearchPanel
from tagstudio.qt.translations import Translations
from tagstudio.qt.widgets.panel import PanelModal, PanelWidget
from tagstudio.qt.widgets.tag import (
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Tag, TagColorGroup
from tagstudio.qt.mixed.tag_color_preview import TagColorPreview
from tagstudio.qt.mixed.tag_color_selection import TagColorSelection
from tagstudio.qt.mixed.tag_search import TagSearchModal, TagSearchPanel
from tagstudio.qt.mixed.tag_widget import (
TagWidget,
get_border_color,
get_highlight_color,
get_primary_color,
get_text_color,
)
from tagstudio.qt.widgets.tag_color_preview import TagColorPreview
from tagstudio.qt.models.palette import ColorType, UiColor, get_tag_color, get_ui_color
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget
logger = structlog.get_logger(__name__)

View File

@@ -12,17 +12,17 @@ from PySide6.QtWidgets import QMessageBox, QPushButton
from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.core.library.alchemy.library import TagColorGroup
from tagstudio.core.palette import ColorType, get_tag_color
from tagstudio.qt.flowlayout import FlowLayout
from tagstudio.qt.modals.build_color import BuildColorPanel
from tagstudio.core.library.alchemy.models import TagColorGroup
from tagstudio.qt.mixed.build_color import BuildColorPanel
from tagstudio.qt.mixed.field_widget import FieldWidget
from tagstudio.qt.mixed.tag_color_label import TagColorLabel
from tagstudio.qt.models.palette import ColorType, get_tag_color
from tagstudio.qt.translations import Translations
from tagstudio.qt.widgets.fields import FieldWidget
from tagstudio.qt.widgets.panel import PanelModal
from tagstudio.qt.widgets.tag_color_label import TagColorLabel
from tagstudio.qt.views.layouts.flow_layout import FlowLayout
from tagstudio.qt.views.panel_modal import PanelModal
if typing.TYPE_CHECKING:
from tagstudio.core.library import Library
from tagstudio.core.library.alchemy.library import Library
logger = structlog.get_logger(__name__)

View File

@@ -6,7 +6,7 @@ from typing import cast
from PySide6.QtCore import QDateTime
from PySide6.QtWidgets import QDateTimeEdit, QVBoxLayout
from tagstudio.qt.widgets.panel import PanelWidget
from tagstudio.qt.views.panel_modal import PanelWidget
if typing.TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver

View File

@@ -21,8 +21,8 @@ from PySide6.QtWidgets import (
QWidget,
)
from tagstudio.qt.mixed.progress_bar import ProgressWidget
from tagstudio.qt.translations import Translations
from tagstudio.qt.widgets.progress import ProgressWidget
if TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver

View File

@@ -25,23 +25,22 @@ from PySide6.QtWidgets import (
from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE
from tagstudio.core.enums import Theme
from tagstudio.core.library.alchemy.enums import FieldTypeEnum
from tagstudio.core.library.alchemy.library import (
from tagstudio.core.library.alchemy.fields import (
BaseField,
DatetimeField,
Entry,
Library,
Tag,
TextField,
)
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry, Tag
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.controller.components.tag_box_controller import TagBoxWidget
from tagstudio.qt.controllers.tag_box_controller import TagBoxWidget
from tagstudio.qt.mixed.datetime_picker import DatetimePicker
from tagstudio.qt.mixed.field_widget import FieldContainer
from tagstudio.qt.mixed.text_field import TextWidget
from tagstudio.qt.translations import Translations
from tagstudio.qt.widgets.datetime_picker import DatetimePicker
from tagstudio.qt.widgets.fields import FieldContainer
from tagstudio.qt.widgets.panel import PanelModal
from tagstudio.qt.widgets.text import TextWidget
from tagstudio.qt.widgets.text_box_edit import EditTextBox
from tagstudio.qt.widgets.text_line_edit import EditTextLine
from tagstudio.qt.views.edit_text_box_modal import EditTextBox
from tagstudio.qt.views.edit_text_line_modal import EditTextLine
from tagstudio.qt.views.panel_modal import PanelModal
if typing.TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver

View File

@@ -22,10 +22,10 @@ from tagstudio.core.enums import ShowFilepathOption, Theme
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.ignore import Ignore
from tagstudio.core.media_types import MediaCategories
from tagstudio.core.palette import ColorType, UiColor, get_ui_color
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.helpers.file_opener import FileOpenerHelper, FileOpenerLabel
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
from tagstudio.qt.translations import Translations
from tagstudio.qt.utils.file_opener import FileOpenerHelper, FileOpenerLabel
if typing.TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver

View File

@@ -17,8 +17,8 @@ from PySide6.QtWidgets import (
)
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.utils.dupe_files import DupeRegistry
from tagstudio.qt.modals.mirror_entries_modal import MirrorEntriesModal
from tagstudio.core.library.alchemy.registries.dupe_files_registry import DupeFilesRegistry
from tagstudio.qt.mixed.mirror_entries_modal import MirrorEntriesModal
from tagstudio.qt.translations import Translations
# Only import for type checking/autocompletion, will not be imported at runtime.
@@ -40,7 +40,7 @@ class FixDupeFilesModal(QWidget):
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 6, 6, 6)
self.tracker = DupeRegistry(library=self.lib)
self.tracker = DupeFilesRegistry(library=self.lib)
self.desc_widget = QLabel(Translations["file.duplicates.description"])
self.desc_widget.setObjectName("descriptionLabel")

View File

@@ -10,12 +10,12 @@ from PySide6.QtCore import Qt
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.utils.unlinked_registry import UnlinkedRegistry
from tagstudio.qt.modals.merge_dupe_entries import MergeDuplicateEntries
from tagstudio.qt.modals.relink_entries_modal import RelinkUnlinkedEntries
from tagstudio.qt.modals.remove_unlinked_modal import RemoveUnlinkedEntriesModal
from tagstudio.core.library.alchemy.registries.unlinked_registry import UnlinkedRegistry
from tagstudio.qt.mixed.merge_dupe_entries import MergeDuplicateEntries
from tagstudio.qt.mixed.progress_bar import ProgressWidget
from tagstudio.qt.mixed.relink_entries_modal import RelinkUnlinkedEntries
from tagstudio.qt.mixed.remove_unlinked_modal import RemoveUnlinkedEntriesModal
from tagstudio.qt.translations import Translations
from tagstudio.qt.widgets.progress import ProgressWidget
# Only import for type checking/autocompletion, will not be imported at runtime.
if TYPE_CHECKING:

View File

@@ -20,14 +20,16 @@ from PySide6.QtWidgets import (
QVBoxLayout,
QWidget,
)
from typing_extensions import deprecated
from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.core.library.alchemy.library import Library, Tag
from tagstudio.core.palette import ColorType, get_tag_color
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Tag
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.flowlayout import FlowLayout
from tagstudio.qt.models.palette import ColorType, get_tag_color
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.layouts.flow_layout import FlowLayout
if TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
@@ -55,6 +57,7 @@ def add_folders_to_tree(library: Library, tree: BranchData, items: tuple[str, ..
return branch
@deprecated("Will be replaced with upcoming 'Macros' feature before v9.6")
def folders_to_tags(library: Library):
logger.info("Converting folders to Tags")
tree = BranchData()

View File

@@ -13,26 +13,19 @@ import structlog
from PIL import Image, ImageQt
from PySide6.QtCore import QEvent, QMimeData, QSize, Qt, QUrl
from PySide6.QtGui import QAction, QDrag, QEnterEvent, QGuiApplication, QMouseEvent, QPixmap
from PySide6.QtWidgets import (
QBoxLayout,
QCheckBox,
QHBoxLayout,
QLabel,
QVBoxLayout,
QWidget,
)
from PySide6.QtWidgets import QBoxLayout, QCheckBox, QHBoxLayout, QLabel, QVBoxLayout, QWidget
from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE
from tagstudio.core.library.alchemy.enums import ItemType
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.media_types import MediaCategories, MediaType
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.flowlayout import FlowWidget
from tagstudio.qt.helpers.file_opener import FileOpenerHelper
from tagstudio.qt.platform_strings import open_file_str, trash_term
from tagstudio.qt.previews.renderer import ThumbRenderer
from tagstudio.qt.translations import Translations
from tagstudio.qt.widgets.thumb_button import ThumbButton
from tagstudio.qt.widgets.thumb_renderer import ThumbRenderer
from tagstudio.qt.utils.file_opener import FileOpenerHelper
from tagstudio.qt.views.layouts.flow_layout import FlowWidget
from tagstudio.qt.views.thumb_button import ThumbButton
if TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver

View File

@@ -15,7 +15,7 @@ from PySide6.QtWidgets import QLabel, QPushButton, QVBoxLayout, QWidget
from tagstudio.qt.helpers.color_overlay import gradient_overlay, theme_fg_overlay
from tagstudio.qt.translations import Translations
from tagstudio.qt.widgets.clickable_label import ClickableLabel
from tagstudio.qt.views.clickable_label import ClickableLabel
# Only import for type checking/autocompletion, will not be imported at runtime.
if typing.TYPE_CHECKING:

View File

@@ -35,8 +35,8 @@ from PySide6.QtWidgets import (
QWidget,
)
from tagstudio.qt.helpers.qslider_wrapper import QClickSlider
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.clickable_slider import ClickableSlider
if typing.TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
@@ -147,7 +147,7 @@ class MediaPlayer(QGraphicsView):
self.controls.setStyleSheet("background: transparent;")
self.controls.setMinimumHeight(48)
self.timeline_slider = QClickSlider()
self.timeline_slider = ClickableSlider()
self.timeline_slider.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.timeline_slider.setTickPosition(QSlider.TickPosition.NoTicks)
self.timeline_slider.setSingleStep(1)
@@ -196,7 +196,7 @@ class MediaPlayer(QGraphicsView):
sub_layout.addWidget(self.mute_unmute)
sub_layout.setAlignment(self.mute_unmute, Qt.AlignmentFlag.AlignLeft)
self.volume_slider = QClickSlider()
self.volume_slider = ClickableSlider()
self.volume_slider.setOrientation(Qt.Orientation.Horizontal)
self.volume_slider.setValue(int(self.player.audioOutput().volume() * 100))
self.volume_slider.valueChanged.connect(self.volume_slider_changed)
@@ -404,12 +404,12 @@ class MediaPlayer(QGraphicsView):
if not self.is_paused:
self.player.stop()
self.player.setSource(QUrl.fromLocalFile(self.filepath))
if self.autoplay.isChecked():
self.player.play()
else:
self.player.setSource(QUrl.fromLocalFile(self.filepath))
if self.autoplay.isChecked():
self.player.play()
def load_toggle_play_icon(self, playing: bool) -> None:
icon = self.driver.rm.pause_icon if playing else self.driver.rm.play_icon
self.play_pause.load(icon)
@@ -454,6 +454,10 @@ class MediaPlayer(QGraphicsView):
duration = self.format_time(self.player.duration())
self.position_label.setText(f"{current} / {duration}")
# Ensures first frame of non-autoplay videos are loaded
if not self.autoplay.isChecked():
self.player.pause()
def _update_controls(self, size: QSize) -> None:
self.scene().setSceneRect(0, 0, size.width(), size.height())

View File

@@ -8,9 +8,9 @@ import typing
from PySide6.QtCore import QObject, Signal
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.utils.dupe_files import DupeRegistry
from tagstudio.core.library.alchemy.registries.dupe_files_registry import DupeFilesRegistry
from tagstudio.qt.mixed.progress_bar import ProgressWidget
from tagstudio.qt.translations import Translations
from tagstudio.qt.widgets.progress import ProgressWidget
# Only import for type checking/autocompletion, will not be imported at runtime.
if typing.TYPE_CHECKING:
@@ -24,7 +24,7 @@ class MergeDuplicateEntries(QObject):
super().__init__()
self.lib = library
self.driver = driver
self.tracker = DupeRegistry(library=self.lib)
self.tracker = DupeFilesRegistry(library=self.lib)
def merge_entries(self):
pw = ProgressWidget(

View File

@@ -30,19 +30,20 @@ from tagstudio.core.constants import (
TS_FOLDER_NAME,
)
from tagstudio.core.enums import LibraryPrefs
from tagstudio.core.library.alchemy import default_color_groups
from tagstudio.core.library.alchemy.constants import SQL_FILENAME
from tagstudio.core.library.alchemy.library import Entry, TagAlias, TagParent
from tagstudio.core.library.alchemy.joins import TagParent
from tagstudio.core.library.alchemy.library import Library as SqliteLibrary
from tagstudio.core.library.helpers.migration import json_to_sql_color
from tagstudio.core.library.alchemy.models import Entry, TagAlias
from tagstudio.core.library.json.library import Library as JsonLibrary
from tagstudio.core.library.json.library import Tag as JsonTag
from tagstudio.qt.helpers.custom_runnable import CustomRunnable
from tagstudio.qt.helpers.function_iterator import FunctionIterator
from tagstudio.qt.helpers.qbutton_wrapper import QPushButtonWrapper
from tagstudio.qt.controllers.paged_panel_controller import PagedPanel
from tagstudio.qt.controllers.paged_panel_state import PagedPanelState
from tagstudio.qt.translations import Translations
from tagstudio.qt.widgets.paged_panel.paged_body_wrapper import PagedBodyWrapper
from tagstudio.qt.widgets.paged_panel.paged_panel import PagedPanel
from tagstudio.qt.widgets.paged_panel.paged_panel_state import PagedPanelState
from tagstudio.qt.utils.custom_runnable import CustomRunnable
from tagstudio.qt.utils.function_iterator import FunctionIterator
from tagstudio.qt.views.paged_body_wrapper import PagedBodyWrapper
from tagstudio.qt.views.qbutton_wrapper import QPushButtonWrapper
logger = structlog.get_logger(__name__)
@@ -779,7 +780,7 @@ class JsonMigrationModal(QObject):
for tag in self.sql_lib.tags:
tag_id = tag.id # Tag IDs start at 0
sql_color = (tag.color_namespace, tag.color_slug)
json_color = json_to_sql_color(self.json_lib.get_tag(tag_id).color)
json_color = default_color_groups.json_to_sql_color(self.json_lib.get_tag(tag_id).color)
logger.info(
"[Color Parity]",

View File

@@ -10,9 +10,9 @@ from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QStandardItem, QStandardItemModel
from PySide6.QtWidgets import QHBoxLayout, QLabel, QListView, QPushButton, QVBoxLayout, QWidget
from tagstudio.core.utils.dupe_files import DupeRegistry
from tagstudio.core.library.alchemy.registries.dupe_files_registry import DupeFilesRegistry
from tagstudio.qt.mixed.progress_bar import ProgressWidget
from tagstudio.qt.translations import Translations
from tagstudio.qt.widgets.progress import ProgressWidget
# Only import for type checking/autocompletion, will not be imported at runtime.
if typing.TYPE_CHECKING:
@@ -22,7 +22,7 @@ if typing.TYPE_CHECKING:
class MirrorEntriesModal(QWidget):
done = Signal()
def __init__(self, driver: "QtDriver", tracker: DupeRegistry):
def __init__(self, driver: "QtDriver", tracker: DupeFilesRegistry):
super().__init__()
self.driver = driver
self.setWindowTitle(Translations["entries.mirror.window_title"])

View File

@@ -13,8 +13,8 @@ from PySide6.QtGui import QIntValidator, QPixmap
from PySide6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QSizePolicy, QWidget
from tagstudio.qt.helpers.color_overlay import theme_fg_overlay
from tagstudio.qt.helpers.qbutton_wrapper import QPushButtonWrapper
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.views.qbutton_wrapper import QPushButtonWrapper
class Pagination(QWidget, QObject):

View File

@@ -8,8 +8,8 @@ from collections.abc import Callable
from PySide6.QtCore import Qt, QThreadPool
from PySide6.QtWidgets import QProgressDialog, QVBoxLayout, QWidget
from tagstudio.qt.helpers.custom_runnable import CustomRunnable
from tagstudio.qt.helpers.function_iterator import FunctionIterator
from tagstudio.qt.utils.custom_runnable import CustomRunnable
from tagstudio.qt.utils.function_iterator import FunctionIterator
class ProgressWidget(QWidget):

View File

@@ -5,9 +5,9 @@
from PySide6.QtCore import QObject, Signal
from tagstudio.core.utils.unlinked_registry import UnlinkedRegistry
from tagstudio.core.library.alchemy.registries.unlinked_registry import UnlinkedRegistry
from tagstudio.qt.mixed.progress_bar import ProgressWidget
from tagstudio.qt.translations import Translations
from tagstudio.qt.widgets.progress import ProgressWidget
class RelinkUnlinkedEntries(QObject):

View File

@@ -17,10 +17,10 @@ from PySide6.QtWidgets import (
QWidget,
)
from tagstudio.core.utils.ignored_registry import IgnoredRegistry
from tagstudio.qt.helpers.custom_runnable import CustomRunnable
from tagstudio.core.library.alchemy.registries.ignored_registry import IgnoredRegistry
from tagstudio.qt.mixed.progress_bar import ProgressWidget
from tagstudio.qt.translations import Translations
from tagstudio.qt.widgets.progress import ProgressWidget
from tagstudio.qt.utils.custom_runnable import CustomRunnable
# Only import for type checking/autocompletion, will not be imported at runtime.
if TYPE_CHECKING:

View File

@@ -17,10 +17,10 @@ from PySide6.QtWidgets import (
QWidget,
)
from tagstudio.core.utils.unlinked_registry import UnlinkedRegistry
from tagstudio.qt.helpers.custom_runnable import CustomRunnable
from tagstudio.core.library.alchemy.registries.unlinked_registry import UnlinkedRegistry
from tagstudio.qt.mixed.progress_bar import ProgressWidget
from tagstudio.qt.translations import Translations
from tagstudio.qt.widgets.progress import ProgressWidget
from tagstudio.qt.utils.custom_runnable import CustomRunnable
# Only import for type checking/autocompletion, will not be imported at runtime.
if TYPE_CHECKING:

View File

@@ -21,14 +21,14 @@ from PySide6.QtWidgets import (
)
from tagstudio.core.enums import ShowFilepathOption, TagClickActionOption
from tagstudio.core.global_settings import (
from tagstudio.qt.global_settings import (
DEFAULT_THUMB_CACHE_SIZE,
MIN_THUMB_CACHE_SIZE,
Splash,
Theme,
)
from tagstudio.qt.translations import DEFAULT_TRANSLATION, LANGUAGES, Translations
from tagstudio.qt.widgets.panel import PanelModal, PanelWidget
from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget
if TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver

View File

@@ -10,14 +10,14 @@ from PySide6.QtCore import QEvent, Qt, Signal
from PySide6.QtGui import QAction, QColor, QEnterEvent
from PySide6.QtWidgets import QHBoxLayout, QPushButton, QVBoxLayout, QWidget
from tagstudio.core.library.alchemy.library import TagColorGroup
from tagstudio.core.library.alchemy.models import TagColorGroup
from tagstudio.qt.helpers.escape_text import escape_text
from tagstudio.qt.translations import Translations
from tagstudio.qt.widgets.tag import (
from tagstudio.qt.mixed.tag_widget import (
get_border_color,
get_highlight_color,
get_text_color,
)
from tagstudio.qt.translations import Translations
logger = structlog.get_logger(__name__)

View File

@@ -23,11 +23,11 @@ from PySide6.QtWidgets import (
from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX
from tagstudio.core.enums import Theme
from tagstudio.qt.modals.build_namespace import BuildNamespacePanel
from tagstudio.qt.mixed.build_namespace import BuildNamespacePanel
from tagstudio.qt.mixed.color_box import ColorBoxWidget
from tagstudio.qt.mixed.field_widget import FieldContainer
from tagstudio.qt.translations import Translations
from tagstudio.qt.widgets.color_box import ColorBoxWidget
from tagstudio.qt.widgets.fields import FieldContainer
from tagstudio.qt.widgets.panel import PanelModal
from tagstudio.qt.views.panel_modal import PanelModal
logger = structlog.get_logger(__name__)

View File

@@ -11,14 +11,10 @@ from PySide6.QtGui import QColor
from PySide6.QtWidgets import QPushButton, QVBoxLayout, QWidget
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.core.library.alchemy.library import TagColorGroup
from tagstudio.core.palette import ColorType, get_tag_color
from tagstudio.core.library.alchemy.models import TagColorGroup
from tagstudio.qt.mixed.tag_widget import get_border_color, get_highlight_color, get_text_color
from tagstudio.qt.models.palette import ColorType, get_tag_color
from tagstudio.qt.translations import Translations
from tagstudio.qt.widgets.tag import (
get_border_color,
get_highlight_color,
get_text_color,
)
if typing.TYPE_CHECKING:
from tagstudio.core.library.alchemy.library import Library

View File

@@ -19,16 +19,13 @@ from PySide6.QtWidgets import (
)
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.core.library.alchemy.library import Library, TagColorGroup
from tagstudio.core.palette import ColorType, get_tag_color
from tagstudio.qt.flowlayout import FlowLayout
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import TagColorGroup
from tagstudio.qt.mixed.tag_widget import get_border_color, get_highlight_color, get_text_color
from tagstudio.qt.models.palette import ColorType, get_tag_color
from tagstudio.qt.translations import Translations
from tagstudio.qt.widgets.panel import PanelWidget
from tagstudio.qt.widgets.tag import (
get_border_color,
get_highlight_color,
get_text_color,
)
from tagstudio.qt.views.layouts.flow_layout import FlowLayout
from tagstudio.qt.views.panel_modal import PanelWidget
logger = structlog.get_logger(__name__)

View File

@@ -7,11 +7,12 @@ import structlog
from PySide6.QtWidgets import QMessageBox, QPushButton
from tagstudio.core.constants import RESERVED_TAG_END, RESERVED_TAG_START
from tagstudio.core.library.alchemy.library import Library, Tag
from tagstudio.qt.modals.build_tag import BuildTagPanel
from tagstudio.qt.modals.tag_search import TagSearchPanel
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Tag
from tagstudio.qt.mixed.build_tag import BuildTagPanel
from tagstudio.qt.mixed.tag_search import TagSearchPanel
from tagstudio.qt.translations import Translations
from tagstudio.qt.widgets.panel import PanelModal
from tagstudio.qt.views.panel_modal import PanelModal
logger = structlog.get_logger(__name__)
@@ -70,5 +71,5 @@ class TagDatabasePanel(TagSearchPanel):
if result != QMessageBox.Ok: # type: ignore
return
self.lib.remove_tag(tag)
self.lib.remove_tag(tag.id)
self.update_tags()

View File

@@ -25,11 +25,12 @@ from PySide6.QtWidgets import (
from tagstudio.core.constants import RESERVED_TAG_END, RESERVED_TAG_START
from tagstudio.core.library.alchemy.enums import BrowsingState, TagColorEnum
from tagstudio.core.library.alchemy.library import Library, Tag
from tagstudio.core.palette import ColorType, get_tag_color
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Tag
from tagstudio.qt.mixed.tag_widget import TagWidget
from tagstudio.qt.models.palette import ColorType, get_tag_color
from tagstudio.qt.translations import Translations
from tagstudio.qt.widgets.panel import PanelModal, PanelWidget
from tagstudio.qt.widgets.tag import TagWidget
from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget
logger = structlog.get_logger(__name__)
@@ -196,7 +197,8 @@ class TagSearchPanel(PanelWidget):
self.search_field.setFocus()
self.update_tags()
from tagstudio.qt.modals.build_tag import BuildTagPanel # here due to circular imports
# TODO: Move this to a top-level import
from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports
self.build_tag_modal: BuildTagPanel = BuildTagPanel(self.lib)
self.add_tag_modal: PanelModal = PanelModal(
@@ -374,7 +376,8 @@ class TagSearchPanel(PanelWidget):
pass
def edit_tag(self, tag: Tag):
from tagstudio.qt.modals.build_tag import BuildTagPanel
# TODO: Move this to a top-level import
from tagstudio.qt.mixed.build_tag import BuildTagPanel
def callback(btp: BuildTagPanel):
self.lib.update_tag(

View File

@@ -12,9 +12,9 @@ from PySide6.QtGui import QAction, QColor, QEnterEvent, QFontMetrics
from PySide6.QtWidgets import QHBoxLayout, QLineEdit, QPushButton, QVBoxLayout, QWidget
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.core.library.alchemy.library import Tag
from tagstudio.core.palette import ColorType, get_tag_color
from tagstudio.core.library.alchemy.models import Tag
from tagstudio.qt.helpers.escape_text import escape_text
from tagstudio.qt.models.palette import ColorType, get_tag_color
from tagstudio.qt.translations import Translations
logger = structlog.get_logger(__name__)

View File

@@ -8,7 +8,7 @@ import re
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QHBoxLayout, QLabel
from tagstudio.qt.widgets.fields import FieldWidget
from tagstudio.qt.mixed.field_widget import FieldWidget
class TextWidget(FieldWidget):

View File

@@ -17,7 +17,7 @@ def remove_mnemonic_marker(label: str) -> str:
if ch == "&":
# escaped ampersand "&&"
if i + 1 < len(label) and label[i + 1] == "&":
result += "&"
result += "&&"
skip = True
# otherwise skip this '&'
continue

View File

@@ -7,16 +7,20 @@ import contextlib
import hashlib
import math
import os
import tarfile
import xml.etree.ElementTree as ET
import zipfile
from copy import deepcopy
from io import BytesIO
from pathlib import Path
from typing import TYPE_CHECKING, cast
from typing import TYPE_CHECKING, Literal, cast
from warnings import catch_warnings
from xml.etree.ElementTree import Element
import cv2
import numpy as np
import pillow_avif # noqa: F401 # pyright: ignore[reportUnusedImport]
import rarfile
import rawpy
import srctools
import structlog
@@ -57,16 +61,17 @@ from tagstudio.core.constants import (
from tagstudio.core.exceptions import NoRendererError
from tagstudio.core.library.ignore import Ignore
from tagstudio.core.media_types import MediaCategories, MediaType
from tagstudio.core.palette import UI_COLORS, ColorType, UiColor, get_ui_color
from tagstudio.core.utils.encoding import detect_char_encoding
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.helpers.blender_thumbnailer import blend_thumb
from tagstudio.qt.global_settings import DEFAULT_CACHED_IMAGE_RES
from tagstudio.qt.helpers.color_overlay import theme_fg_overlay
from tagstudio.qt.helpers.file_tester import is_readable_video
from tagstudio.qt.helpers.gradient import four_corner_gradient
from tagstudio.qt.helpers.gradients import four_corner_gradient
from tagstudio.qt.helpers.image_effects import replace_transparent_pixels
from tagstudio.qt.helpers.text_wrapper import wrap_full_text
from tagstudio.qt.helpers.vendored.pydub.audio_segment import (
from tagstudio.qt.models.palette import UI_COLORS, ColorType, UiColor, get_ui_color
from tagstudio.qt.previews.vendored.blender_renderer import blend_thumb
from tagstudio.qt.previews.vendored.pydub.audio_segment import (
_AudioSegment as AudioSegment,
)
from tagstudio.qt.resource_manager import ResourceManager
@@ -88,15 +93,30 @@ except ImportError:
logger.exception('[ThumbRenderer] Could not import the "pillow_jxl" module')
class _TarFile(tarfile.TarFile):
"""Wrapper around tarfile.TarFile to mimic zipfile.ZipFile's API."""
def __init__(self, filepath: Path, mode: Literal["r"]) -> None:
super().__init__(filepath, mode)
def namelist(self) -> list[str]:
return self.getnames()
def read(self, name: str) -> bytes:
return self.extractfile(name).read()
type _Archive_T = type[zipfile.ZipFile] | type[rarfile.RarFile] | type[_TarFile]
type _Archive = zipfile.ZipFile | rarfile.RarFile | _TarFile
class ThumbRenderer(QObject):
"""A class for rendering image and file thumbnails."""
rm: ResourceManager = ResourceManager()
updated = Signal(float, QPixmap, QSize, Path)
updated_ratio = Signal(float)
cached_img_res: int = 256 # TODO: Pull this from config
cached_img_ext: str = ".webp" # TODO: Pull this from config
cached_img_ext: str = ".webp"
def __init__(self, driver: "QtDriver", library: "Library") -> None:
"""Initialize the class."""
@@ -104,6 +124,13 @@ class ThumbRenderer(QObject):
self.driver = driver
self.lib = library
settings_res = self.driver.settings.cached_thumb_resolution
self.cached_img_res = (
settings_res
if settings_res >= 16 and settings_res <= 2048
else DEFAULT_CACHED_IMAGE_RES
)
# Cached thumbnail elements.
# Key: Size + Pixel Ratio Tuple + Radius Scale
# (Ex. (512, 512, 1.25, 4))
@@ -849,29 +876,73 @@ class ThumbRenderer(QObject):
return im
@staticmethod
def _epub_cover(filepath: Path) -> Image.Image:
"""Extracts and returns the first image found in the ePub file at the given filepath.
def _epub_cover(filepath: Path, ext: str) -> Image.Image | None:
"""Extracts the cover specified by ComicInfo.xml or first image found in the ePub file.
Args:
filepath (Path): The path to the ePub file.
ext (str): The file extension.
Returns:
Image: The first image found in the ePub file, or None by default.
Image: The cover specified in ComicInfo.xml,
the first image found in the ePub file, or None by default.
"""
im: Image.Image = None
im: Image.Image | None = None
try:
with zipfile.ZipFile(filepath, "r") as zip_file:
for file_name in zip_file.namelist():
if file_name.lower().endswith(
(".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg")
):
image_data = zip_file.read(file_name)
im = Image.open(BytesIO(image_data))
archiver: _Archive_T = zipfile.ZipFile
if ext == ".cbr":
archiver = rarfile.RarFile
elif ext == ".cbt":
archiver = _TarFile
with archiver(filepath, "r") as archive:
if "ComicInfo.xml" in archive.namelist():
comic_info = ET.fromstring(archive.read("ComicInfo.xml"))
im = ThumbRenderer.__cover_from_comic_info(archive, comic_info, "FrontCover")
if not im:
im = ThumbRenderer.__cover_from_comic_info(
archive, comic_info, "InnerCover"
)
if not im:
for file_name in archive.namelist():
if file_name.lower().endswith(
(".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg")
):
image_data = archive.read(file_name)
im = Image.open(BytesIO(image_data))
break
except Exception as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
@staticmethod
def __cover_from_comic_info(
archive: _Archive, comic_info: Element, cover_type: str
) -> Image.Image | None:
"""Extract the cover specified in ComicInfo.xml.
Args:
archive (_Archive): The current ePub file.
comic_info (Element): The parsed ComicInfo.xml.
cover_type (str): The type of cover to load.
Returns:
Image: The cover specified in ComicInfo.xml.
"""
im: Image.Image | None = None
cover = comic_info.find(f"./*Page[@Type='{cover_type}']")
if cover is not None:
pages = [f for f in archive.namelist() if f != "ComicInfo.xml"]
page_name = pages[int(cover.get("Image"))]
if page_name.endswith((".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg")):
image_data = archive.read(page_name)
im = Image.open(BytesIO(image_data))
return im
def _font_short_thumb(self, filepath: Path, size: int) -> Image.Image:
"""Render a small font preview ("Aa") thumbnail from a font file.
@@ -1404,7 +1475,7 @@ class ThumbRenderer(QObject):
image = self._render(
timestamp,
filepath,
(ThumbRenderer.cached_img_res, ThumbRenderer.cached_img_res),
(self.cached_img_res, self.cached_img_res),
1,
is_grid_thumb,
save_to_file=file_name,
@@ -1528,7 +1599,7 @@ class ThumbRenderer(QObject):
if MediaCategories.is_ext_in_category(
ext, MediaCategories.EBOOK_TYPES, mime_fallback=True
):
image = self._epub_cover(_filepath)
image = self._epub_cover(_filepath, ext)
# Krita ========================================================
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.KRITA_TYPES, mime_fallback=True

View File

@@ -12,7 +12,7 @@ from shutil import which
import ffmpeg
import structlog
from tagstudio.qt.helpers.silent_popen import silent_Popen, silent_run
from tagstudio.core.utils.silent_subprocess import silent_popen, silent_run
logger = structlog.get_logger(__name__)
@@ -69,7 +69,7 @@ def probe(filename, cmd=FFPROBE_CMD, timeout=None, **kwargs):
args += [filename]
# PATCHED
p = silent_Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
p = silent_popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
communicate_kwargs = {}
if timeout is not None:
communicate_kwargs["timeout"] = timeout

View File

@@ -18,7 +18,7 @@ from tempfile import NamedTemporaryFile
from pydub.logging_utils import log_conversion, log_subprocess_output
from pydub.utils import fsdecode
from tagstudio.qt.helpers.vendored.ffmpeg import FFMPEG_CMD
from tagstudio.qt.previews.vendored.ffmpeg import FFMPEG_CMD
try:
from itertools import izip
@@ -42,8 +42,8 @@ from pydub.utils import (
ratio_to_db,
)
from tagstudio.qt.helpers.silent_popen import silent_Popen
from tagstudio.qt.helpers.vendored.pydub.utils import _mediainfo_json
from tagstudio.core.utils.silent_subprocess import silent_popen
from tagstudio.qt.previews.vendored.pydub.utils import _mediainfo_json
basestring = str
xrange = range
@@ -608,7 +608,7 @@ class _AudioSegment:
with open(os.devnull, "rb") as devnull:
# PATCHED
p = silent_Popen(
p = silent_popen(
conversion_command, stdin=devnull, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
p_out, p_err = p.communicate()
@@ -781,7 +781,7 @@ class _AudioSegment:
log_conversion(conversion_command)
# PATCHED
p = silent_Popen(
p = silent_popen(
conversion_command,
stdin=stdin_parameter,
stdout=subprocess.PIPE,
@@ -1008,7 +1008,7 @@ class _AudioSegment:
# read stdin / write stdout
with open(os.devnull, "rb") as devnull:
# PATCHED
p = silent_Popen(
p = silent_popen(
conversion_command, stdin=devnull, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
p_out, p_err = p.communicate()

View File

@@ -8,8 +8,8 @@ from pydub.utils import (
get_extra_info,
)
from tagstudio.qt.helpers.silent_popen import silent_Popen
from tagstudio.qt.helpers.vendored.ffmpeg import FFPROBE_CMD
from tagstudio.core.utils.silent_subprocess import silent_popen
from tagstudio.qt.previews.vendored.ffmpeg import FFPROBE_CMD
def _mediainfo_json(filepath, read_ahead_limit=-1):
@@ -39,7 +39,7 @@ def _mediainfo_json(filepath, read_ahead_limit=-1):
command = [prober, "-of", "json"] + command_args
# PATCHED
res = silent_Popen(
res = silent_popen(
command, stdin=stdin_parameter, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
output, stderr = res.communicate(input=stdin_data)

View File

@@ -49,57 +49,59 @@ import tagstudio.qt.resources_rc # noqa: F401
from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE, VERSION, VERSION_BRANCH
from tagstudio.core.driver import DriverMixin
from tagstudio.core.enums import MacroID, SettingItems, ShowFilepathOption
from tagstudio.core.global_settings import (
DEFAULT_GLOBAL_SETTINGS_PATH,
GlobalSettings,
Theme,
)
from tagstudio.core.library.alchemy.enums import (
BrowsingState,
FieldTypeEnum,
ItemType,
SortingModeEnum,
)
from tagstudio.core.library.alchemy.library import Entry, FieldID, Library, LibraryStatus
from tagstudio.core.library.alchemy.fields import FieldID
from tagstudio.core.library.alchemy.library import Library, LibraryStatus
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.library.ignore import Ignore
from tagstudio.core.library.refresh import RefreshTracker
from tagstudio.core.media_types import MediaCategories
from tagstudio.core.palette import ColorType, UiColor, get_ui_color
from tagstudio.core.query_lang.util import ParsingError
from tagstudio.core.ts_core import TagStudioCore
from tagstudio.core.utils.refresh_dir import RefreshDirTracker
from tagstudio.core.utils.str_formatting import strip_web_protocol
from tagstudio.core.utils.types import unwrap
from tagstudio.core.utils.web import strip_web_protocol
from tagstudio.qt.cache_manager import CacheManager
from tagstudio.qt.controllers.ffmpeg_missing_message_box import FfmpegMissingMessageBox
# this import has side-effect of import PySide resources
from tagstudio.qt.controller.fix_ignored_modal_controller import FixIgnoredEntriesModal
from tagstudio.qt.controller.widgets.ignore_modal_controller import IgnoreModal
from tagstudio.qt.controller.widgets.library_info_window_controller import LibraryInfoWindow
from tagstudio.qt.helpers.custom_runnable import CustomRunnable
from tagstudio.qt.helpers.file_deleter import delete_file
from tagstudio.qt.helpers.function_iterator import FunctionIterator
from tagstudio.qt.helpers.vendored.ffmpeg import FFMPEG_CMD, FFPROBE_CMD
from tagstudio.qt.main_window import MainWindow
from tagstudio.qt.modals.about import AboutModal
from tagstudio.qt.modals.build_tag import BuildTagPanel
from tagstudio.qt.modals.drop_import import DropImportModal
from tagstudio.qt.modals.ffmpeg_checker import FfmpegChecker
from tagstudio.qt.modals.fix_dupes import FixDupeFilesModal
from tagstudio.qt.modals.fix_unlinked import FixUnlinkedEntriesModal
from tagstudio.qt.modals.folders_to_tags import FoldersToTagsModal
from tagstudio.qt.modals.settings_panel import SettingsPanel
from tagstudio.qt.modals.tag_color_manager import TagColorManager
from tagstudio.qt.modals.tag_database import TagDatabasePanel
from tagstudio.qt.modals.tag_search import TagSearchModal
from tagstudio.qt.controllers.fix_ignored_modal_controller import FixIgnoredEntriesModal
from tagstudio.qt.controllers.ignore_modal_controller import IgnoreModal
from tagstudio.qt.controllers.library_info_window_controller import LibraryInfoWindow
from tagstudio.qt.global_settings import (
DEFAULT_GLOBAL_SETTINGS_PATH,
GlobalSettings,
Theme,
)
from tagstudio.qt.mixed.about_modal import AboutModal
from tagstudio.qt.mixed.build_tag import BuildTagPanel
from tagstudio.qt.mixed.drop_import_modal import DropImportModal
from tagstudio.qt.mixed.fix_dupe_files import FixDupeFilesModal
from tagstudio.qt.mixed.fix_unlinked import FixUnlinkedEntriesModal
from tagstudio.qt.mixed.folders_to_tags import FoldersToTagsModal
from tagstudio.qt.mixed.item_thumb import BadgeType, ItemThumb
from tagstudio.qt.mixed.migration_modal import JsonMigrationModal
from tagstudio.qt.mixed.progress_bar import ProgressWidget
from tagstudio.qt.mixed.settings_panel import SettingsPanel
from tagstudio.qt.mixed.tag_color_manager import TagColorManager
from tagstudio.qt.mixed.tag_database import TagDatabasePanel
from tagstudio.qt.mixed.tag_search import TagSearchModal
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
from tagstudio.qt.platform_strings import trash_term
from tagstudio.qt.previews.renderer import ThumbRenderer
from tagstudio.qt.previews.vendored.ffmpeg import FFMPEG_CMD, FFPROBE_CMD
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.splash import SplashScreen
from tagstudio.qt.translations import Translations
from tagstudio.qt.widgets.item_thumb import BadgeType, ItemThumb
from tagstudio.qt.widgets.migration_modal import JsonMigrationModal
from tagstudio.qt.widgets.panel import PanelModal
from tagstudio.qt.widgets.progress import ProgressWidget
from tagstudio.qt.widgets.thumb_renderer import ThumbRenderer
from tagstudio.qt.utils.custom_runnable import CustomRunnable
from tagstudio.qt.utils.file_deleter import delete_file
from tagstudio.qt.utils.function_iterator import FunctionIterator
from tagstudio.qt.views.main_window import MainWindow
from tagstudio.qt.views.panel_modal import PanelModal
from tagstudio.qt.views.splash import SplashScreen
BADGE_TAGS = {
BadgeType.FAVORITE: TAG_FAVORITE,
@@ -588,7 +590,7 @@ class QtDriver(DriverMixin, QObject):
# Check if FFmpeg or FFprobe are missing and show warning if so
if not which(FFMPEG_CMD) or not which(FFPROBE_CMD):
FfmpegChecker().show()
FfmpegMissingMessageBox().show()
self.app.exec()
self.shutdown()
@@ -1003,7 +1005,7 @@ class QtDriver(DriverMixin, QObject):
def add_new_files_callback(self):
"""Run when user initiates adding new files to the Library."""
tracker = RefreshDirTracker(self.lib)
tracker = RefreshTracker(self.lib)
pw = ProgressWidget(
cancel_button_text=None,
@@ -1041,7 +1043,7 @@ class QtDriver(DriverMixin, QObject):
)
QThreadPool.globalInstance().start(r)
def add_new_files_runnable(self, tracker: RefreshDirTracker):
def add_new_files_runnable(self, tracker: RefreshTracker):
"""Adds any known new files to the library and run default macros on them.
Threaded method.
@@ -1695,9 +1697,14 @@ class QtDriver(DriverMixin, QObject):
open_status = LibraryStatus(
success=False, library_path=path, message=type(e).__name__, msg_description=str(e)
)
self.cache_manager = CacheManager(path, max_size=self.settings.thumb_cache_size)
self.cache_manager = CacheManager(
path,
max_size=self.settings.thumb_cache_size,
img_quality=self.settings.cached_thumb_quality,
)
cache_size = self.settings.thumb_cache_size * self.cache_manager.STAT_MULTIPLIER
logger.info(
f"[Config] Thumbnail Cache Size: {format_size(self.settings.thumb_cache_size)}",
f"[Config] Thumbnail Cache Size: {format_size(cache_size)}",
)
# Migration is required

View File

@@ -14,8 +14,8 @@ from PySide6.QtCore import Qt
from PySide6.QtGui import QMouseEvent
from PySide6.QtWidgets import QLabel, QWidget
from tagstudio.core.utils.silent_subprocess import silent_popen # pyright: ignore
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.helpers.silent_popen import silent_Popen
logger = structlog.get_logger(__name__)
@@ -45,7 +45,7 @@ def open_file(path: str | Path, file_manager: bool = False, windows_start_comman
# For some reason, if the args are passed in a list, this will error when the
# path has spaces, even while surrounded in double quotes.
silent_Popen(
silent_popen(
command_name + command_arg,
shell=True,
close_fds=True,
@@ -66,7 +66,7 @@ def open_file(path: str | Path, file_manager: bool = False, windows_start_comman
)
else:
command = f'"{normpath}"'
silent_Popen(
silent_popen(
command,
shell=True,
close_fds=True,
@@ -98,7 +98,7 @@ def open_file(path: str | Path, file_manager: bool = False, windows_start_comman
command_args = [str(path)]
command = shutil.which(command_name)
if command is not None:
silent_Popen([command] + command_args, close_fds=True)
silent_popen([command] + command_args, close_fds=True)
else:
logger.info("Could not find command on system PATH", command=command_name)
except Exception:

View File

@@ -3,17 +3,21 @@
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from typing import override
from PySide6.QtCore import Signal
from PySide6.QtGui import QMouseEvent
from PySide6.QtWidgets import QLabel
class ClickableLabel(QLabel):
"""A clickable Label widget."""
"""A clickable QLabel widget."""
clicked = Signal()
def __init__(self):
super().__init__()
def mousePressEvent(self, event): # noqa: N802
@override
def mousePressEvent(self, ev: QMouseEvent):
self.clicked.emit()

View File

@@ -11,8 +11,8 @@ from PySide6.QtWidgets import QSlider, QStyle, QStyleOptionSlider
logger = structlog.get_logger(__name__)
class QClickSlider(QSlider):
"""Custom QSlider wrapper.
class ClickableSlider(QSlider):
"""A clickable QSlider wrapper.
The purpose of this wrapper is to allow us to set slider positions
based on click events.

View File

@@ -5,7 +5,7 @@
from PySide6.QtWidgets import QPlainTextEdit, QVBoxLayout
from tagstudio.qt.widgets.panel import PanelWidget
from tagstudio.qt.views.panel_modal import PanelWidget
class EditTextBox(PanelWidget):

View File

@@ -5,7 +5,7 @@ from collections.abc import Callable
from PySide6.QtWidgets import QLineEdit, QVBoxLayout
from tagstudio.qt.widgets.panel import PanelWidget
from tagstudio.qt.views.panel_modal import PanelWidget
class EditTextLine(PanelWidget):

View File

@@ -13,9 +13,10 @@ from PySide6.QtWidgets import (
)
from tagstudio.core.constants import IGNORE_NAME
from tagstudio.core.library.alchemy.library import Library, Tag
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Tag
from tagstudio.qt.translations import Translations
from tagstudio.qt.widgets.panel import PanelWidget
from tagstudio.qt.views.panel_modal import PanelWidget
logger = structlog.get_logger(__name__)

View File

@@ -35,15 +35,15 @@ from PySide6.QtWidgets import (
from tagstudio.core.enums import ShowFilepathOption
from tagstudio.core.library.alchemy.enums import SortingModeEnum
from tagstudio.qt.controller.widgets.preview_panel_controller import PreviewPanel
from tagstudio.qt.flowlayout import FlowLayout
from tagstudio.qt.controllers.preview_panel_controller import PreviewPanel
from tagstudio.qt.helpers.color_overlay import theme_fg_overlay
from tagstudio.qt.mixed.landing import LandingWidget
from tagstudio.qt.mixed.pagination import Pagination
from tagstudio.qt.mnemonics import assign_mnemonics
from tagstudio.qt.pagination import Pagination
from tagstudio.qt.platform_strings import trash_term
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.translations import Translations
from tagstudio.qt.widgets.landing import LandingWidget
from tagstudio.qt.views.layouts.flow_layout import FlowLayout
# Only import for type checking/autocompletion, will not be imported at runtime.
if typing.TYPE_CHECKING:

View File

@@ -16,13 +16,14 @@ from PySide6.QtWidgets import (
)
from tagstudio.core.enums import Theme
from tagstudio.core.library.alchemy.library import Entry, Library
from tagstudio.core.palette import ColorType, UiColor, get_ui_color
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.controller.widgets.preview.preview_thumb_controller import PreviewThumb
from tagstudio.qt.controllers.preview_thumb_controller import PreviewThumb
from tagstudio.qt.mixed.field_containers import FieldContainers
from tagstudio.qt.mixed.file_attributes import FileAttributeData, FileAttributes
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
from tagstudio.qt.translations import Translations
from tagstudio.qt.widgets.preview.field_containers import FieldContainers
from tagstudio.qt.widgets.preview.file_attributes import FileAttributeData, FileAttributes
if typing.TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver

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