mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2026-07-09 07:30:30 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed9850e60f |
+43
-61
@@ -18,9 +18,9 @@ Legacy (JSON) library save format versions were tied to the release version of t
|
||||
|
||||
### Versions 1.0.0 - 9.4.2
|
||||
|
||||
| Used in Releases | Format | Location |
|
||||
| ---------------- | ------ | --------------------------------------------- |
|
||||
| v1.0.0 - 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.
|
||||
|
||||
@@ -30,11 +30,7 @@ Replaced by the new SQLite format introduced in TagStudio [v9.5.0 Pre-Release 1]
|
||||
|
||||
## SQLite <small>v9.5.0+</small>
|
||||
|
||||
Starting with TagStudio [v9.5.0-pr1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr1), the library save format has been moved to the [SQLite](https://sqlite.org) format. Legacy JSON libraries are migrated (with the user's consent) to the new format when opening in current versions of the program. The save format versioning is now separate from the program's versioning number.
|
||||
|
||||
The database storage location of all SQLite versions to date is at:
|
||||
|
||||
`<Library Folder>`/.TagStudio/ts_library.sqlite
|
||||
Starting with TagStudio [v9.5.0-pr1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr1), the library save format has been moved to a [SQLite](https://sqlite.org) format. Legacy JSON libraries are migrated (with the user's consent) to the new format when opening in current versions of the program. The save format versioning is now separate from the program's versioning number.
|
||||
|
||||
### Versioning
|
||||
|
||||
@@ -42,12 +38,12 @@ Versions **1-100** stored the database version in a table called `preferences` i
|
||||
|
||||
Versions **>101** store the database version in a table called `versions` in a row with the `key` column of `'CURRENT'` inside the corresponding `value` column. The `versions` table also stores the initial database version in which the file was created with under the `'INITIAL'` key. Databases created before this key was introduced will always have `'INITIAL'` value of `100`.
|
||||
|
||||
#### `versions` Table
|
||||
#### "versions" Table
|
||||
|
||||
| key (`VARCHAR`) | value (`INTEGER`) |
|
||||
| --------------- | ------------------------------------------ |
|
||||
| `'INITIAL'` | Version DB was created with, minimum `100` |
|
||||
| `'CURRENT'` | Current version of DB |
|
||||
| key (`VARCHAR`) | value (`INTEGER`) |
|
||||
| --------------- | --------------------------------------------- |
|
||||
| `'INITIAL'` | <Version DB was created with, minimum `100`\> |
|
||||
| `'CURRENT'` | <Current version of DB\> |
|
||||
|
||||
#### Major and Minor Versioning
|
||||
|
||||
@@ -55,23 +51,19 @@ Version **100** came along with a major/minor versioning system built into to th
|
||||
|
||||
For example, a database with version 204 would still be readable in an older version of TagStudio that understands version 200. A database with version 300, on the other hand, would no longer be readable in that same older version and an error message would display.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! note ""Version 0" Message"
|
||||
If you see an message when opening a library along the lines of **"Found Version 0"**, this means that you're opening a library created with a newer version of TagStudio in an older version of TagStudio that does not recognize the future versioning system. To open your library, you should use a TagStudio version greater than or equal to the one the library was created or last used with.
|
||||
|
||||
---
|
||||
|
||||
### Versions 1 - 5
|
||||
|
||||
These versions were used while developing the new SQLite file format, outside of any official or recommended release. These versions **were never supported** in any official capacity and were actively warned against using for real libraries.
|
||||
These versions were used while developing the new SQLite file format, outside any official or recommended release. These versions **were never supported** in any official capacity and were actively warned against using for real libraries.
|
||||
|
||||
---
|
||||
|
||||
### Version 6
|
||||
|
||||
| Added in Commit | Introduced in Release | Format |
|
||||
| ---------------------------------------- | ------------------------------------------------------------------------------- | ------ |
|
||||
| d1b006a8978a7fa1b7f1a82243e490aca8a8355e | [v9.5.0-pr1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr1) | 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.
|
||||
|
||||
@@ -81,9 +73,9 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
|
||||
|
||||
### Version 7
|
||||
|
||||
| Added in Commit | Introduced in Release | Format |
|
||||
| ---------------------------------------- | ------------------------------------------------------------------------------- | ------ |
|
||||
| 480328b83bc1c69ab52a3eb11e2935337d3460ab | [v9.5.0-pr2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr2) | 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.~~ _See [Version 200](#version-200)_
|
||||
- Repairs tags that may have a disambiguation_id pointing towards a deleted tag.
|
||||
@@ -92,9 +84,9 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
|
||||
|
||||
### Version 8
|
||||
|
||||
| Added in Commit | Introduced in Release | Format |
|
||||
| ---------------------------------------- | ------------------------------------------------------------------------------- | ------ |
|
||||
| 28de21ade757aa5b80e87f77a459f4a3af21ffe0 | [v9.5.0-pr4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr4) | 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](colors.md#secondary-color) to apply to a tag's border as a new optional behavior.
|
||||
- Adds three new default colors: "Burgundy (TagStudio Shades)", "Dark Teal (TagStudio Shades)", and "Dark Lavender (TagStudio Shades)".
|
||||
@@ -104,9 +96,9 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
|
||||
|
||||
### Version 9
|
||||
|
||||
| Added in Commit | Introduced in Release | Format |
|
||||
| ---------------------------------------- | ----------------------------------------------------------------------- | ------ |
|
||||
| 31833245a4d7a655dc4e66e6005a9dd78c36c04d | [v9.5.2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.2) | 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.
|
||||
|
||||
@@ -116,9 +108,9 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
|
||||
|
||||
#### Version 100
|
||||
|
||||
| Added in Commit | Introduced in Release | Format |
|
||||
| ---------------------------------------- | --------------------- | ------ |
|
||||
| 74383e3c3c12f72be1481ab0b86c7360b95c2d85 | _None_ | SQLite |
|
||||
| Used From | Format | Location |
|
||||
| ---------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| 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.
|
||||
@@ -127,9 +119,9 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
|
||||
|
||||
#### Version 101
|
||||
|
||||
| Added in Commit | Introduced in Release | Format |
|
||||
| ---------------------------------------- | ----------------------------------------------------------------------- | ------ |
|
||||
| 12e074b71d8860282b44e49e0e1a41b7a2e4bae8 | [v9.5.4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.4) | SQLite |
|
||||
| Used From | Format | Location |
|
||||
| ---------------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| 12e074b71d8860282b44e49e0e1a41b7a2e4bae8/[v9.5.4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.4) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
|
||||
- Deprecates the `preferences` table, set to be removed in a [future](#version-104) TagStudio version.
|
||||
- Introduces the `versions` table
|
||||
@@ -141,26 +133,24 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
|
||||
|
||||
#### Version 102
|
||||
|
||||
| Added in Commit | Introduced in Release | Format |
|
||||
| ---------------------------------------- | ----------------------------------------------------------------------- | ------ |
|
||||
| 71d04254cf87f4200bb7ffc81656e50dfb122e4d | [v9.5.5](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.5) | SQLite |
|
||||
| Used From | Format | Location |
|
||||
| ---------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| 71d04254cf87f4200bb7ffc81656e50dfb122e4d | 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.
|
||||
|
||||
#### Version 103
|
||||
|
||||
| Added in Commit | Introduced in Release | Format |
|
||||
| ---------------------------------------- | ----------------------------------------------------------------------- | ------ |
|
||||
| 88d0b47a86821ccfadba653f30a515abce5b24b0 | [v9.5.7](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.7) | SQLite |
|
||||
| Used From | Format | Location |
|
||||
| ---------------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| 88d0b47a86821ccfadba653f30a515abce5b24b0/[v9.5.7](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.7) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
|
||||
- Adds the `is_hidden` column to the `tags` table (default `0`). Used for excluding entries tagged with hidden tags from library searches.
|
||||
- Sets the `is_hidden` field on the built-in Archived tag to `1`, to match the Archived tag now being hidden by default.
|
||||
|
||||
#### Version 104
|
||||
|
||||
| Added in Commit | Introduced in Release | Format |
|
||||
| ---------------------------------------- | --------------------- | ------ |
|
||||
| ad2cbbca483018d245b44348e2c4f5a0e0bb28f1 | _None_ | SQLite |
|
||||
| Used From | Format | Location |
|
||||
| ---------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| ad2cbbca483018d245b44348e2c4f5a0e0bb28f1 | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
|
||||
- Removes the `preferences` table, after migrating the contained extension list to the .ts_ignore file, if necessary.
|
||||
|
||||
@@ -170,9 +160,9 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
|
||||
|
||||
#### Version 200
|
||||
|
||||
| Added in Commit | Introduced in Release | Format |
|
||||
| ---------------------------------------- | --------------------- | ------ |
|
||||
| c15e2b56eedd0a3c13391fa43571b8f8f7c7a91f | _None_ | SQLite |
|
||||
| Used From | Format | Location |
|
||||
| ---------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| c15e2b56eedd0a3c13391fa43571b8f8f7c7a91f | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
|
||||
- Adds `text_field_templates` and `date_field_templates` tables.
|
||||
- Drops `boolean_fields` and `value_type` tables.
|
||||
@@ -187,17 +177,9 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
|
||||
|
||||
#### Version 201
|
||||
|
||||
| Added in Commit | Introduced in Release | Format |
|
||||
| ---------------------------------------- | ----------------------------------------------------------------------- | ------ |
|
||||
| 38da7bb3a920a01d4d70fa065fd19c83ff6eecb1 | [v9.6.0](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.6.0) | SQLite |
|
||||
| Used From | Format | Location |
|
||||
| ---------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| 38da7bb3a920a01d4d70fa065fd19c83ff6eecb1 | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
|
||||
- Drops `type_key` columns from `text_fields` and `datetime_fields` tables.
|
||||
- Enforces column positions for `text_fields` and `datetime_fields` tables.
|
||||
|
||||
#### Version 202
|
||||
|
||||
| Added in Commit | Introduced in Release | Format |
|
||||
| --------------- | ----------------------------------------------------------------------- | ------ |
|
||||
| | [v9.6.1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.6.1) | SQLite |
|
||||
|
||||
- Applies repairs to the `tag_parents` table, removing rows that reference child tags that have been deleted.
|
||||
|
||||
+4
-3
@@ -122,6 +122,7 @@ A detailed specification written for the TagStudio tag and/or library format. In
|
||||
- [ ] Tag Search
|
||||
- [ ] Pinned Tags
|
||||
- [ ] New Tabbed Tag Building UI to Support New Tag Features :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.8.x]**
|
||||
- [ ] Custom Thumbnail Overrides :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.7.x]**
|
||||
- [ ] Media Duration Labels :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
|
||||
- [ ] Word/Line Count Labels :material-chevron-up:{ .priority-low title="Low Priority" }
|
||||
- [ ] Custom Tag Badges :material-chevron-up:{ .priority-low title="Low Priority" }
|
||||
@@ -175,12 +176,12 @@ File or file-like [entries](entries.md) stored in the library.
|
||||
- [ ] Optional Units (e.g. inches, cm, height notation, degrees, bytes, etc.) :material-chevron-double-up:{ .priority-med title="Medium Priority" }
|
||||
- [x] Custom Field Names **[[v9.6.0](changelog.md#960-june-29th-2026)]**
|
||||
- [x] Removal of Deprecated Fields **[[v9.6.0](changelog.md#960-june-29th-2026)]**
|
||||
- [ ] Custom Thumbnail Overrides :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.7.x]**
|
||||
- [ ] Entry Groups :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
|
||||
- [ ] Non-exclusive; Entries can be in multiple groups :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Ability to number entries within group (i.e. page numbers) :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Ability to number entries within group :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Ability to set sorting method for group :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Ability to set custom thumbnail for group :material-chevron-double-up:{ .priority-med title="Medium Priority" }
|
||||
- [ ] Ability to set custom thumbnail for group :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Group is treated as entry with tags and metadata :material-chevron-double-up:{ .priority-med title="Medium Priority" }
|
||||
- [ ] Nested groups :material-chevron-double-up:{ .priority-med title="Medium Priority" }
|
||||
|
||||
### :material-tag-text: Tags
|
||||
|
||||
+1
-1
@@ -1,6 +1,7 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
{
|
||||
lib,
|
||||
pkgs,
|
||||
@@ -77,7 +78,6 @@ pkgs.mkShellNoCC {
|
||||
coreutils
|
||||
uv
|
||||
|
||||
pyright
|
||||
ruff
|
||||
];
|
||||
buildInputs = [
|
||||
|
||||
+2
-3
@@ -9,7 +9,7 @@ build-backend = "hatchling.build"
|
||||
[project]
|
||||
name = "TagStudio"
|
||||
description = "A User-Focused Photo & File Management System."
|
||||
version = "9.6.1"
|
||||
version = "9.6.0"
|
||||
license = "GPL-3.0-only"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12,<3.14"
|
||||
@@ -30,7 +30,7 @@ dependencies = [
|
||||
"PySide6==6.8.0.*",
|
||||
"rarfile==4.2",
|
||||
"rawpy~=0.27",
|
||||
"Send2Trash>=1.8,<3",
|
||||
"Send2Trash~=1.8",
|
||||
"SQLAlchemy~=2.0",
|
||||
"srctools~=2.6",
|
||||
"structlog~=25.3",
|
||||
@@ -69,7 +69,6 @@ qt_api = "pyside6"
|
||||
pythonpath = ["src"]
|
||||
filterwarnings = [
|
||||
"ignore:DISTINCT ON is currently supported only by the PostgreSQL dialect:sqlalchemy.exc.SADeprecationWarning",
|
||||
'ignore:Invoking or_\(\) without arguments is deprecated:sqlalchemy.exc.SADeprecationWarning',
|
||||
]
|
||||
|
||||
[tool.pyright]
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from tagstudio.main import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,9 +1,8 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
from importlib.metadata import version
|
||||
|
||||
VERSION: str = version("tagstudio") # Major.Minor.Patch
|
||||
VERSION: str = "9.6.0" # Major.Minor.Patch
|
||||
VERSION_BRANCH: str = "" # Usually "" or "Pre-Release"
|
||||
COPYRIGHT_YEARS: str = "2021-2026"
|
||||
COPYRIGHT: str = f"© {COPYRIGHT_YEARS} Travis Abendshien & TagStudio Contributors"
|
||||
|
||||
@@ -9,7 +9,7 @@ JSON_FILENAME: str = "ts_library.json"
|
||||
|
||||
DB_VERSION_CURRENT_KEY: str = "CURRENT"
|
||||
DB_VERSION_INITIAL_KEY: str = "INITIAL"
|
||||
DB_VERSION: int = 202
|
||||
DB_VERSION: int = 201
|
||||
|
||||
TAG_CHILDREN_QUERY = text("""
|
||||
WITH RECURSIVE ChildTags AS (
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
# pyright: reportDeprecated=false
|
||||
|
||||
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
@@ -93,6 +94,7 @@ from tagstudio.core.library.alchemy.fields import (
|
||||
TextFieldTemplate,
|
||||
)
|
||||
from tagstudio.core.library.alchemy.joins import TagEntry, TagParent
|
||||
from tagstudio.core.library.alchemy.metadata import FileMetadata
|
||||
from tagstudio.core.library.alchemy.models import (
|
||||
Entry,
|
||||
Folder,
|
||||
@@ -105,6 +107,7 @@ from tagstudio.core.library.alchemy.models import (
|
||||
from tagstudio.core.library.alchemy.visitors import SQLBoolExpressionBuilder
|
||||
from tagstudio.core.library.ignore import migrate_ext_list
|
||||
from tagstudio.core.library.json.library import Library as JsonLibrary
|
||||
from tagstudio.core.utils.stat import get_date_created, get_date_modified
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.translations import Translations
|
||||
|
||||
@@ -571,16 +574,12 @@ class Library:
|
||||
self.__apply_db103_migration(session)
|
||||
if loaded_db_version < 104:
|
||||
# changes: deletes preferences
|
||||
self.__apply_db104_migration(session, library_dir)
|
||||
self.__apply_db104_migrations(session, library_dir)
|
||||
if loaded_db_version < 200:
|
||||
self.__apply_db200_migrations(session)
|
||||
# changes: field tables
|
||||
self.__apply_db200_migration(session)
|
||||
if initial_db_version < 200 and loaded_db_version < 201:
|
||||
# changes: field tables
|
||||
self.__apply_db201_migration(session)
|
||||
if loaded_db_version < 202:
|
||||
# changes: tag_parents
|
||||
self.__apply_db202_migration(session)
|
||||
self.__apply_db201_migrations(session)
|
||||
|
||||
session.execute(
|
||||
text("CREATE INDEX IF NOT EXISTS idx_tags_name_shorthand ON tags (name, shorthand)")
|
||||
@@ -723,7 +722,8 @@ class Library:
|
||||
def __apply_db102_migration(self, session: Session):
|
||||
"""Migrate DB to DB_VERSION 102."""
|
||||
with session:
|
||||
stmt = delete(TagParent).where(TagParent.parent_id.not_in(select(Tag.id).distinct()))
|
||||
all_tag_ids = session.scalars(text("SELECT DISTINCT id FROM tags")).all()
|
||||
stmt = delete(TagParent).where(TagParent.parent_id.not_in(all_tag_ids))
|
||||
session.execute(stmt)
|
||||
session.commit()
|
||||
logger.info("[Library][Migration] Verified TagParent table data")
|
||||
@@ -757,7 +757,7 @@ class Library:
|
||||
)
|
||||
session.rollback()
|
||||
|
||||
def __apply_db104_migration(self, session: Session, library_dir: Path):
|
||||
def __apply_db104_migrations(self, session: Session, library_dir: Path):
|
||||
"""Migrate DB from DB_VERSION 103 to 104."""
|
||||
# Convert file extension list to ts_ignore file, if a .ts_ignore file does not exist
|
||||
self.__migrate_sql_to_ts_ignore(library_dir)
|
||||
@@ -782,7 +782,7 @@ class Library:
|
||||
with open(ts_ignore, "w") as f:
|
||||
f.write(migrate_ext_list(extensions, is_exclude_list))
|
||||
|
||||
def __apply_db200_migration(self, session: Session):
|
||||
def __apply_db200_migrations(self, session: Session):
|
||||
"""Migrate DB to DB_VERSION 200."""
|
||||
with session:
|
||||
# Drop unused 'boolean_fields' and 'value_type' tables
|
||||
@@ -869,7 +869,7 @@ class Library:
|
||||
|
||||
session.commit()
|
||||
|
||||
def __apply_db201_migration(self, session: Session):
|
||||
def __apply_db201_migrations(self, session: Session):
|
||||
"""Migrate DB to DB_VERSION 201."""
|
||||
with session:
|
||||
create_text_fields_table = text("""
|
||||
@@ -920,14 +920,6 @@ class Library:
|
||||
|
||||
session.commit()
|
||||
|
||||
def __apply_db202_migration(self, session: Session):
|
||||
"""Migrate DB to DB_VERSION 202."""
|
||||
with session:
|
||||
stmt = delete(TagParent).where(TagParent.child_id.not_in(select(Tag.id).distinct()))
|
||||
session.execute(stmt)
|
||||
session.commit()
|
||||
logger.info("[Library][Migration] Verified TagParent table data")
|
||||
|
||||
@property
|
||||
def field_templates(self) -> Sequence[BaseFieldTemplate]:
|
||||
with Session(self.engine) as session:
|
||||
@@ -946,7 +938,11 @@ class Library:
|
||||
return entry
|
||||
|
||||
def get_entry_full(
|
||||
self, entry_id: int, with_fields: bool = True, with_tags: bool = True
|
||||
self,
|
||||
entry_id: int,
|
||||
with_fields: bool = True,
|
||||
with_tags: bool = True,
|
||||
with_metadata: bool = True,
|
||||
) -> Entry | None:
|
||||
"""Load entry and join with all joins and all tags."""
|
||||
# NOTE: TODO: Currently this method makes multiple separate queries to the db and combines
|
||||
@@ -976,6 +972,11 @@ class Library:
|
||||
)
|
||||
)
|
||||
|
||||
if with_metadata:
|
||||
entry_stmt = entry_stmt.outerjoin(Entry.file_metadata).options(
|
||||
selectinload(Entry.file_metadata),
|
||||
)
|
||||
|
||||
start_time = time.time()
|
||||
entry = session.scalar(entry_stmt)
|
||||
if with_tags:
|
||||
@@ -1164,10 +1165,79 @@ class Library:
|
||||
session.query(Entry).where(Entry.id.in_(sub_list)).delete()
|
||||
session.commit()
|
||||
|
||||
def has_entry_with_path(self, path: Path) -> bool:
|
||||
"""Check if an entry with this path is in the library."""
|
||||
def get_entry_id_from_path(self, path: Path) -> int:
|
||||
"""Attempt to return an Entry ID given a filepath, else return -1."""
|
||||
with Session(self.engine) as session:
|
||||
return session.query(exists().where(Entry.path == path)).scalar()
|
||||
return session.scalar(select(Entry.id).where(Entry.path == path).limit(1)) or -1
|
||||
|
||||
# def update_entry_file_metadata(
|
||||
# self, entry_id: int, date_created: datetime | None, date_modified: datetime | None
|
||||
# ):
|
||||
# with Session(self.engine) as session:
|
||||
# stmt = update(FileMetadata).where(
|
||||
# and_(
|
||||
# FileMetadata.entry_id == entry_id,
|
||||
# )
|
||||
# )
|
||||
# if date_created:
|
||||
# stmt = stmt.values(date_created=date_created)
|
||||
# if date_modified:
|
||||
# stmt = stmt.values(date_modified=date_modified)
|
||||
|
||||
# session.execute(stmt)
|
||||
# session.commit()
|
||||
|
||||
def refresh_file_entry_stats(self, entry_id: int, path: Path | None):
|
||||
"""Updates a file entry's associated stat() data."""
|
||||
needs_update = False
|
||||
|
||||
entry = self.get_entry_full(
|
||||
entry_id, with_fields=False, with_tags=False, with_metadata=True
|
||||
)
|
||||
if not entry:
|
||||
return
|
||||
|
||||
if not path:
|
||||
full_path = unwrap(self.library_dir) / entry.path
|
||||
else:
|
||||
full_path = unwrap(self.library_dir) / path
|
||||
|
||||
logger.info(full_path)
|
||||
|
||||
file_date_created = get_date_created(full_path)
|
||||
file_date_modified = get_date_modified(full_path)
|
||||
|
||||
# Log info
|
||||
if entry.date_created != file_date_created:
|
||||
logger.info(f"Difference in date_created!: {entry.date_created}/{file_date_created}")
|
||||
needs_update = True
|
||||
else:
|
||||
logger.info("No difference in date_created.")
|
||||
|
||||
if entry.date_modified != file_date_modified:
|
||||
logger.info(f"Difference in date_modified!: {entry.date_modified}/{file_date_modified}")
|
||||
needs_update = True
|
||||
else:
|
||||
logger.info("No difference in date_modified")
|
||||
|
||||
if needs_update:
|
||||
return
|
||||
else:
|
||||
logger.info(f"Updating entry file_metadata for {full_path}")
|
||||
|
||||
with Session(self.engine) as session:
|
||||
stmt = update(FileMetadata).where(
|
||||
and_(
|
||||
FileMetadata.entry_id == entry_id,
|
||||
)
|
||||
)
|
||||
if file_date_created:
|
||||
stmt = stmt.values(date_created=file_date_created)
|
||||
if file_date_modified:
|
||||
stmt = stmt.values(date_modified=file_date_modified)
|
||||
|
||||
session.execute(stmt)
|
||||
session.commit()
|
||||
|
||||
def get_paths(self, limit: int = -1) -> list[str]:
|
||||
path_strings: list[str] = []
|
||||
@@ -1204,11 +1274,7 @@ class Library:
|
||||
ast = search.ast
|
||||
|
||||
if not search.show_hidden_entries:
|
||||
hidden_tag_ids = select(Tag.id).where(Tag.is_hidden)
|
||||
hidden_entry_ids = select(TagEntry.entry_id).where(
|
||||
TagEntry.tag_id.in_(hidden_tag_ids)
|
||||
)
|
||||
statement = statement.where(Entry.id.not_in(hidden_entry_ids))
|
||||
statement = statement.where(~Entry.tags.any(Tag.is_hidden))
|
||||
|
||||
if ast:
|
||||
start_time = time.time()
|
||||
@@ -1485,7 +1551,7 @@ class Library:
|
||||
|
||||
Returns True if the action succeeded and False if the path already exists.
|
||||
"""
|
||||
if self.has_entry_with_path(path):
|
||||
if self.get_entry_id_from_path(path) >= 0:
|
||||
return False
|
||||
if isinstance(entry_id, Entry):
|
||||
entry_id = entry_id.id
|
||||
@@ -1734,11 +1800,9 @@ class Library:
|
||||
|
||||
if parent_ids is not None:
|
||||
self.update_parent_tags(tag, parent_ids, session)
|
||||
session.flush()
|
||||
|
||||
if aliases is not None:
|
||||
self.update_aliases(tag, aliases, session)
|
||||
session.flush()
|
||||
self.update_aliases(tag, aliases)
|
||||
|
||||
session.commit()
|
||||
session.expunge(tag)
|
||||
@@ -2070,45 +2134,48 @@ class Library:
|
||||
else:
|
||||
self.add_color(new_color_group)
|
||||
|
||||
def update_aliases(self, tag: Tag, aliases: Iterable[TagAlias], session: Session) -> bool:
|
||||
def update_aliases(self, tag: Tag, aliases: Iterable[TagAlias]) -> bool:
|
||||
"""Update TagAliases for a given Tag."""
|
||||
unique_alias_names: set[str] = set()
|
||||
# Remove aliases that are no longer on the Tag
|
||||
try:
|
||||
old_aliases = session.scalars(select(TagAlias).where(TagAlias.tag_id == tag.id)).all()
|
||||
unique_alias_names = set([x.name for x in old_aliases])
|
||||
old_alias_ids: list[int] = [a.id for a in old_aliases]
|
||||
for old_alias in old_aliases:
|
||||
if old_alias.id not in [a.id for a in aliases] or not old_alias.name:
|
||||
logger.warning(
|
||||
"[Library] Deleting removed alias", id=old_alias.id, name=old_alias.name
|
||||
)
|
||||
session.delete(old_alias)
|
||||
session.commit()
|
||||
except IntegrityError as e:
|
||||
session.rollback()
|
||||
logger.error("[Library] Could not update aliases", error=e)
|
||||
return False
|
||||
|
||||
# Update or Add aliases
|
||||
for alias in aliases:
|
||||
# Sanitize alias names
|
||||
alias.name = alias.name.strip()
|
||||
if not alias.name or alias.name in unique_alias_names:
|
||||
continue
|
||||
|
||||
with Session(self.engine) as session:
|
||||
# Remove aliases that are no longer on the Tag
|
||||
try:
|
||||
if alias.id in old_alias_ids:
|
||||
stmt = update(TagAlias).where(TagAlias.id == alias.id).values(name=alias.name)
|
||||
session.execute(stmt)
|
||||
else:
|
||||
session.add(alias)
|
||||
old_aliases = session.scalars(
|
||||
select(TagAlias).where(TagAlias.tag_id == tag.id)
|
||||
).all()
|
||||
old_alias_ids: list[int] = [a.id for a in old_aliases]
|
||||
for old_alias in old_aliases:
|
||||
if old_alias.id not in [a.id for a in aliases] or not old_alias.name:
|
||||
logger.warning(
|
||||
"[Library] Deleting removed alias", id=old_alias.id, name=old_alias.name
|
||||
)
|
||||
session.delete(old_alias)
|
||||
session.commit()
|
||||
except IntegrityError as e:
|
||||
session.rollback()
|
||||
logger.error("[Library] Could not update or add alias", error=e)
|
||||
logger.error("[Library] Could not update aliases", error=e)
|
||||
return False
|
||||
|
||||
session.commit()
|
||||
# Update or Add aliases
|
||||
for alias in aliases:
|
||||
# Sanitize alias names
|
||||
alias.name = alias.name.strip()
|
||||
if not alias.name:
|
||||
continue
|
||||
|
||||
try:
|
||||
if alias.id in old_alias_ids:
|
||||
stmt = (
|
||||
update(TagAlias).where(TagAlias.id == alias.id).values(name=alias.name)
|
||||
)
|
||||
session.execute(stmt)
|
||||
else:
|
||||
session.add(alias)
|
||||
except IntegrityError as e:
|
||||
session.rollback()
|
||||
logger.error("[Library] Could not update or add alias", error=e)
|
||||
return False
|
||||
|
||||
session.commit()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime as dt
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, override
|
||||
|
||||
from sqlalchemy import ForeignKey, ForeignKeyConstraint, Integer, null
|
||||
from sqlalchemy.orm import Mapped, declared_attr, mapped_column, relationship
|
||||
|
||||
|
||||
from tagstudio.core.library.alchemy.db import Base, PathType
|
||||
|
||||
from tagstudio.core.library.alchemy.joins import TagParent
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tagstudio.core.library.alchemy.models import Entry
|
||||
|
||||
|
||||
class FileMetadata(Base):
|
||||
"""Table that includes file data and metadata obtained from os.stat() for entries."""
|
||||
|
||||
__tablename__ = "file_metadata"
|
||||
|
||||
entry_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("entries.id"), primary_key=True, nullable=False
|
||||
)
|
||||
|
||||
# NOTE: These dates are stored as floats because that's their natural form from os.stat()
|
||||
# and comparisons are quicker without having to convert to/from datetime objects.
|
||||
date_created: Mapped[float | None]
|
||||
date_modified: Mapped[float | None]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
entry_id: int,
|
||||
date_created: float | None = None,
|
||||
date_modified: float | None = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.entry_id = entry_id
|
||||
|
||||
# # Path data
|
||||
# self.path = path
|
||||
# self.filename = path.name
|
||||
# self.suffix = path.suffix.lstrip(".").lower()
|
||||
|
||||
# File metadata
|
||||
self.date_created = date_created # st_birthtime on Windows and Mac, st_ctime on Linux
|
||||
self.date_modified = date_modified # st_mtime
|
||||
|
||||
|
||||
class ExifMetadata(Base):
|
||||
"""Contains Exif metadata for a entries."""
|
||||
|
||||
__tablename__ = "exif_metadata"
|
||||
|
||||
entry_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("entries.id"), primary_key=True, nullable=False
|
||||
)
|
||||
date_taken: Mapped[dt | None]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
entry_id: int,
|
||||
date_taken: dt | None = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.entry_id = entry_id
|
||||
self.date_taken = date_taken # Exif.Image.DateTime
|
||||
|
||||
|
||||
class DimensionMetadata(Base):
|
||||
"""Contains dimension metadata for entries (e.g. image and video files)."""
|
||||
|
||||
__tablename__ = "dimension_metadata"
|
||||
|
||||
entry_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("entries.id"), primary_key=True, nullable=False
|
||||
)
|
||||
width: Mapped[int] = mapped_column(nullable=False)
|
||||
height: Mapped[int] = mapped_column(nullable=False)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
entry_id: int,
|
||||
width: int,
|
||||
height: int,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.entry_id = entry_id
|
||||
self.width = width
|
||||
self.height = height
|
||||
|
||||
|
||||
class DurationMetadata(Base):
|
||||
"""Contains duration metadata for entries (e.g. audio and video files)."""
|
||||
|
||||
__tablename__ = "duration_metadata"
|
||||
|
||||
entry_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("entries.id"), primary_key=True, nullable=False
|
||||
)
|
||||
duration: Mapped[float] = mapped_column(nullable=False)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
entry_id: int,
|
||||
duration: float,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.entry_id = entry_id
|
||||
self.duration = duration
|
||||
@@ -17,6 +17,8 @@ from tagstudio.core.library.alchemy.fields import (
|
||||
TextField,
|
||||
)
|
||||
from tagstudio.core.library.alchemy.joins import TagParent
|
||||
from tagstudio.core.library.alchemy.metadata import FileMetadata
|
||||
from tagstudio.core.utils.stat import get_date_created, get_date_modified
|
||||
|
||||
|
||||
class Namespace(Base):
|
||||
@@ -182,6 +184,7 @@ class Tag(Base):
|
||||
return self.name >= other.name
|
||||
|
||||
|
||||
# TODO: Use or replace these with an actual multi-root implementation
|
||||
class Folder(Base):
|
||||
__tablename__ = "folders"
|
||||
|
||||
@@ -196,15 +199,16 @@ class Entry(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
|
||||
# TODO: Use or replace these with an actual multi-root implementation
|
||||
folder_id: Mapped[int] = mapped_column(ForeignKey("folders.id"))
|
||||
folder: Mapped[Folder] = relationship("Folder")
|
||||
|
||||
# TODO: Possibly move to FileMetadata table if Entry is split into Entry/FileEntry (see #588)
|
||||
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]
|
||||
|
||||
date_added: Mapped[dt | None] # The date this entry was added to the library
|
||||
|
||||
tags: Mapped[set[Tag]] = relationship(secondary="tag_entries")
|
||||
|
||||
@@ -217,6 +221,11 @@ class Entry(Base):
|
||||
cascade="all, delete",
|
||||
)
|
||||
|
||||
file_metadata: Mapped["FileMetadata"] = relationship(
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
@property
|
||||
def fields(self) -> list[BaseField]:
|
||||
fields: list[BaseField] = []
|
||||
@@ -232,30 +241,35 @@ class Entry(Base):
|
||||
def is_archived(self) -> bool:
|
||||
return any(tag.id == TAG_ARCHIVED for tag in self.tags)
|
||||
|
||||
@property
|
||||
def date_created(self) -> float | None:
|
||||
return self.file_metadata.date_created if self.file_metadata else None
|
||||
|
||||
@property
|
||||
def date_modified(self) -> float | None:
|
||||
return self.file_metadata.date_modified if self.file_metadata else None
|
||||
|
||||
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,
|
||||
# date_created: float | None = None,
|
||||
# date_modified: float | None = None,
|
||||
path_for_file_metadata: Path | None = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.path = path
|
||||
self.folder = folder
|
||||
|
||||
self.id = id # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
||||
self.folder = folder # NOTE: Currently unused
|
||||
self.path = path
|
||||
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
|
||||
self.date_added = date_added # The date this entry was added to the library
|
||||
|
||||
for field in fields:
|
||||
if isinstance(field, TextField):
|
||||
@@ -265,6 +279,13 @@ class Entry(Base):
|
||||
else:
|
||||
raise ValueError(f"Invalid field type: {field}")
|
||||
|
||||
if path_for_file_metadata:
|
||||
self.file_metadata = FileMetadata(
|
||||
entry_id=self.id,
|
||||
date_created=get_date_created(path_for_file_metadata),
|
||||
date_modified=get_date_modified(path_for_file_metadata),
|
||||
)
|
||||
|
||||
def has_tag(self, tag: Tag) -> bool:
|
||||
return tag in self.tags
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from dataclasses import dataclass, field
|
||||
from datetime import datetime as dt
|
||||
from pathlib import Path
|
||||
from time import time
|
||||
import platform
|
||||
|
||||
import structlog
|
||||
from wcmatch import pathlib
|
||||
@@ -38,12 +39,14 @@ class RefreshTracker:
|
||||
while index < len(self.files_not_in_library):
|
||||
yield index
|
||||
end = min(len(self.files_not_in_library), index + batch_size)
|
||||
lib_dir = unwrap(self.library.library_dir)
|
||||
entries = [
|
||||
Entry(
|
||||
path=entry_path,
|
||||
folder=unwrap(self.library.folder),
|
||||
fields=[],
|
||||
date_added=dt.now(),
|
||||
path_for_file_metadata=(lib_dir / entry_path),
|
||||
)
|
||||
for entry_path in self.files_not_in_library[index:end]
|
||||
]
|
||||
@@ -144,8 +147,11 @@ class RefreshTracker:
|
||||
dir_file_count += 1
|
||||
self.library.included_files.add(f)
|
||||
|
||||
if not self.library.has_entry_with_path(f):
|
||||
entry_id = self.library.get_entry_id_from_path(f)
|
||||
if entry_id < 0:
|
||||
self.files_not_in_library.append(f)
|
||||
else:
|
||||
self.library.refresh_file_entry_stats(entry_id, path=f)
|
||||
|
||||
end_time_total = time()
|
||||
yield dir_file_count
|
||||
@@ -189,8 +195,12 @@ class RefreshTracker:
|
||||
|
||||
relative_path = f.relative_to(library_dir)
|
||||
|
||||
if not self.library.has_entry_with_path(relative_path):
|
||||
entry_id = self.library.get_entry_id_from_path(relative_path)
|
||||
if entry_id < 0:
|
||||
self.files_not_in_library.append(relative_path)
|
||||
else:
|
||||
self.library.refresh_file_entry_stats(entry_id, path=relative_path)
|
||||
|
||||
except ValueError:
|
||||
logger.info("[Refresh]: ValueError when refreshing directory with wcmatch!")
|
||||
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
import contextlib
|
||||
from typing import override
|
||||
|
||||
import structlog
|
||||
|
||||
from tagstudio.core.utils.module_status import ModuleStatus
|
||||
from tagstudio.core.utils.silent_subprocess import (
|
||||
silent_run, # pyright: ignore[reportUnknownVariableType]
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class _FfModuleStatus(ModuleStatus):
|
||||
"""A base class that implements common logic for reading FFmpeg/FFprobe version output."""
|
||||
|
||||
_FFMPEG = "ffmpeg"
|
||||
_FFPROBE = "ffprobe"
|
||||
|
||||
@classmethod
|
||||
def ff_version(cls, command: str):
|
||||
ff_cmd = cls._which(command)
|
||||
if ff_cmd:
|
||||
out = silent_run([ff_cmd, "-version"], shell=False, capture_output=True, text=True)
|
||||
if out.returncode == 0:
|
||||
with contextlib.suppress(Exception):
|
||||
return str(out.stdout).split(" ")[2]
|
||||
|
||||
|
||||
class FfmpegStatus(_FfModuleStatus):
|
||||
"""Class for getting the location and version of FFmpeg, if it exists."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def which(cls):
|
||||
return cls._which(cls._FFMPEG)
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def _version(cls):
|
||||
return cls.ff_version(cls._FFMPEG)
|
||||
|
||||
|
||||
class FfprobeStatus(_FfModuleStatus):
|
||||
"""Class for getting the location and version of FFprobe, if it exists."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def which(cls):
|
||||
return cls._which(cls._FFPROBE)
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def _version(cls):
|
||||
return cls.ff_version(cls._FFPROBE)
|
||||
@@ -1,75 +0,0 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
import os
|
||||
import platform
|
||||
from shutil import which
|
||||
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
user = os.environ.get("USER", None)
|
||||
|
||||
# NOTE: macOS does not make its PATH variable available to processes started outside the terminal.
|
||||
# The following is a list of common directories to search for binaries in.
|
||||
# TODO: Use a library for XDG compliance
|
||||
_MACOS_BIN_LOCATIONS: list[str] = [
|
||||
"", # Equates to PATH itself
|
||||
# User level
|
||||
"~/.local/share/bin/", # XDG-compliant user-created bin
|
||||
"~/.local/bin/", # Fallback user-created bin
|
||||
"~/.local/state/nix/profile/bin/", # XDG-compliant home Nix bin
|
||||
"~/.nix-profile/bin/", # Fallback home Nix bin
|
||||
# System level
|
||||
f"/etc/profiles/per-user/{user}/bin/", # Per-user Nix bin
|
||||
"/nix/var/nix/profiles/default/bin/", # Inherited Nix bin
|
||||
"/opt/homebrew/bin/", # Homebrew bin
|
||||
"/usr/local/bin/", # Administrator-configured bin
|
||||
"/usr/bin/", # System bin
|
||||
"/bin/", # Core system bin
|
||||
]
|
||||
|
||||
|
||||
class ModuleStatus:
|
||||
"""An abstract base class for module status logic including the binary location and version."""
|
||||
|
||||
__cached_location: str | None = None
|
||||
__cached_version: str | None = None
|
||||
|
||||
@classmethod
|
||||
def which(cls) -> str | None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@classmethod
|
||||
def _cache_location(cls) -> str | None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@classmethod
|
||||
def version(cls) -> str | None:
|
||||
if cls.__cached_version is None:
|
||||
cls.__cached_version = cls._version()
|
||||
return cls.__cached_version
|
||||
|
||||
@classmethod
|
||||
def _version(cls) -> str | None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@classmethod
|
||||
def _which(cls, cmd: str) -> str | None:
|
||||
"""Internal method for determining the correct location for which().
|
||||
|
||||
Args:
|
||||
cmd (str): The process command to search for.
|
||||
"""
|
||||
if cls.__cached_location:
|
||||
return cls.__cached_location
|
||||
|
||||
if platform.system() == "Darwin":
|
||||
for loc in _MACOS_BIN_LOCATIONS:
|
||||
full_command = which(loc + cmd)
|
||||
if full_command:
|
||||
cls.__cached_location = full_command
|
||||
return full_command
|
||||
|
||||
cls.__cached_location = which(cmd)
|
||||
return cls.__cached_location
|
||||
@@ -1,34 +0,0 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
import contextlib
|
||||
from typing import override
|
||||
|
||||
import structlog
|
||||
|
||||
from tagstudio.core.utils.module_status import ModuleStatus
|
||||
from tagstudio.core.utils.silent_subprocess import (
|
||||
silent_run, # pyright: ignore[reportUnknownVariableType]
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class RipgrepStatus(ModuleStatus):
|
||||
"""Class for getting the location and version of ripgrep, if it exists."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def which(cls):
|
||||
return cls._which("rg")
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def _version(cls):
|
||||
ripgrep_cmd = cls.which()
|
||||
if ripgrep_cmd:
|
||||
out = silent_run([ripgrep_cmd, "-V"], shell=False, capture_output=True, text=True)
|
||||
if out.returncode == 0:
|
||||
with contextlib.suppress(Exception):
|
||||
return str(out.stdout).split(" ")[1].rstrip("\n")
|
||||
@@ -0,0 +1,16 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
import platform
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_date_modified(path: Path) -> float:
|
||||
return path.stat().st_mtime
|
||||
|
||||
|
||||
def get_date_created(path: Path) -> float:
|
||||
if platform.system() in {"Windows", "Darwin"}:
|
||||
return path.stat().st_birthtime
|
||||
else:
|
||||
return path.stat().st_ctime
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env python3
|
||||
#!/usr/bin/env python
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
|
||||
|
||||
import typing
|
||||
from shutil import which
|
||||
from warnings import catch_warnings
|
||||
|
||||
from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus
|
||||
from tagstudio.qt.controllers.field_template_search_panel_controller import FieldTemplateSearchModal
|
||||
from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchModal
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.previews.vendored.ffmpeg import FFMPEG_CMD, FFPROBE_CMD
|
||||
from tagstudio.qt.views.preview_panel_view import PreviewPanelView
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
@@ -22,10 +22,7 @@ class PreviewPanel(PreviewPanelView):
|
||||
super().__init__(library, driver)
|
||||
|
||||
self.__add_field_modal = FieldTemplateSearchModal(self.lib, is_field_template_chooser=True)
|
||||
self.__add_tag_modal = TagSearchModal(
|
||||
self.lib, title=Translations["tag.add.plural"], is_tag_chooser=True
|
||||
)
|
||||
self.__add_tag_modal.tsp.set_driver(driver)
|
||||
self.__add_tag_modal = TagSearchModal(self.lib, is_tag_chooser=True)
|
||||
self._thumb.check_ffmpeg.connect(self._toggle_ffmpeg_warning)
|
||||
|
||||
@typing.override
|
||||
@@ -58,7 +55,7 @@ class PreviewPanel(PreviewPanelView):
|
||||
self._containers.update_from_entry(self._selected[0])
|
||||
|
||||
def _toggle_ffmpeg_warning(self, enable_warning: bool = True) -> None:
|
||||
if enable_warning and (not FfmpegStatus.which() or not FfprobeStatus.which()):
|
||||
if enable_warning and (not which(FFMPEG_CMD) or not which(FFPROBE_CMD)):
|
||||
self._ffmpeg_warning_widget.show()
|
||||
return
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from typing import override
|
||||
from typing import TYPE_CHECKING, override
|
||||
from warnings import catch_warnings
|
||||
|
||||
import structlog
|
||||
@@ -20,6 +20,10 @@ from tagstudio.qt.views.tag_search_panel_view import TagSearchPanelView
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
class TagSearchModal(PanelModal):
|
||||
tsp: "TagSearchPanel"
|
||||
@@ -27,7 +31,6 @@ class TagSearchModal(PanelModal):
|
||||
def __init__(
|
||||
self,
|
||||
library: Library,
|
||||
title: str,
|
||||
exclude: list[int] | None = None,
|
||||
is_tag_chooser: bool = True,
|
||||
has_save: bool = False,
|
||||
@@ -39,8 +42,8 @@ class TagSearchModal(PanelModal):
|
||||
view=TagSearchPanelView(is_tag_chooser),
|
||||
)
|
||||
super().__init__(
|
||||
widget=self.tsp,
|
||||
title=title,
|
||||
self.tsp,
|
||||
Translations["tag.add.plural"],
|
||||
is_savable=has_save,
|
||||
)
|
||||
|
||||
@@ -166,25 +169,17 @@ class TagSearchPanel(SearchPanel[Tag]):
|
||||
# Connect callbacks
|
||||
tag_widget.on_edit.connect(lambda edit_tag=item: self.on_item_edit(edit_tag))
|
||||
tag_widget.on_remove.connect(lambda remove_tag=item: self._on_item_remove(remove_tag))
|
||||
if self.is_chooser:
|
||||
tag_widget.bg_button.clicked.connect(
|
||||
lambda checked=False, tag=item: self._on_item_chosen(tag)
|
||||
)
|
||||
else:
|
||||
tag_widget.bg_button.clicked.connect(
|
||||
lambda checked=False, edit_tag=item: self.on_item_edit(edit_tag)
|
||||
)
|
||||
tag_widget.bg_button.clicked.connect(
|
||||
lambda checked=False, tag=item: self._on_item_chosen(tag)
|
||||
)
|
||||
|
||||
# Connect search action
|
||||
if self._driver is not None:
|
||||
tag_widget.search_for_tag_action.triggered.connect(
|
||||
lambda checked=False, tag_id=item.id: self.search_for_tag(tag_id)
|
||||
lambda tag_id=item.id: self.search_for_tag(tag_id)
|
||||
)
|
||||
tag_widget.search_for_tag_action.setEnabled(True)
|
||||
else:
|
||||
logger.warning(
|
||||
"[TagSearchPanel] No driver was set for this TagSearchPanel. Was this on purpose?"
|
||||
)
|
||||
tag_widget.search_for_tag_action.setEnabled(False)
|
||||
|
||||
@override
|
||||
|
||||
@@ -6,7 +6,9 @@ from pathlib import Path
|
||||
|
||||
import ffmpeg
|
||||
|
||||
from tagstudio.qt.previews.vendored.probe import probe
|
||||
from tagstudio.qt.previews.vendored.ffmpeg import (
|
||||
probe, # pyright: ignore[reportUnknownVariableType]
|
||||
)
|
||||
|
||||
|
||||
def is_readable_video(filepath: Path | str):
|
||||
@@ -19,8 +21,6 @@ def is_readable_video(filepath: Path | str):
|
||||
"""
|
||||
try:
|
||||
result = probe(Path(filepath))
|
||||
if not result:
|
||||
return False
|
||||
for stream in result["streams"]:
|
||||
# DRM check
|
||||
if stream.get("codec_tag_string") in [
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
from shutil import which
|
||||
|
||||
from PIL import ImageQt
|
||||
from PySide6.QtCore import QSize, Qt
|
||||
@@ -27,16 +28,15 @@ from tagstudio.core.constants import (
|
||||
VERSION_BRANCH,
|
||||
)
|
||||
from tagstudio.core.ts_core import TagStudioCore
|
||||
from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus
|
||||
from tagstudio.core.utils.ripgrep_status import RipgrepStatus
|
||||
from tagstudio.core.utils.str_formatting import is_version_outdated
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.controllers.clickable_label import ClickableLabel
|
||||
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
|
||||
from tagstudio.qt.utils.file_opener import open_file
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import form_content_style, header
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import form_content_style
|
||||
|
||||
|
||||
class AboutModal(QWidget):
|
||||
@@ -66,10 +66,6 @@ class AboutModal(QWidget):
|
||||
self.content_layout.setContentsMargins(12, 12, 12, 12)
|
||||
self.content_layout.setSpacing(12)
|
||||
|
||||
red = get_ui_color(ColorType.PRIMARY, UiColor.RED)
|
||||
green = get_ui_color(ColorType.PRIMARY, UiColor.GREEN)
|
||||
amber = get_ui_color(ColorType.PRIMARY, UiColor.AMBER)
|
||||
|
||||
# TagStudio Logo -------------------------------------------------------
|
||||
self.logo_widget = QLabel()
|
||||
self.logo_pixmap = QPixmap.fromImage(ImageQt.ImageQt(self.rm.ts_logo_text_color))
|
||||
@@ -82,7 +78,7 @@ class AboutModal(QWidget):
|
||||
self.logo_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
# Version --------------------------------------------------------------
|
||||
self.version_label = QLabel(header(AboutModal.VERSION_STR, 3))
|
||||
self.version_label = QLabel(f"<h3>{AboutModal.VERSION_STR}</h3>")
|
||||
self.version_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
# Copyright ------------------------------------------------------------
|
||||
@@ -98,10 +94,31 @@ class AboutModal(QWidget):
|
||||
self.desc_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
|
||||
# System Info ----------------------------------------------------------
|
||||
ff_version = ffmpeg.version()
|
||||
red = get_ui_color(ColorType.PRIMARY, UiColor.RED)
|
||||
green = get_ui_color(ColorType.PRIMARY, UiColor.GREEN)
|
||||
amber = get_ui_color(ColorType.PRIMARY, UiColor.AMBER)
|
||||
missing = Translations["generic.missing"]
|
||||
found = Translations["about.module.found"]
|
||||
|
||||
ffmpeg_status = f'<span style="color:{red}">{missing}</span>'
|
||||
if ff_version["ffmpeg"] is not None:
|
||||
ffmpeg_status = (
|
||||
f'<span style="color:{green}">{found}</span> (' + ff_version["ffmpeg"] + ")"
|
||||
)
|
||||
|
||||
ffprobe_status = f'<span style="color:{red}">{missing}</span>'
|
||||
if ff_version["ffprobe"] is not None:
|
||||
ffprobe_status = (
|
||||
f'<span style="color:{green}">{found}</span> (' + ff_version["ffprobe"] + ")"
|
||||
)
|
||||
|
||||
ripgrep_status = f'<span style="color:{amber}">{missing}</span>'
|
||||
if which("rg") is not None:
|
||||
ripgrep_status = f'<span style="color:{green}">{found}</span>'
|
||||
|
||||
self.system_info_widget = QWidget()
|
||||
self.system_info_layout = QFormLayout(self.system_info_widget)
|
||||
self.system_info_layout.setSpacing(4)
|
||||
self.system_info_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
|
||||
# Version
|
||||
@@ -132,85 +149,46 @@ class AboutModal(QWidget):
|
||||
|
||||
# TODO: Add row for "App Cache Path" (currently that TagStudio.ini file)
|
||||
|
||||
# Optional Modules -----------------------------------------------------
|
||||
|
||||
self.parent_optional_modules_widget = QWidget()
|
||||
self.parent_optional_modules_layout = QVBoxLayout(self.parent_optional_modules_widget)
|
||||
self.parent_optional_modules_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.parent_optional_modules_layout.setSpacing(0)
|
||||
|
||||
# Subtitle
|
||||
self.optional_modules_label = QLabel(header(Translations["about.modules.title"], 4))
|
||||
|
||||
self.optional_modules_widget = QWidget()
|
||||
self.optional_modules_layout = QFormLayout(self.optional_modules_widget)
|
||||
self.optional_modules_layout.setSpacing(4)
|
||||
self.optional_modules_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
|
||||
ffmpeg_ver = FfmpegStatus.version()
|
||||
ffprobe_ver = FfprobeStatus.version()
|
||||
ripgrep_ver = RipgrepStatus.version()
|
||||
|
||||
missing = Translations["generic.missing"]
|
||||
found = Translations["about.module.found"]
|
||||
|
||||
ffmpeg_status = f'<span style="color:{red}">{missing}</span>'
|
||||
if ffmpeg_ver is not None:
|
||||
ffmpeg_status = f'<span style="color:{green}">{found}</span> (' + ffmpeg_ver + ")"
|
||||
|
||||
ffprobe_status = f'<span style="color:{red}">{missing}</span>'
|
||||
if ffprobe_ver is not None:
|
||||
ffprobe_status = f'<span style="color:{green}">{found}</span> (' + ffprobe_ver + ")"
|
||||
|
||||
ripgrep_status = f'<span style="color:{amber}">{missing}</span>'
|
||||
if ripgrep_ver is not None:
|
||||
ripgrep_status = f'<span style="color:{green}">{found}</span> (' + ripgrep_ver + ")"
|
||||
|
||||
# FFmpeg Status
|
||||
ffmpeg_path_title = QLabel("FFmpeg")
|
||||
ffmpeg_path_content = ClickableLabel(f"{ffmpeg_status}")
|
||||
ffmpeg_location = FfmpegStatus.which()
|
||||
ffmpeg_location = which(ffmpeg._get_ffmpeg_location()) # pyright: ignore[reportPrivateUsage]
|
||||
if ffmpeg_location:
|
||||
ffmpeg_path_content.clicked.connect(
|
||||
lambda: open_file(ffmpeg_location, file_manager=True)
|
||||
)
|
||||
ffmpeg_path_content.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
ffmpeg_path_content.setToolTip(ffmpeg_location)
|
||||
ffmpeg_path_content.setStyleSheet(form_content_style())
|
||||
self.optional_modules_layout.addRow(ffmpeg_path_title, ffmpeg_path_content)
|
||||
self.system_info_layout.addRow(ffmpeg_path_title, ffmpeg_path_content)
|
||||
ffmpeg_path_content.setMaximumWidth(ffmpeg_path_content.sizeHint().width())
|
||||
|
||||
# FFprobe Status
|
||||
ffprobe_path_title = QLabel("FFprobe")
|
||||
ffprobe_path_content = ClickableLabel(f"{ffprobe_status}")
|
||||
ffprobe_location = FfprobeStatus.which()
|
||||
ffprobe_location = which(ffmpeg._get_ffprobe_location()) # pyright: ignore[reportPrivateUsage]
|
||||
if ffprobe_location:
|
||||
ffprobe_path_content.clicked.connect(
|
||||
lambda: open_file(ffprobe_location, file_manager=True)
|
||||
)
|
||||
ffprobe_path_content.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
ffprobe_path_content.setToolTip(ffprobe_location)
|
||||
ffprobe_path_content.setStyleSheet(form_content_style())
|
||||
self.optional_modules_layout.addRow(ffprobe_path_title, ffprobe_path_content)
|
||||
self.system_info_layout.addRow(ffprobe_path_title, ffprobe_path_content)
|
||||
ffprobe_path_content.setMaximumWidth(ffprobe_path_content.sizeHint().width())
|
||||
|
||||
# ripgrep Status
|
||||
# TODO: Add a central class to find ripgrep info, similar to ffmpeg
|
||||
ripgrep_path_title = QLabel("ripgrep") # NOTE: Don't localize
|
||||
ripgrep_path_content = ClickableLabel()
|
||||
ripgrep_path_content.setText(f"{ripgrep_status}") # TODO: Pass in constructor after #1386
|
||||
ripgrep_location = RipgrepStatus.which()
|
||||
ripgrep_location = which("rg")
|
||||
if ripgrep_location:
|
||||
ripgrep_path_content.clicked.connect(
|
||||
lambda: open_file(ripgrep_location, file_manager=True)
|
||||
)
|
||||
ripgrep_path_content.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
ripgrep_path_content.setToolTip(ripgrep_location)
|
||||
ripgrep_path_content.setStyleSheet(form_content_style())
|
||||
ripgrep_path_content.setMaximumWidth(ripgrep_path_content.sizeHint().width())
|
||||
self.optional_modules_layout.addRow(ripgrep_path_title, ripgrep_path_content)
|
||||
|
||||
self.parent_optional_modules_layout.addWidget(self.optional_modules_label)
|
||||
self.parent_optional_modules_layout.addWidget(self.optional_modules_widget)
|
||||
self.system_info_layout.addRow(ripgrep_path_title, ripgrep_path_content)
|
||||
|
||||
# Links ----------------------------------------------------------------
|
||||
|
||||
@@ -239,7 +217,6 @@ class AboutModal(QWidget):
|
||||
self.content_layout.addWidget(self.version_label)
|
||||
self.content_layout.addWidget(self.desc_label)
|
||||
self.content_layout.addWidget(self.system_info_widget)
|
||||
self.content_layout.addWidget(self.parent_optional_modules_widget)
|
||||
self.content_layout.addStretch(1)
|
||||
self.content_layout.addWidget(self.links_label)
|
||||
self.content_layout.addWidget(self.copyright_label)
|
||||
|
||||
@@ -176,9 +176,7 @@ class BuildTagPanel(PanelWidget):
|
||||
if tag is not None:
|
||||
exclude_ids.append(tag.id)
|
||||
|
||||
self.add_tag_modal = TagSearchModal(
|
||||
self.lib, title=Translations["tag.add.plural"], exclude=exclude_ids
|
||||
)
|
||||
self.add_tag_modal = TagSearchModal(self.lib, exclude_ids)
|
||||
self.add_tag_modal.tsp.item_chosen.connect(lambda x: self._add_parent_tag_callback(x))
|
||||
self.parent_tags_add_button.clicked.connect(self.add_tag_modal.show)
|
||||
|
||||
|
||||
@@ -300,25 +300,11 @@ class ItemThumb(FlowWidget):
|
||||
# NOTE: self.item_id seems to act as a reference here and does not need to be updated inside
|
||||
# QtDriver.update_thumbs() while item_thumb.delete_action does.
|
||||
# If this behavior ever changes, move this method back to QtDriver.update_thumbs().
|
||||
self.thumb_button.pressed.connect(
|
||||
lambda: (
|
||||
self.toggle_item_selection()
|
||||
if (
|
||||
QGuiApplication.keyboardModifiers() == Qt.KeyboardModifier.ControlModifier
|
||||
or not self.thumb_button.selected
|
||||
)
|
||||
else None
|
||||
)
|
||||
)
|
||||
|
||||
self.thumb_button.clicked.connect(
|
||||
lambda: (
|
||||
self.toggle_item_selection()
|
||||
if (
|
||||
QGuiApplication.keyboardModifiers() != Qt.KeyboardModifier.ControlModifier
|
||||
and self.thumb_button.selected
|
||||
)
|
||||
else None
|
||||
lambda: self.driver.toggle_item_selection(
|
||||
self.item_id,
|
||||
append=(QGuiApplication.keyboardModifiers() == Qt.KeyboardModifier.ControlModifier),
|
||||
bridge=(QGuiApplication.keyboardModifiers() == Qt.KeyboardModifier.ShiftModifier),
|
||||
)
|
||||
)
|
||||
self.set_mode(mode)
|
||||
@@ -331,13 +317,6 @@ class ItemThumb(FlowWidget):
|
||||
def is_archived(self) -> bool:
|
||||
return self.badge_active[BadgeType.ARCHIVED]
|
||||
|
||||
def toggle_item_selection(self):
|
||||
self.driver.toggle_item_selection(
|
||||
self.item_id,
|
||||
append=(QGuiApplication.keyboardModifiers() == Qt.KeyboardModifier.ControlModifier),
|
||||
bridge=(QGuiApplication.keyboardModifiers() == Qt.KeyboardModifier.ShiftModifier),
|
||||
)
|
||||
|
||||
def set_mode(self, mode: ItemType | None) -> None:
|
||||
if mode is None:
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, on=True)
|
||||
|
||||
@@ -98,9 +98,6 @@ class SettingsPanel(PanelWidget):
|
||||
self.root_layout.setContentsMargins(0, 6, 0, 0)
|
||||
|
||||
self.library_settings_container = QWidget()
|
||||
self.appearance_settings_container = QWidget()
|
||||
self.localization_settings_container = QWidget()
|
||||
self.media_settings_container = QWidget()
|
||||
|
||||
# Tabs
|
||||
self.tab_widget = QTabWidget()
|
||||
@@ -108,19 +105,6 @@ class SettingsPanel(PanelWidget):
|
||||
self.__build_global_settings()
|
||||
self.tab_widget.addTab(self.global_settings_container, Translations["settings.global"])
|
||||
|
||||
self.__build_appearance_settings()
|
||||
self.tab_widget.addTab(
|
||||
self.appearance_settings_container, Translations["settings.appearance"]
|
||||
)
|
||||
|
||||
self.__build_localization_settings()
|
||||
self.tab_widget.addTab(
|
||||
self.localization_settings_container, Translations["settings.localization"]
|
||||
)
|
||||
|
||||
self.__build_media_settings()
|
||||
self.tab_widget.addTab(self.media_settings_container, Translations["settings.media"])
|
||||
|
||||
# self.__build_library_settings()
|
||||
# self.tab_widget.addTab(self.library_settings_container, Translations["settings.library"])
|
||||
|
||||
@@ -148,6 +132,17 @@ class SettingsPanel(PanelWidget):
|
||||
form_layout = QFormLayout(self.global_settings_container)
|
||||
form_layout.setContentsMargins(6, 6, 6, 6)
|
||||
|
||||
# Language
|
||||
self.language_combobox = QComboBox()
|
||||
for k in LANGUAGES:
|
||||
self.language_combobox.addItem(k, LANGUAGES[k])
|
||||
current_lang: str = self.driver.settings.language
|
||||
if current_lang not in LANGUAGES.values():
|
||||
current_lang = DEFAULT_TRANSLATION
|
||||
self.language_combobox.setCurrentIndex(list(LANGUAGES.values()).index(current_lang))
|
||||
self.language_combobox.currentIndexChanged.connect(self.__update_restart_label)
|
||||
form_layout.addRow(Translations["settings.language"], self.language_combobox)
|
||||
|
||||
# Open Last Library on Start
|
||||
self.open_last_lib_checkbox = QCheckBox()
|
||||
self.open_last_lib_checkbox.setChecked(self.driver.settings.open_last_loaded_on_startup)
|
||||
@@ -155,6 +150,54 @@ class SettingsPanel(PanelWidget):
|
||||
Translations["settings.open_library_on_start"], self.open_last_lib_checkbox
|
||||
)
|
||||
|
||||
# Generate Thumbnails
|
||||
self.generate_thumbs = QCheckBox()
|
||||
self.generate_thumbs.setChecked(self.driver.settings.generate_thumbs)
|
||||
form_layout.addRow(Translations["settings.generate_thumbs"], self.generate_thumbs)
|
||||
|
||||
# Thumbnail Cache Size
|
||||
self.thumb_cache_size_container = QWidget()
|
||||
self.thumb_cache_size_layout = QHBoxLayout(self.thumb_cache_size_container)
|
||||
self.thumb_cache_size_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.thumb_cache_size_layout.setSpacing(6)
|
||||
self.thumb_cache_size = QLineEdit()
|
||||
self.thumb_cache_size.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
self.thumb_cache_size_validator = QDoubleValidator(
|
||||
MIN_THUMB_CACHE_SIZE, 1_000_000_000, 2
|
||||
) # High limit
|
||||
self.thumb_cache_size.setValidator(self.thumb_cache_size_validator)
|
||||
self.thumb_cache_size.setText(
|
||||
str(max(self.driver.settings.thumb_cache_size, MIN_THUMB_CACHE_SIZE)).removesuffix(".0")
|
||||
)
|
||||
self.thumb_cache_size_layout.addWidget(self.thumb_cache_size)
|
||||
self.thumb_cache_size_layout.setStretch(1, 2)
|
||||
self.thumb_cache_size_layout.addWidget(QLabel("MiB"))
|
||||
form_layout.addRow(
|
||||
Translations["settings.thumb_cache_size.label"], self.thumb_cache_size_container
|
||||
)
|
||||
|
||||
# Cached Thumbnail Resolution
|
||||
self.cached_thumb_res_container = QWidget()
|
||||
self.cached_thumb_res_layout = QHBoxLayout(self.cached_thumb_res_container)
|
||||
self.cached_thumb_res_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.cached_thumb_res_layout.setSpacing(6)
|
||||
self.cached_thumb_res = QLineEdit()
|
||||
self.cached_thumb_res.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
self.cached_thumb_res_validator = QIntValidator(MIN_CACHED_THUMB_RES, MAX_CACHED_THUMB_RES)
|
||||
self.cached_thumb_res.setValidator(self.cached_thumb_res_validator)
|
||||
self.cached_thumb_res.setText(str(self.driver.settings.cached_thumb_resolution))
|
||||
self.cached_thumb_res_layout.addWidget(self.cached_thumb_res)
|
||||
self.cached_thumb_res_layout.setStretch(1, 2)
|
||||
self.cached_thumb_res_layout.addWidget(QLabel("px"))
|
||||
form_layout.addRow(
|
||||
Translations["settings.cached_thumb_resolution.label"], self.cached_thumb_res_container
|
||||
)
|
||||
|
||||
# Autoplay
|
||||
self.autoplay_checkbox = QCheckBox()
|
||||
self.autoplay_checkbox.setChecked(self.driver.settings.autoplay)
|
||||
form_layout.addRow(Translations["media_player.autoplay"], self.autoplay_checkbox)
|
||||
|
||||
# Scan for new files when a library is opened
|
||||
self.scan_files_on_open_checkbox = QCheckBox()
|
||||
self.scan_files_on_open_checkbox.setChecked(self.driver.settings.scan_files_on_open)
|
||||
@@ -216,83 +259,26 @@ class SettingsPanel(PanelWidget):
|
||||
Translations["settings.tag_click_action.label"], self.tag_click_action_combobox
|
||||
)
|
||||
|
||||
# TODO: Implement Library Settings
|
||||
def __build_library_settings(self): # pyright: ignore[reportUnusedFunction]
|
||||
form_layout = QFormLayout(self.library_settings_container)
|
||||
form_layout.setContentsMargins(6, 6, 6, 6)
|
||||
# Dark Mode
|
||||
self.theme_combobox = QComboBox()
|
||||
for k in SettingsPanel.theme_map:
|
||||
self.theme_combobox.addItem(SettingsPanel.theme_map[k], k)
|
||||
theme = self.driver.settings.theme
|
||||
if theme not in SettingsPanel.theme_map:
|
||||
theme = Theme.DEFAULT
|
||||
self.theme_combobox.setCurrentIndex(list(SettingsPanel.theme_map.keys()).index(theme))
|
||||
self.theme_combobox.currentIndexChanged.connect(self.__update_restart_label)
|
||||
form_layout.addRow(Translations["settings.theme.label"], self.theme_combobox)
|
||||
|
||||
todo_label = QLabel("TODO")
|
||||
form_layout.addRow(todo_label)
|
||||
|
||||
def __build_media_settings(self):
|
||||
form_layout = QFormLayout(self.media_settings_container)
|
||||
form_layout.setContentsMargins(6, 6, 6, 6)
|
||||
|
||||
# Autoplay
|
||||
self.autoplay_checkbox = QCheckBox()
|
||||
self.autoplay_checkbox.setChecked(self.driver.settings.autoplay)
|
||||
form_layout.addRow(Translations["media_player.autoplay"], self.autoplay_checkbox)
|
||||
|
||||
# Generate Thumbnails
|
||||
self.generate_thumbs = QCheckBox()
|
||||
self.generate_thumbs.setChecked(self.driver.settings.generate_thumbs)
|
||||
form_layout.addRow(Translations["settings.generate_thumbs"], self.generate_thumbs)
|
||||
|
||||
# Thumbnail Cache Size
|
||||
self.thumb_cache_size_container = QWidget()
|
||||
self.thumb_cache_size_layout = QHBoxLayout(self.thumb_cache_size_container)
|
||||
self.thumb_cache_size_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.thumb_cache_size_layout.setSpacing(6)
|
||||
self.thumb_cache_size = QLineEdit()
|
||||
self.thumb_cache_size.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
self.thumb_cache_size_validator = QDoubleValidator(
|
||||
MIN_THUMB_CACHE_SIZE, 1_000_000_000, 2
|
||||
) # High limit
|
||||
self.thumb_cache_size.setValidator(self.thumb_cache_size_validator)
|
||||
self.thumb_cache_size.setText(
|
||||
str(max(self.driver.settings.thumb_cache_size, MIN_THUMB_CACHE_SIZE)).removesuffix(".0")
|
||||
)
|
||||
self.thumb_cache_size_layout.addWidget(self.thumb_cache_size)
|
||||
self.thumb_cache_size_layout.setStretch(1, 2)
|
||||
self.thumb_cache_size_layout.addWidget(QLabel("MiB"))
|
||||
form_layout.addRow(
|
||||
Translations["settings.thumb_cache_size.label"], self.thumb_cache_size_container
|
||||
)
|
||||
|
||||
# Cached Thumbnail Resolution
|
||||
self.cached_thumb_res_container = QWidget()
|
||||
self.cached_thumb_res_layout = QHBoxLayout(self.cached_thumb_res_container)
|
||||
self.cached_thumb_res_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.cached_thumb_res_layout.setSpacing(6)
|
||||
self.cached_thumb_res = QLineEdit()
|
||||
self.cached_thumb_res.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
self.cached_thumb_res_validator = QIntValidator(MIN_CACHED_THUMB_RES, MAX_CACHED_THUMB_RES)
|
||||
self.cached_thumb_res.setValidator(self.cached_thumb_res_validator)
|
||||
self.cached_thumb_res.setText(str(self.driver.settings.cached_thumb_resolution))
|
||||
self.cached_thumb_res_layout.addWidget(self.cached_thumb_res)
|
||||
self.cached_thumb_res_layout.setStretch(1, 2)
|
||||
self.cached_thumb_res_layout.addWidget(QLabel("px"))
|
||||
form_layout.addRow(
|
||||
Translations["settings.cached_thumb_resolution.label"], self.cached_thumb_res_container
|
||||
)
|
||||
|
||||
def __build_localization_settings(self):
|
||||
form_layout = QFormLayout(self.localization_settings_container)
|
||||
form_layout.setContentsMargins(6, 6, 6, 6)
|
||||
|
||||
# Language
|
||||
self.language_combobox = QComboBox()
|
||||
translated_langs = [(Translations[f"language.{lang}"], lang) for lang in LANGUAGES]
|
||||
translated_langs.sort(key=lambda x: x[0])
|
||||
|
||||
for lang in translated_langs:
|
||||
self.language_combobox.addItem(lang[0], lang[1])
|
||||
current_lang: str = self.driver.settings.language
|
||||
if current_lang not in LANGUAGES:
|
||||
current_lang = DEFAULT_TRANSLATION
|
||||
self.language_combobox.setCurrentIndex([x[1] for x in translated_langs].index(current_lang))
|
||||
self.language_combobox.currentIndexChanged.connect(self.__update_restart_label)
|
||||
form_layout.addRow(Translations["settings.language"], self.language_combobox)
|
||||
# Splash Screen
|
||||
self.splash_combobox = QComboBox()
|
||||
for k in SettingsPanel.splash_map:
|
||||
self.splash_combobox.addItem(SettingsPanel.splash_map[k], k)
|
||||
splash = self.driver.settings.splash
|
||||
if splash not in SettingsPanel.splash_map:
|
||||
splash = Splash.DEFAULT
|
||||
self.splash_combobox.setCurrentIndex(list(SettingsPanel.splash_map.keys()).index(splash))
|
||||
form_layout.addRow(Translations["settings.splash.label"], self.splash_combobox)
|
||||
|
||||
# Date Format
|
||||
self.dateformat_combobox = QComboBox()
|
||||
@@ -317,34 +303,20 @@ class SettingsPanel(PanelWidget):
|
||||
self.zeropadding_checkbox.setChecked(self.driver.settings.zero_padding)
|
||||
form_layout.addRow(Translations["settings.zeropadding.label"], self.zeropadding_checkbox)
|
||||
|
||||
def __build_appearance_settings(self):
|
||||
form_layout = QFormLayout(self.appearance_settings_container)
|
||||
# TODO: Implement Library Settings
|
||||
def __build_library_settings(self): # pyright: ignore[reportUnusedFunction]
|
||||
form_layout = QFormLayout(self.library_settings_container)
|
||||
form_layout.setContentsMargins(6, 6, 6, 6)
|
||||
|
||||
# Dark Mode
|
||||
self.theme_combobox = QComboBox()
|
||||
for k in SettingsPanel.theme_map:
|
||||
self.theme_combobox.addItem(SettingsPanel.theme_map[k], k)
|
||||
theme = self.driver.settings.theme
|
||||
if theme not in SettingsPanel.theme_map:
|
||||
theme = Theme.DEFAULT
|
||||
self.theme_combobox.setCurrentIndex(list(SettingsPanel.theme_map.keys()).index(theme))
|
||||
self.theme_combobox.currentIndexChanged.connect(self.__update_restart_label)
|
||||
form_layout.addRow(Translations["settings.theme.label"], self.theme_combobox)
|
||||
todo_label = QLabel("TODO")
|
||||
form_layout.addRow(todo_label)
|
||||
|
||||
# Splash Screen
|
||||
self.splash_combobox = QComboBox()
|
||||
for k in SettingsPanel.splash_map:
|
||||
self.splash_combobox.addItem(SettingsPanel.splash_map[k], k)
|
||||
splash = self.driver.settings.splash
|
||||
if splash not in SettingsPanel.splash_map:
|
||||
splash = Splash.DEFAULT
|
||||
self.splash_combobox.setCurrentIndex(list(SettingsPanel.splash_map.keys()).index(splash))
|
||||
form_layout.addRow(Translations["settings.splash.label"], self.splash_combobox)
|
||||
def __get_language(self) -> str:
|
||||
return list(LANGUAGES.values())[self.language_combobox.currentIndex()]
|
||||
|
||||
def get_settings(self) -> dict[str, Any]: # pyright: ignore[reportExplicitAny]
|
||||
return {
|
||||
"language": self.language_combobox.currentData(),
|
||||
"language": self.__get_language(),
|
||||
"open_last_loaded_on_startup": self.open_last_lib_checkbox.isChecked(),
|
||||
"generate_thumbs": self.generate_thumbs.isChecked(),
|
||||
"thumb_cache_size": max(
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# SPDX-FileCopyrightText: (c) 2022 Karl Kroening (kkroening)
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
# Vendored from ffmpeg-python and ffmpeg-python PR#790 by amamic1803
|
||||
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
from shutil import which
|
||||
|
||||
import ffmpeg
|
||||
import structlog
|
||||
|
||||
from tagstudio.core.utils.silent_subprocess import silent_popen, silent_run
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
user = os.environ.get("USER", None)
|
||||
FFMPEG_MACOS_LOCATIONS: list[str] = [
|
||||
"",
|
||||
"/opt/homebrew/bin/",
|
||||
"/usr/local/bin/",
|
||||
f"/etc/profiles/per-user/{user}/bin",
|
||||
]
|
||||
|
||||
|
||||
# TODO: Make this more intuitive to use in other classes
|
||||
def _get_ffprobe_location() -> str:
|
||||
cmd: str = "ffprobe"
|
||||
if platform.system() == "Darwin":
|
||||
for loc in FFMPEG_MACOS_LOCATIONS:
|
||||
if which(loc + cmd):
|
||||
cmd = loc + cmd
|
||||
break
|
||||
logger.info(
|
||||
f"[FFmpeg] Using FFprobe location: {cmd}{' (Found)' if which(cmd) else ' (Not Found)'}"
|
||||
)
|
||||
return cmd
|
||||
|
||||
|
||||
# TODO: Make this more intuitive to use in other classes
|
||||
def _get_ffmpeg_location() -> str:
|
||||
cmd: str = "ffmpeg"
|
||||
if platform.system() == "Darwin":
|
||||
for loc in FFMPEG_MACOS_LOCATIONS:
|
||||
if which(loc + cmd):
|
||||
cmd = loc + cmd
|
||||
break
|
||||
logger.info(
|
||||
f"[FFmpeg] Using FFmpeg location: {cmd}{' (Found)' if which(cmd) else ' (Not Found)'}"
|
||||
)
|
||||
return cmd
|
||||
|
||||
|
||||
FFPROBE_CMD = _get_ffprobe_location()
|
||||
FFMPEG_CMD = _get_ffmpeg_location()
|
||||
|
||||
|
||||
def probe(filename, cmd=FFPROBE_CMD, timeout=None, **kwargs):
|
||||
"""Run ffprobe on the specified file and return a JSON representation of the output.
|
||||
|
||||
Raises:
|
||||
:class:`ffmpeg.Error`: if ffprobe returns a non-zero exit code,
|
||||
an :class:`Error` is returned with a generic error message.
|
||||
The stderr output can be retrieved by accessing the
|
||||
``stderr`` property of the exception.
|
||||
"""
|
||||
args = [cmd, "-show_format", "-show_streams", "-of", "json"]
|
||||
args += ffmpeg._utils.convert_kwargs_to_cmd_line_args(kwargs) # pyright: ignore[reportAttributeAccessIssue]
|
||||
args += [filename]
|
||||
|
||||
# PATCHED
|
||||
p = silent_popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
communicate_kwargs = {}
|
||||
if timeout is not None:
|
||||
communicate_kwargs["timeout"] = timeout
|
||||
out, err = p.communicate(**communicate_kwargs)
|
||||
if p.returncode != 0:
|
||||
raise ffmpeg.Error("ffprobe", out, err)
|
||||
return json.loads(out.decode("utf-8"))
|
||||
|
||||
|
||||
def version():
|
||||
"""Checks the version of FFmpeg and FFprobe and returns None if they dont exist."""
|
||||
version: dict[str, str | None] = {"ffmpeg": None, "ffprobe": None}
|
||||
|
||||
if which(FFMPEG_CMD):
|
||||
ret = silent_run([FFMPEG_CMD, "-version"], shell=False, capture_output=True, text=True)
|
||||
if ret.returncode == 0:
|
||||
with contextlib.suppress(Exception):
|
||||
version["ffmpeg"] = str(ret.stdout).split(" ")[2]
|
||||
|
||||
if which(FFPROBE_CMD):
|
||||
ret = silent_run([FFPROBE_CMD, "-version"], shell=False, capture_output=True, text=True)
|
||||
if ret.returncode == 0:
|
||||
with contextlib.suppress(Exception):
|
||||
version["ffprobe"] = str(ret.stdout).split(" ")[2]
|
||||
|
||||
return version
|
||||
@@ -1,45 +0,0 @@
|
||||
# SPDX-FileCopyrightText: (c) 2022 Karl Kroening (kkroening)
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
# Vendored from ffmpeg-python and ffmpeg-python PR#790 by amamic1803
|
||||
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import ffmpeg
|
||||
import structlog
|
||||
|
||||
from tagstudio.core.utils.ffmpeg_status import FfprobeStatus
|
||||
from tagstudio.core.utils.silent_subprocess import (
|
||||
silent_popen, # pyright: ignore[reportUnknownVariableType]
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def probe(filename: Path | str, timeout: int | None = None, **kwargs: ...):
|
||||
"""Run ffprobe on the specified file and return a JSON representation of the output.
|
||||
|
||||
Raises:
|
||||
Error: If ffprobe returns a non-zero exit code, an Error is raised
|
||||
with a generic error message. The stderr output can be retrieved
|
||||
by accessing the `stderr` property of the exception.
|
||||
"""
|
||||
ffprobe_cmd: str | None = FfprobeStatus.which()
|
||||
if not ffprobe_cmd:
|
||||
return
|
||||
args: list[str] = [ffprobe_cmd, "-show_format", "-show_streams", "-of", "json"]
|
||||
args += ffmpeg._utils.convert_kwargs_to_cmd_line_args(kwargs) # pyright: ignore
|
||||
args += [filename] # pyright: ignore
|
||||
|
||||
# PATCHED
|
||||
p = silent_popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
communicate_kwargs = {}
|
||||
if timeout is not None:
|
||||
communicate_kwargs["timeout"] = timeout
|
||||
out, err = p.communicate(**communicate_kwargs)
|
||||
if p.returncode != 0:
|
||||
raise ffmpeg.Error("ffprobe", out, err)
|
||||
return json.loads(out.decode("utf-8"))
|
||||
@@ -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.core.utils.ffmpeg_status import FfmpegStatus
|
||||
from tagstudio.qt.previews.vendored.ffmpeg import FFMPEG_CMD
|
||||
|
||||
try:
|
||||
from itertools import izip
|
||||
@@ -161,7 +161,7 @@ class _AudioSegment:
|
||||
slice = a[5000:10000] # get a slice from 5 to 10 seconds of an mp3
|
||||
"""
|
||||
|
||||
converter = FfmpegStatus.which()
|
||||
converter = FFMPEG_CMD
|
||||
|
||||
# TODO: remove in 1.0 release
|
||||
# maintain backwards compatibility for ffmpeg attr (now called converter)
|
||||
@@ -727,7 +727,7 @@ class _AudioSegment:
|
||||
stdin_parameter = None
|
||||
stdin_data = None
|
||||
else:
|
||||
if cls.converter == FfmpegStatus.which():
|
||||
if cls.converter == FFMPEG_CMD:
|
||||
conversion_command += [
|
||||
"-read_ahead_limit",
|
||||
str(read_ahead_limit),
|
||||
|
||||
@@ -14,13 +14,13 @@ from pydub.utils import (
|
||||
get_extra_info,
|
||||
)
|
||||
|
||||
from tagstudio.core.utils.ffmpeg_status import FfprobeStatus
|
||||
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):
|
||||
"""Return json dictionary with media info(codec, duration, size, bitrate...) from filepath."""
|
||||
prober = FfprobeStatus.which()
|
||||
prober = FFPROBE_CMD
|
||||
command_args = [
|
||||
"-v",
|
||||
"info",
|
||||
|
||||
@@ -48,8 +48,8 @@ class ThumbGridLayout(QLayout):
|
||||
self._renderer.updated.connect(self._on_rendered)
|
||||
self._render_cutoff: float = 0.0
|
||||
|
||||
# _entry_ids[StartIndex:EndIndex], per_row
|
||||
self._last_page_update: tuple[int, int, int] | None = None
|
||||
# _entry_ids[StartIndex:EndIndex]
|
||||
self._last_page_update: tuple[int, int] | None = None
|
||||
|
||||
self._scroll_to: int | None = None
|
||||
|
||||
@@ -215,8 +215,7 @@ class ThumbGridLayout(QLayout):
|
||||
start = offset * per_row
|
||||
end = start + (visible_rows * per_row)
|
||||
|
||||
first_visible = self._entry_ids[start] if 0 <= start < len(self._entry_ids) else None
|
||||
self.visible_changed.emit(first_visible)
|
||||
self.visible_changed.emit(self._entry_ids[start])
|
||||
|
||||
# Load closest off screen rows
|
||||
start -= per_row * 3
|
||||
@@ -224,9 +223,9 @@ class ThumbGridLayout(QLayout):
|
||||
|
||||
start = max(0, start)
|
||||
end = min(len(self._entry_ids), end)
|
||||
if (start, end, per_row) == self._last_page_update:
|
||||
if (start, end) == self._last_page_update:
|
||||
return
|
||||
self._last_page_update = (start, end, per_row)
|
||||
self._last_page_update = (start, end)
|
||||
|
||||
# Clear render queue if len > 2 pages
|
||||
if len(self.driver.thumb_job_queue.queue) > (per_row * visible_rows * 2):
|
||||
|
||||
@@ -16,38 +16,38 @@ logger = structlog.get_logger(__name__)
|
||||
|
||||
DEFAULT_TRANSLATION = "en"
|
||||
|
||||
LANGUAGES = [
|
||||
# "am", # Minimal
|
||||
"ceb",
|
||||
"cs",
|
||||
# "da", # Minimal
|
||||
"de",
|
||||
"el",
|
||||
"en",
|
||||
"es",
|
||||
"fi",
|
||||
"fil",
|
||||
"fr",
|
||||
"hu",
|
||||
# "is", # Minimal
|
||||
"it",
|
||||
"ja",
|
||||
"nb_NO",
|
||||
"nl",
|
||||
"pl",
|
||||
"pt_BR",
|
||||
"pt",
|
||||
"qpv",
|
||||
"ro",
|
||||
"ru",
|
||||
"sv",
|
||||
"ta",
|
||||
# "th", # Minimal
|
||||
"tok",
|
||||
"tr",
|
||||
"zh_Hans",
|
||||
"zh_Hant",
|
||||
]
|
||||
LANGUAGES = {
|
||||
# "Amharic": "am", # Minimal
|
||||
"Cebuano": "ceb",
|
||||
"Chinese (Simplified)": "zh_Hans",
|
||||
"Chinese (Traditional)": "zh_Hant",
|
||||
"Czech": "cs",
|
||||
# "Danish": "da", # Minimal
|
||||
"Dutch": "nl",
|
||||
"English": "en",
|
||||
"Filipino": "fil",
|
||||
"Finnish": "fi",
|
||||
"French": "fr",
|
||||
"German": "de",
|
||||
"Greek": "el",
|
||||
"Hungarian": "hu",
|
||||
# "Icelandic": "is", # Minimal
|
||||
"Italian": "it",
|
||||
"Japanese": "ja",
|
||||
"Norwegian Bokmål": "nb_NO",
|
||||
"Polish": "pl",
|
||||
"Portuguese (Brazil)": "pt_BR",
|
||||
"Portuguese (Portugal)": "pt",
|
||||
"Romanian": "ro",
|
||||
"Russian": "ru",
|
||||
"Spanish": "es",
|
||||
"Swedish": "sv",
|
||||
"Tamil": "ta",
|
||||
# "Thai": "th", # Minimal
|
||||
"Toki Pona": "tok",
|
||||
"Turkish": "tr",
|
||||
"Viossa": "qpv",
|
||||
}
|
||||
|
||||
# A map of field class names to their respective translation keys.
|
||||
FIELD_TYPE_KEYS = {
|
||||
|
||||
+13
-33
@@ -39,6 +39,7 @@ from PySide6.QtGui import (
|
||||
)
|
||||
from PySide6.QtWidgets import QApplication, QFileDialog, QMessageBox, QPushButton, QScrollArea
|
||||
|
||||
# This import has side-effect of importing PySide resources
|
||||
import tagstudio.qt.resources_rc # noqa: F401 # pyright: ignore[reportUnusedImport]
|
||||
from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE, VERSION, VERSION_BRANCH
|
||||
from tagstudio.core.driver import DriverMixin
|
||||
@@ -51,11 +52,6 @@ from tagstudio.core.library.refresh import RefreshTracker
|
||||
from tagstudio.core.media_types import MediaCategories
|
||||
from tagstudio.core.query_lang.util import ParsingError
|
||||
from tagstudio.core.ts_core import TagStudioCore
|
||||
|
||||
# This import has side-effect of importing PySide resources
|
||||
from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus
|
||||
from tagstudio.core.utils.module_status import ModuleStatus
|
||||
from tagstudio.core.utils.ripgrep_status import RipgrepStatus
|
||||
from tagstudio.core.utils.str_formatting import is_version_outdated
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.cache_manager import CacheManager
|
||||
@@ -63,7 +59,7 @@ from tagstudio.qt.controllers.field_template_search_panel_controller import Fiel
|
||||
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.controllers.tag_search_panel_controller import TagSearchModal
|
||||
from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchModal, TagSearchPanel
|
||||
from tagstudio.qt.controllers.update_available_message_box import UpdateAvailableMessageBox
|
||||
from tagstudio.qt.global_settings import DEFAULT_GLOBAL_SETTINGS_PATH, GlobalSettings, Theme
|
||||
from tagstudio.qt.mixed.about_modal import AboutModal
|
||||
@@ -89,6 +85,7 @@ from tagstudio.qt.views.main_window import MainWindow
|
||||
from tagstudio.qt.views.panel_modal import PanelModal
|
||||
from tagstudio.qt.views.splash import SplashScreen
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import header
|
||||
from tagstudio.qt.views.tag_search_panel_view import TagSearchPanelView
|
||||
|
||||
BADGE_TAGS = {
|
||||
BadgeType.FAVORITE: TAG_FAVORITE,
|
||||
@@ -324,9 +321,6 @@ class QtDriver(DriverMixin, QObject):
|
||||
timer.start(500)
|
||||
timer.timeout.connect(lambda: None)
|
||||
|
||||
# Detect optional modules and versions for logging
|
||||
self.log_optional_modules()
|
||||
|
||||
# self.main_window = loader.load(home_path)
|
||||
self.main_window = MainWindow(self)
|
||||
self.main_window.setWindowTitle(self.base_title)
|
||||
@@ -358,13 +352,15 @@ class QtDriver(DriverMixin, QObject):
|
||||
self.app.setDesktopFileName("tagstudio")
|
||||
|
||||
# Initialize the Tag Manager panel
|
||||
self.tag_manager_panel = TagSearchModal(
|
||||
self.lib,
|
||||
self.tag_manager_panel = PanelModal(
|
||||
widget=TagSearchPanel(
|
||||
self.lib,
|
||||
is_tag_chooser=False,
|
||||
view=TagSearchPanelView(is_tag_chooser=False),
|
||||
),
|
||||
title=Translations["tag_manager.title"],
|
||||
is_tag_chooser=False,
|
||||
is_savable=False,
|
||||
)
|
||||
self.tag_manager_panel.tsp.set_driver(self)
|
||||
|
||||
self.tag_manager_panel.done.connect(
|
||||
lambda checked=False: self.main_window.preview_panel.set_selection(
|
||||
self.selected, update_preview=False
|
||||
@@ -390,10 +386,8 @@ class QtDriver(DriverMixin, QObject):
|
||||
)
|
||||
)
|
||||
|
||||
# Initialize the "Add Tag" panel
|
||||
self.add_tag_modal = TagSearchModal(
|
||||
self.lib, title=Translations["tag.add.plural"], is_tag_chooser=True
|
||||
)
|
||||
# Initialize the Tag Search panel
|
||||
self.add_tag_modal = TagSearchModal(self.lib, is_tag_chooser=True)
|
||||
self.add_tag_modal.tsp.set_driver(self)
|
||||
self.add_tag_modal.tsp.item_chosen.connect(
|
||||
lambda chosen_tag: (
|
||||
@@ -865,7 +859,7 @@ class QtDriver(DriverMixin, QObject):
|
||||
self.modal = PanelModal(
|
||||
panel,
|
||||
Translations["tag.new"],
|
||||
Translations["tag.create"],
|
||||
Translations["tag.add"],
|
||||
is_savable=True,
|
||||
)
|
||||
|
||||
@@ -1762,17 +1756,3 @@ class QtDriver(DriverMixin, QObject):
|
||||
def clear_selected(self):
|
||||
self._selected.clear()
|
||||
self.main_window.thumb_layout.update_selected()
|
||||
|
||||
def log_optional_modules(self) -> None:
|
||||
"""Logs the status of optional modules."""
|
||||
status_classes: list[tuple[str, type[ModuleStatus]]] = [
|
||||
("FFmpeg", FfmpegStatus),
|
||||
("FFprobe", FfprobeStatus),
|
||||
("ripgrep", RipgrepStatus),
|
||||
]
|
||||
|
||||
for name, sc in status_classes:
|
||||
if sc.which():
|
||||
logger.info(f"[QtDriver] {name} found", which=sc.which(), version=sc.version())
|
||||
else:
|
||||
logger.warning(f"[QtDriver] {sc} not found")
|
||||
|
||||
@@ -205,16 +205,14 @@ def container_style() -> str:
|
||||
|
||||
def form_content_style() -> str:
|
||||
return f"""
|
||||
QLabel{{
|
||||
background-color: {
|
||||
background-color: {
|
||||
Theme.COLOR_BG.value
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else Theme.COLOR_BG_LIGHT.value
|
||||
};
|
||||
border-radius: 3px;
|
||||
font-weight: 500;
|
||||
padding: 1px;
|
||||
}}
|
||||
border-radius: 3px;
|
||||
font-weight: 500;
|
||||
padding: 1px;
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"about.description": "TagStudio ist eine Anwendung zum organisieren von Fotos und Dateien mit einem zugrunde liegendem Tag-basierten System, welches sich darauf konzentriert, dem Nutzer Freiraum und Flexibilität zu bieten. Keine proprietären Programme oder Formate, kein Meer an Hilfsdateien und keine komplette Umwälzung deiner Dateisystemstruktur.",
|
||||
"about.documentation": "Dokumentation",
|
||||
"about.module.found": "Gefunden",
|
||||
"about.modules.title": "Optionale Module",
|
||||
"about.title": "Über TagStudio",
|
||||
"about.website": "Webseite",
|
||||
"app.git": "Git Commit",
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"about.description": "TagStudio is a photo and file organization application with an underlying tag-based system that focuses on giving freedom and flexibility to the user. No proprietary programs or formats, no sea of sidecar files, and no complete upheaval of your filesystem structure.",
|
||||
"about.documentation": "Documentation",
|
||||
"about.module.found": "Found",
|
||||
"about.modules.title": "Optional Modules",
|
||||
"about.title": "About TagStudio",
|
||||
"about.version": "Version",
|
||||
"about.version.latest": "{built_version} (Latest Release: {latest_version})",
|
||||
@@ -197,36 +196,6 @@
|
||||
"json_migration.title.new_lib": "<h2>v9.5+ Library</h2>",
|
||||
"json_migration.title.old_lib": "<h2>v9.4 Library</h2>",
|
||||
"landing.open_create_library": "Open/Create Library {shortcut}",
|
||||
"language.am": "Amharic",
|
||||
"language.ceb": "Cebuano",
|
||||
"language.cs": "Czech",
|
||||
"language.da": "Danish",
|
||||
"language.de": "German",
|
||||
"language.el": "Greek",
|
||||
"language.en": "English",
|
||||
"language.es": "Spanish",
|
||||
"language.fi": "Finnish",
|
||||
"language.fil": "Filipino",
|
||||
"language.fr": "French",
|
||||
"language.hu": "Hungarian",
|
||||
"language.is": "Icelandic",
|
||||
"language.it": "Italian",
|
||||
"language.ja": "Japanese",
|
||||
"language.nb_NO": "Norwegian Bokmål",
|
||||
"language.nl": "Dutch",
|
||||
"language.pl": "Polish",
|
||||
"language.pt": "Portuguese",
|
||||
"language.pt_BR": "Portuguese (Brazil)",
|
||||
"language.qpv": "Viossa",
|
||||
"language.ro": "Romanian",
|
||||
"language.ru": "Russian",
|
||||
"language.sv": "Swedish",
|
||||
"language.ta": "Tamil",
|
||||
"language.th": "Thai",
|
||||
"language.tok": "Toki Pona",
|
||||
"language.tr": "Turkish",
|
||||
"language.zh_Hans": "Chinese (Simplified)",
|
||||
"language.zh_Hant": "Chinese (Traditional)",
|
||||
"library_info.cleanup": "Cleanup",
|
||||
"library_info.cleanup.backups": "Library Backups:",
|
||||
"library_info.cleanup.dupe_files": "Duplicate Files:",
|
||||
@@ -308,7 +277,6 @@
|
||||
"select.all": "Select All",
|
||||
"select.clear": "Clear Selection",
|
||||
"select.inverse": "Invert Selection",
|
||||
"settings.appearance": "Appearance",
|
||||
"settings.cached_thumb_resolution.label": "Cached Thumbnail Resolution",
|
||||
"settings.clear_thumb_cache.title": "Clear Thumbnail Cache",
|
||||
"settings.dateformat.english": "English",
|
||||
@@ -325,8 +293,6 @@
|
||||
"settings.infinite_scroll": "Infinite Scrolling",
|
||||
"settings.language": "Language",
|
||||
"settings.library": "Library Settings",
|
||||
"settings.localization": "Localization",
|
||||
"settings.media": "Media",
|
||||
"settings.open_library_on_start": "Open Library on Start",
|
||||
"settings.page_size": "Page Size",
|
||||
"settings.restart_required": "Please restart TagStudio for changes to take effect.",
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
{
|
||||
"about.app_cache_path": "Ruta caché aplicación",
|
||||
"about.config_path": "Ruta de Configuración",
|
||||
"about.description": "TagStudio es una aplicación para organizar fotografías y archivos que utiliza un sistema de etiquetas subyacentes centrado en dar libertad y flexibilidad al usuario. Sin programas ni formatos propios, ni un mar de archivos y sin trastornar completamente la estructura de tu sistema de archivos.",
|
||||
"about.documentation": "Documentación",
|
||||
"about.module.found": "Encontrado",
|
||||
"about.title": "Acerca de TagStudio",
|
||||
"about.version": "Versión",
|
||||
"about.version.latest": "{built_version} (Última versión: {latest_version})",
|
||||
"about.website": "Página web",
|
||||
"app.git": "Commit de Git",
|
||||
"app.pre_release": "Pre-Lanzamiento",
|
||||
@@ -73,25 +70,12 @@
|
||||
"entries.unlinked.unlinked_count": "Entradas no vinculadas: {count}",
|
||||
"ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}<br>{ffprobe}: {ffprobe_status}",
|
||||
"field.add": "Añadir campo",
|
||||
"field.add.plural": "Añadir Campos",
|
||||
"field.confirm_remove": "¿Está seguro de que desea eliminar el campo \"{name}\"?",
|
||||
"field.copy": "Copiar Campo",
|
||||
"field.edit": "Editar Campo",
|
||||
"field.field_name_required": "Nombre del Campo (Obligatorio)",
|
||||
"field.mixed_data": "Datos variados",
|
||||
"field.name": "Nombre",
|
||||
"field.paste": "Pegar Campo",
|
||||
"field.remove": "Eliminar campo",
|
||||
"field.text.is_multiline": "Multilínea",
|
||||
"field.type": "Tipo",
|
||||
"field_template.all_field_templates": "Todas las plantillas de campos",
|
||||
"field_template.confirm_delete": "¿Seguro que quieres eliminar la plantilla de Campos \"{field_template_name}\"?",
|
||||
"field_template.create": "Crear Plantilla de Campos",
|
||||
"field_template.create_add": "Crear && Añadir \"{query}\"",
|
||||
"field_template.delete": "Eliminar Plantilla de Campos",
|
||||
"field_template.edit": "Editar plantilla de campos",
|
||||
"field_template.new": "Nueva plantilla de campos",
|
||||
"field_template_manager.title": "Plantillas de campos de biblioteca",
|
||||
"field_type.datetime": "Fecha y Hora",
|
||||
"field_type.text": "Texto",
|
||||
"field_type.unknown": "Tipo Desconocido",
|
||||
@@ -136,7 +120,6 @@
|
||||
"generic.delete_alt": "&Eliminar",
|
||||
"generic.done": "Terminado",
|
||||
"generic.done_alt": "&Hecho",
|
||||
"generic.dont_remind": "No me lo vuelvas a recordar",
|
||||
"generic.edit": "Editar",
|
||||
"generic.edit_alt": "&Editar",
|
||||
"generic.filename": "Nombre de archivo",
|
||||
@@ -161,7 +144,6 @@
|
||||
"home.search": "Buscar",
|
||||
"home.search.view_limit": "Límite visualización:",
|
||||
"home.search_entries": "Buscar entradas",
|
||||
"home.search_field_templates": "Buscar plantillas de Campos",
|
||||
"home.search_library": "Buscar el biblioteca",
|
||||
"home.search_tags": "Buscar etiquetas",
|
||||
"home.show_hidden_entries": "Mostrar entradas ocultas",
|
||||
@@ -231,7 +213,6 @@
|
||||
"menu.delete_selected_files_singular": "Mover archivo a la {trash_term}",
|
||||
"menu.edit": "Editar",
|
||||
"menu.edit.ignore_files": "Ignorar archivos y carpetas",
|
||||
"menu.edit.manage_field_templates": "Gestionar plantillas de Campos",
|
||||
"menu.edit.manage_tags": "Gestionar etiquetas",
|
||||
"menu.edit.new_tag": "Nueva &Etiqueta",
|
||||
"menu.file": "&Archivo",
|
||||
@@ -268,8 +249,6 @@
|
||||
"namespace.new.button": "Nuevo espacio de nombre",
|
||||
"namespace.new.prompt": "¡Crea un nuevo espacio de nombre para empezar a añadir colores personalizados!",
|
||||
"preview.ignored": "Ignorado",
|
||||
"preview.missing_module.jxl": "{module} es necesario para la previsualización de JPEG XL",
|
||||
"preview.missing_module.multimedia": "{module} es necesario para la reproducción multimedia",
|
||||
"preview.multiple_selection": "<b>{count}</b> Elementos seleccionados",
|
||||
"preview.no_selection": "No hay elementos seleccionados",
|
||||
"preview.unlinked": "Desvinculado",
|
||||
@@ -277,7 +256,6 @@
|
||||
"select.all": "Seleccionar todo",
|
||||
"select.clear": "Borrar selección",
|
||||
"select.inverse": "Invertir selección",
|
||||
"settings.cached_thumb_resolution.label": "Resolución Miniaturas de vídeo en caché",
|
||||
"settings.clear_thumb_cache.title": "Borrar cache de las miniaturas",
|
||||
"settings.dateformat.english": "Inglés",
|
||||
"settings.dateformat.international": "Internacional",
|
||||
@@ -300,7 +278,6 @@
|
||||
"settings.show_filenames_in_grid": "Mostrar el nombre de archivo en la cuadrícula",
|
||||
"settings.show_recent_libraries": "Mostrar bibliotecas recientes",
|
||||
"settings.splash.label": "Pantalla de Bienvenida",
|
||||
"settings.splash.option.aurora": "Aurora (9.6)",
|
||||
"settings.splash.option.classic": "Clásico (9.0)",
|
||||
"settings.splash.option.default": "Por Defecto",
|
||||
"settings.splash.option.goo_gears": "Código abierto (9.4)",
|
||||
@@ -357,7 +334,6 @@
|
||||
"tag.parent_tags": "Etiquetas principales",
|
||||
"tag.parent_tags.add": "Añadir etiquetas principales",
|
||||
"tag.parent_tags.description": "Esta etiqueta se puede tratar como sustituto de cualquiera de las etiquetas padre en las búsquedas.",
|
||||
"tag.properties": "Propiedades",
|
||||
"tag.remove": "Eliminar etiqueta",
|
||||
"tag.search_for_tag": "Buscar por etiqueta",
|
||||
"tag.shorthand": "Abreviatura",
|
||||
@@ -375,7 +351,6 @@
|
||||
"trash.dialog.title.singular": "Eliminar archivo",
|
||||
"trash.name.generic": "Basura",
|
||||
"trash.name.windows": "Papelera de reciclaje",
|
||||
"update.view_update": "Ver Actualización",
|
||||
"version_modal.description": "¡Ya está disponible una nueva versión de TagStudio! Puedes descargar la última versión desde <a href=\"{github_url}\">Github</a>.",
|
||||
"version_modal.status": "Versión Instalada: {installed_version}<br>Última Versión Publicada: {latest_release_version}",
|
||||
"version_modal.title": "Actualización de TagStudio disponible",
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"about.version": "Versioon",
|
||||
"about.version.latest": "{built_version} (Viimane versioon: {latest_version})",
|
||||
"about.website": "Veebisait"
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
{
|
||||
"about.app_cache_path": "Chemin d'accès du cache de l'application",
|
||||
"about.config_path": "Chemin de Configuration",
|
||||
"about.description": "TagStudio est une application d'organisation de photos et de fichiers avec un système de tags qui met en avant la liberté et flexibilité à l'utilisateur. Pas de programmes ou de formats propriétaires, pas la moindre trace de fichiers secondaires, et pas de bouleversement complet de la structure de votre système de fichiers.",
|
||||
"about.documentation": "Documentation",
|
||||
"about.module.found": "Trouvé",
|
||||
"about.title": "À propos de TagStudio",
|
||||
"about.version": "Version",
|
||||
"about.version.latest": "{built_version} (Dernière version : {latest_version})",
|
||||
"about.website": "Site Internet",
|
||||
"app.git": "Git Commit",
|
||||
"app.pre_release": "Version Préliminaire",
|
||||
@@ -73,25 +70,12 @@
|
||||
"entries.unlinked.unlinked_count": "Entrées non Liées : {count}",
|
||||
"ffmpeg.missing.status": "{ffmpeg} : {ffmpeg_status}<br>{ffprobe} : {ffprobe_status}",
|
||||
"field.add": "Ajouter un Champ",
|
||||
"field.add.plural": "Ajouter des champs",
|
||||
"field.confirm_remove": "Êtes-vous sûr de vouloir supprimer le champ \"{name}\"?",
|
||||
"field.copy": "Copier le Champ",
|
||||
"field.edit": "Modifier le Champ",
|
||||
"field.field_name_required": "Nom du champ (obligatoire)",
|
||||
"field.mixed_data": "Données Mélangées",
|
||||
"field.name": "Nom",
|
||||
"field.paste": "Coller le Champ",
|
||||
"field.remove": "Supprimer un Champ",
|
||||
"field.text.is_multiline": "Multiligne",
|
||||
"field.type": "Type",
|
||||
"field_template.all_field_templates": "Tous les formats de champs",
|
||||
"field_template.confirm_delete": "Êtes-vous sûr de vouloir supprimer le format de champ « {field_template_name} » ?",
|
||||
"field_template.create": "Créer un format de champ",
|
||||
"field_template.create_add": "Créer && Ajouter « {query} »",
|
||||
"field_template.delete": "Supprimer un format de champ",
|
||||
"field_template.edit": "Modifier le format de champ",
|
||||
"field_template.new": "Nouveau format de champ",
|
||||
"field_template_manager.title": "Format de champs de bibliothèque",
|
||||
"field_type.datetime": "Date et temps",
|
||||
"field_type.text": "Texte",
|
||||
"field_type.unknown": "Type inconnu",
|
||||
@@ -136,7 +120,6 @@
|
||||
"generic.delete_alt": "&Supprimer",
|
||||
"generic.done": "Terminé",
|
||||
"generic.done_alt": "&Terminé",
|
||||
"generic.dont_remind": "Ne pas me le rappelez",
|
||||
"generic.edit": "Modifier",
|
||||
"generic.edit_alt": "&Modifier",
|
||||
"generic.filename": "Nom de fichier",
|
||||
@@ -161,7 +144,6 @@
|
||||
"home.search": "Rechercher",
|
||||
"home.search.view_limit": "Limite d'affichage :",
|
||||
"home.search_entries": "Recherche",
|
||||
"home.search_field_templates": "Rechercher un format de champs",
|
||||
"home.search_library": "Rechercher dans la Bibliothèque",
|
||||
"home.search_tags": "Recherche de Tags",
|
||||
"home.show_hidden_entries": "Afficher les entrées cachées",
|
||||
@@ -231,7 +213,6 @@
|
||||
"menu.delete_selected_files_singular": "Déplacer le Fichier vers {trash_term}",
|
||||
"menu.edit": "Édition",
|
||||
"menu.edit.ignore_files": "Ignorer les Fichiers et Dossiers",
|
||||
"menu.edit.manage_field_templates": "Gérer les format de champs",
|
||||
"menu.edit.manage_tags": "Gérer les Tags",
|
||||
"menu.edit.new_tag": "Nouveaux &Tag",
|
||||
"menu.file": "&Fichier",
|
||||
@@ -268,8 +249,6 @@
|
||||
"namespace.new.button": "Nouvelle Namespace",
|
||||
"namespace.new.prompt": "Commencer par créer une nouvelle namespace pour pouvoir créer des couleurs personnalisées!",
|
||||
"preview.ignored": "Ignoré",
|
||||
"preview.missing_module.jxl": "Le module {module} est nécessaire pour les aperçus JPEG XL",
|
||||
"preview.missing_module.multimedia": "{module} est nécessaire pour la lecture multimédia",
|
||||
"preview.multiple_selection": "<b>{count}</b> Éléments Sélectionner",
|
||||
"preview.no_selection": "Pas d'Objet Selectionné",
|
||||
"preview.unlinked": "Non-lié",
|
||||
@@ -299,7 +278,6 @@
|
||||
"settings.show_filenames_in_grid": "Afficher les Noms de Fichiers en Grille",
|
||||
"settings.show_recent_libraries": "Afficher les Bibliothèques Récentes",
|
||||
"settings.splash.label": "Page de guarde",
|
||||
"settings.splash.option.aurora": "Aurora (9.6)",
|
||||
"settings.splash.option.classic": "Classique (9.0)",
|
||||
"settings.splash.option.default": "Défaut",
|
||||
"settings.splash.option.goo_gears": "Open Source (9.4)",
|
||||
@@ -356,7 +334,6 @@
|
||||
"tag.parent_tags": "Tags Parent",
|
||||
"tag.parent_tags.add": "Ajouter des Tags Parents",
|
||||
"tag.parent_tags.description": "Ce Tag peut être utilisé en replacement de tous ces Tags Parents dans les recherches.",
|
||||
"tag.properties": "Proprietes",
|
||||
"tag.remove": "Supprimer un Tag",
|
||||
"tag.search_for_tag": "Recherche de Label",
|
||||
"tag.shorthand": "Abrégé",
|
||||
@@ -374,7 +351,6 @@
|
||||
"trash.dialog.title.singular": "Supprimer le Fichier",
|
||||
"trash.name.generic": "Poubelle",
|
||||
"trash.name.windows": "Corbeille",
|
||||
"update.view_update": "Afficher la mise à jour",
|
||||
"version_modal.description": "Une nouvelle version de TagStudio est disponible! Vous pouvez télécharger la version la plus récente sur <a href=\"{github_url}\">Github</a>.",
|
||||
"version_modal.status": "Version installer : {installed_version}<br>Dernière version disponible : {latest_release_version}",
|
||||
"version_modal.title": "Mise à jour de TagStudio disponible",
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
{
|
||||
"about.app_cache_path": "Gyorsítótár elérési útvonala",
|
||||
"about.config_path": "Konfigurációs fájl",
|
||||
"about.description": "A TagStudio egy fénykép- és fájlkezelő program, mely címkék segítségével nyújt felhasználói szabadságot és rugalmasságot. A TagStudio nem használ jogvédett formátumokat, társfájlokat és nem fordítja a feje tetejére a már létező fájlrendszert.",
|
||||
"about.documentation": "Dokumentáció",
|
||||
"about.module.found": "Telepítve",
|
||||
"about.modules.title": "Nemkötelező modulok",
|
||||
"about.title": "A TagStudio névjegye",
|
||||
"about.version": "Kiadás",
|
||||
"about.version.latest": "{built_version} (legújabb kiadás: {latest_version})",
|
||||
"about.website": "Honlap",
|
||||
"app.git": "Git-véglegesítés",
|
||||
"app.pre_release": "Kísérleti verzió",
|
||||
@@ -78,20 +74,12 @@
|
||||
"field.confirm_remove": "Biztosan el akarja távolítani a(z) „{name}”-mezőt?",
|
||||
"field.copy": "Mező &másolása",
|
||||
"field.edit": "Mező szerkesztése",
|
||||
"field.field_name_required": "Mező neve (kötelező)",
|
||||
"field.mixed_data": "Kevert adatok",
|
||||
"field.name": "Név",
|
||||
"field.paste": "Mező &beillesztése",
|
||||
"field.remove": "Mező eltávolítása",
|
||||
"field.text.is_multiline": "Többsoros",
|
||||
"field.type": "Típus",
|
||||
"field_template.all_field_templates": "Mezőminták hozzáadása",
|
||||
"field_template.confirm_delete": "Biztosan törölni akarja a(z) „{field_template_name}” mezőmintát?",
|
||||
"field_template.create": "Mezőminta létrehozása",
|
||||
"field_template.create_add": "A(z) „{query}” mező létrehozása és hozzáadása",
|
||||
"field_template.delete": "Mezőminta törlése",
|
||||
"field_template.edit": "Mezőminta szerkesztése",
|
||||
"field_template.new": "Új mezőminta",
|
||||
"field_template_manager.title": "Mezőminták a jelenlegi könyvtárban",
|
||||
"field_type.datetime": "Dátum és idő",
|
||||
"field_type.text": "Szöveg",
|
||||
@@ -137,7 +125,6 @@
|
||||
"generic.delete_alt": "&Törlés",
|
||||
"generic.done": "Kész",
|
||||
"generic.done_alt": "Kész",
|
||||
"generic.dont_remind": "Ne emlékeztessen többször",
|
||||
"generic.edit": "Szerkesztés",
|
||||
"generic.edit_alt": "S&zerkesztés",
|
||||
"generic.filename": "Fájlnév",
|
||||
@@ -197,36 +184,6 @@
|
||||
"json_migration.title.new_lib": "<h2>9.5 és afölötti könyvtár</h2>",
|
||||
"json_migration.title.old_lib": "<h2>9.4-es könyvtár</h2>",
|
||||
"landing.open_create_library": "Könyvtár meg&nyitása/létrehozása {shortcut}",
|
||||
"language.am": "amhara",
|
||||
"language.ceb": "szebuano",
|
||||
"language.cs": "cseh",
|
||||
"language.da": "dán",
|
||||
"language.de": "német",
|
||||
"language.el": "görög",
|
||||
"language.en": "angol",
|
||||
"language.es": "spanyol",
|
||||
"language.fi": "finn",
|
||||
"language.fil": "filippínó",
|
||||
"language.fr": "francia",
|
||||
"language.hu": "magyar",
|
||||
"language.is": "izlandi",
|
||||
"language.it": "olasz",
|
||||
"language.ja": "japán",
|
||||
"language.nb_NO": "bokmål norvég",
|
||||
"language.nl": "holland",
|
||||
"language.pl": "lengyel",
|
||||
"language.pt": "portugál",
|
||||
"language.pt_BR": "brazíliai portugál",
|
||||
"language.qpv": "viossa",
|
||||
"language.ro": "román",
|
||||
"language.ru": "orosz",
|
||||
"language.sv": "svéd",
|
||||
"language.ta": "tamil",
|
||||
"language.th": "thai",
|
||||
"language.tok": "toki pona",
|
||||
"language.tr": "török",
|
||||
"language.zh_Hans": "kínai (egyszerűsített)",
|
||||
"language.zh_Hant": "kínai (hagyományos)",
|
||||
"library.missing": "Hiányzó hely",
|
||||
"library.name": "Könyvtár",
|
||||
"library.refresh.scanning.plural": "Új fájlok keresése a mappákban…\n{searched_count} fájl megvizsgálva; ebből {found_count} új fájl",
|
||||
@@ -289,8 +246,8 @@
|
||||
"menu.tools.fix_ignored_entries": "Figyelmen &kívül hagyott elemek javítása",
|
||||
"menu.tools.fix_unlinked_entries": "Kapcsolat &nélküli elemek javítása",
|
||||
"menu.view": "&Nézet",
|
||||
"menu.view.decrease_thumbnail_size": "Bélyegkép méretének csökkentése",
|
||||
"menu.view.increase_thumbnail_size": "Bélyegkép méretének növelése",
|
||||
"menu.view.decrease_thumbnail_size": "Indexkép méretének csökkentése",
|
||||
"menu.view.increase_thumbnail_size": "Indexkép méretének növelése",
|
||||
"menu.view.library_info": "&Könyvtárinformáció",
|
||||
"menu.window": "&Ablak",
|
||||
"namespace.create.description": "A TagStudio névterekkel különíti el az adatcsoportokat, mint a címkék és a színek, így azok könnyen exportálhatóak és megoszthatóak. A „tagstudio”-val kezdődő névterek belső használatra vannak lefoglalva.",
|
||||
@@ -299,8 +256,6 @@
|
||||
"namespace.new.button": "Új névtér",
|
||||
"namespace.new.prompt": "Az egyéni színek használatához először hozzon létre egy névteret!",
|
||||
"preview.ignored": "Figyelmen kívül hagyva",
|
||||
"preview.missing_module.jxl": "A JPEG XL-előnézetekhez a(z) {module} szükséges",
|
||||
"preview.missing_module.multimedia": "A hangok és videók lejátszásához a(z) {module} szükséges",
|
||||
"preview.multiple_selection": "<b>{count}</b> kijelölt elem",
|
||||
"preview.no_selection": "Nincs kijelölt elem",
|
||||
"preview.unlinked": "Kapcsolat nélküli elem",
|
||||
@@ -308,25 +263,21 @@
|
||||
"select.all": "&Az összes kijelölése",
|
||||
"select.clear": "&Kijelölés megszüntetése",
|
||||
"select.inverse": "Kijelölés &megfordítása",
|
||||
"settings.appearance": "Megjelenés",
|
||||
"settings.cached_thumb_resolution.label": "Gyorsítótárazott bélyegképek felbontása",
|
||||
"settings.clear_thumb_cache.title": "&Miniatűr-gyorsítótár ürítése",
|
||||
"settings.dateformat.english": "Angol",
|
||||
"settings.dateformat.international": "Nemzetközi",
|
||||
"settings.dateformat.label": "Dátumformátum",
|
||||
"settings.dateformat.system": "Rendszer",
|
||||
"settings.filepath.label": "&Elérési utak láthatósága",
|
||||
"settings.filepath.option.full": "Teljes elérési út megjelenítése",
|
||||
"settings.filepath.option.name": "Csak a fájlnév megjelenítése",
|
||||
"settings.filepath.option.relative": "Relatív elérési út megjelenítése",
|
||||
"settings.generate_thumbs": "Bélyegkép-előállítás",
|
||||
"settings.filepath.option.full": "Teljes elérési út mutatása",
|
||||
"settings.filepath.option.name": "Csak a fájlnév mutatása",
|
||||
"settings.filepath.option.relative": "Relatív elérési út mutatása",
|
||||
"settings.generate_thumbs": "Indexkép-előállítás",
|
||||
"settings.global": "Globális beállítások",
|
||||
"settings.hourformat.label": "24-órás idő",
|
||||
"settings.infinite_scroll": "Végtelen görgetés",
|
||||
"settings.language": "&Nyelv",
|
||||
"settings.library": "Könyvtárbeállítások",
|
||||
"settings.localization": "Nyelv és formátumok",
|
||||
"settings.media": "Média",
|
||||
"settings.open_library_on_start": "&Könyvtár megnyitása a program indulásakor",
|
||||
"settings.page_size": "&Oldalméret",
|
||||
"settings.restart_required": "A módosítások érvénybeléptetéséhez<br>újra kell indítani a TagStudiót.",
|
||||
@@ -334,7 +285,6 @@
|
||||
"settings.show_filenames_in_grid": "&Fájlnevek megjelenítése rácsnézetben",
|
||||
"settings.show_recent_libraries": "&Legutóbbi könyvtárak megjelenítése",
|
||||
"settings.splash.label": "Indítókép",
|
||||
"settings.splash.option.aurora": "Aurora (9.6)",
|
||||
"settings.splash.option.classic": "Klasszikus (9.0)",
|
||||
"settings.splash.option.default": "Alapértelmezett",
|
||||
"settings.splash.option.goo_gears": "Nyílt forráskódú (9.4)",
|
||||
@@ -348,7 +298,7 @@
|
||||
"settings.theme.label": "&Téma:",
|
||||
"settings.theme.light": "Világos",
|
||||
"settings.theme.system": "Automatikus",
|
||||
"settings.thumb_cache_size.label": "Bélyegkép-gyorsítótár mérete",
|
||||
"settings.thumb_cache_size.label": "Indexkép-gyorsítótár mérete",
|
||||
"settings.title": "Beállítások",
|
||||
"settings.zeropadding.label": "Egyszámjegyű napok kezdése nullával",
|
||||
"sorting.direction.ascending": "Növekvő sorrend",
|
||||
@@ -391,7 +341,6 @@
|
||||
"tag.parent_tags": "Szülőcímkék",
|
||||
"tag.parent_tags.add": "Új szülőcímke",
|
||||
"tag.parent_tags.description": "Ez a címke képes helyettesíteni bármely alábbi szülőcímkét kereséskor.",
|
||||
"tag.properties": "Tulajdonságok",
|
||||
"tag.remove": "Címke eltávolítása",
|
||||
"tag.search_for_tag": "Címke keresése",
|
||||
"tag.shorthand": "Rövidítés",
|
||||
@@ -409,7 +358,6 @@
|
||||
"trash.dialog.title.singular": "Fájl törlése",
|
||||
"trash.name.generic": "kukába",
|
||||
"trash.name.windows": "lomtárba",
|
||||
"update.view_update": "Frissítés megtekintése",
|
||||
"version_modal.description": "Elérhetővé vált egy TagStudio-frissítés. A legújabb verziót a <a href=\"{github_url}\">Githubról</a> töltheti le.",
|
||||
"version_modal.status": "Telepített verzió: {installed_version}<br>Legújabb stabil verzió: {latest_release_version}",
|
||||
"version_modal.title": "TagStudio-frissítés",
|
||||
|
||||
@@ -174,8 +174,6 @@
|
||||
"json_migration.title.new_lib": "<h2>v9.5+ ライブラリ</h2>",
|
||||
"json_migration.title.old_lib": "<h2>v9.4 ライブラリ</h2>",
|
||||
"landing.open_create_library": "ライブラリを開く/作成する {shortcut}",
|
||||
"language.en": "英語",
|
||||
"language.es": "スペイン語",
|
||||
"library.missing": "ライブラリの場所が見つかりません",
|
||||
"library.name": "ライブラリ",
|
||||
"library.refresh.scanning.plural": "新しいファイルを検索中...\n{searched_count} 件を検索、{found_count} 件の新規ファイルを検出",
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
{
|
||||
"about.app_cache_path": "Путь к кешу",
|
||||
"about.config_path": "Путь к конфигурации",
|
||||
"about.description": "TagStudio — это приложение для организации фотографий и прочих файлов, основанное на системе \"тегов\", и ориентированное на предоставление пользователю свободы и гибкости. Никаких проприетарных форматов и программ, никакой кучи сопроводительных файлов, и никакого переворота вашей файловой системы.",
|
||||
"about.documentation": "Документация",
|
||||
"about.module.found": "Найдено",
|
||||
"about.title": "О программе TagStudio",
|
||||
"about.version": "Версия",
|
||||
"about.version.latest": "{built_version} (Актуальный релиз: {latest_version})",
|
||||
"about.website": "Веб-сайт",
|
||||
"app.git": "Коммит Git",
|
||||
"app.pre_release": "Пре-релиз",
|
||||
@@ -75,19 +72,9 @@
|
||||
"field.confirm_remove": "Вы уверены, что хотите удалить поле \"{name}\"?",
|
||||
"field.copy": "Копировать поле",
|
||||
"field.edit": "Редактировать поле",
|
||||
"field.field_name_required": "Название поля (обязательно)",
|
||||
"field.mixed_data": "Смешанные данные",
|
||||
"field.name": "Название",
|
||||
"field.paste": "Вставить поле",
|
||||
"field.remove": "Удалить поле",
|
||||
"field.type": "Тип",
|
||||
"field_template.all_field_templates": "Все шаблоны полей",
|
||||
"field_template.confirm_delete": "Вы уверены, что хотите удалить шаблон поля \"{field_template_name}\"?",
|
||||
"field_template.create": "Создать шаблон поля",
|
||||
"field_template.create_add": "Создать и добавить \"{query}\"",
|
||||
"field_template.delete": "Удалить шаблон поля",
|
||||
"field_template.edit": "Редактировать шаблон поля",
|
||||
"field_template.new": "Новый шаблон поля",
|
||||
"field_type.datetime": "Дата",
|
||||
"field_type.text": "Текст",
|
||||
"field_type.unknown": "Неизвестный тип",
|
||||
@@ -190,25 +177,6 @@
|
||||
"json_migration.title.new_lib": "<h2>Библиотека версии 9.5+</h2>",
|
||||
"json_migration.title.old_lib": "<h2>Библиотека версии 9.4</h2>",
|
||||
"landing.open_create_library": "Открыть/создать библиотеку {shortcut}",
|
||||
"language.en": "Английский",
|
||||
"language.es": "Испанский",
|
||||
"language.fi": "Финский",
|
||||
"language.fil": "Филипинский",
|
||||
"language.fr": "Французский",
|
||||
"language.hu": "Венгерский",
|
||||
"language.is": "Исландский",
|
||||
"language.it": "Итальянский",
|
||||
"language.ja": "Японский",
|
||||
"language.pl": "Польский",
|
||||
"language.pt": "Португальский",
|
||||
"language.pt_BR": "Португальский (Бразилия)",
|
||||
"language.ro": "Румынский",
|
||||
"language.ru": "Русский",
|
||||
"language.sv": "Шведский",
|
||||
"language.th": "Тайский",
|
||||
"language.tr": "Турецкий",
|
||||
"language.zh_Hans": "Китайский (упрощенный)",
|
||||
"language.zh_Hant": "Китайский (традиционный)",
|
||||
"library.missing": "Отсутствует путь к библиотеке",
|
||||
"library.name": "Библиотека",
|
||||
"library.refresh.scanning.plural": "Сканирование папок на наличие новых файлов...\nПросканировано {searched_count} файлов, найдено {found_count} новых",
|
||||
|
||||
Binary file not shown.
@@ -11,7 +11,7 @@ def test_github_api_unavailable(qtbot: QtBot, mocker) -> None:
|
||||
mocker.patch(
|
||||
"requests.get",
|
||||
side_effect=ConnectionError(
|
||||
"Emulating a failure with 'api.github.com' ([Errno 0] This should be handled)"
|
||||
"Failed to resolve 'api.github.com' ([Errno -3] Temporary failure in name resolution)"
|
||||
),
|
||||
)
|
||||
modal = AboutModal("/tmp")
|
||||
|
||||
@@ -81,9 +81,9 @@ def test_library_add_file(library: Library):
|
||||
fields=[TextField(name="Title", value="I'm a Test Title")],
|
||||
)
|
||||
|
||||
assert not library.has_entry_with_path(entry.path)
|
||||
assert not library.get_entry_id_from_path(entry.path)
|
||||
assert library.add_entries([entry])
|
||||
assert library.has_entry_with_path(entry.path)
|
||||
assert library.get_entry_id_from_path(entry.path)
|
||||
|
||||
|
||||
def test_create_tag(library: Library, generate_tag: Callable[..., Tag]):
|
||||
@@ -347,8 +347,8 @@ def test_merge_entries(library: Library):
|
||||
entry_b_: Entry = unwrap(library.get_entry_full(entry_b_id))
|
||||
|
||||
assert library.merge_entries(entry_a_, entry_b_)
|
||||
assert not library.has_entry_with_path(Path("a"))
|
||||
assert library.has_entry_with_path(Path("b"))
|
||||
assert not library.get_entry_id_from_path(Path("a"))
|
||||
assert library.get_entry_id_from_path(Path("b"))
|
||||
|
||||
entry_b_merged = unwrap(library.get_entry_full(entry_b_id))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user