Compare commits

...

29 Commits

Author SHA1 Message Date
Hosted Weblate 16e546f8b2 Translated using Weblate (French)
Currently translated at 100.0% (428 of 428 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Med <45147847+kitsumed@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/fr/
Translation: TagStudio/Application Strings
2026-08-16 18:08:42 +02:00
Hosted Weblate 69377b5749 Translated using Weblate (Dutch)
Currently translated at 34.5% (148 of 428 strings)

Translated using Weblate (Dutch)

Currently translated at 34.5% (148 of 428 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Lily H <flannyha@gmail.com>
Co-authored-by: Pheubel <luca.guagliardo@live.nl>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/nl/
Translation: TagStudio/Application Strings
2026-08-16 18:08:42 +02:00
Hosted Weblate ce03c18b7d Translated using Weblate (Hungarian)
Currently translated at 100.0% (430 of 430 strings)

Co-authored-by: Szíjártó Levente Pál <szijartoleventepal@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/hu/
Translation: TagStudio/Application Strings
2026-08-16 18:08:42 +02:00
Travis Abendshien 555dae50d4 chore: bump version to v9.6.4 2026-08-15 21:46:46 -07:00
Travis Abendshien e6d67c26dd docs: reorder changelog to match release notes 2026-08-15 21:46:20 -07:00
Travis Abendshien 435f5ff2b6 fix(ui) fix suggest box icon and opacity edge cases 2026-08-15 14:05:46 -07:00
Travis Abendshien 3cebb211b3 docs: ammend changelog 2026-08-15 13:28:26 -07:00
Travis Abendshien 512bf623c2 fix(ui): fix accent color issues on Windows (#1477) 2026-08-15 13:24:39 -07:00
Travis Abendshien 30c81257fa fix: revert update check order 2026-08-15 13:24:24 -07:00
Travis Abendshien fcd4d67711 fix(logs): use correct name for missing optional modules 2026-08-15 12:49:22 -07:00
Travis Abendshien b44ab8f161 docs: update changelog 2026-08-15 11:08:27 -07:00
Travis Abendshien 56a0b73cbd fix: early return higher DB version, log+type hint fixes (#1476) 2026-08-15 11:03:12 -07:00
Travis Abendshien 3ce65552f7 fix: yield from all_entries() instead of returning (fixes #1447, fixes #1467) 2026-08-15 10:29:46 -07:00
Weblate (bot) 49e320618c translations: update from Hosted Weblate (#1468)
* Translated using Weblate (Japanese)

Currently translated at 100.0% (428 of 428 strings)

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

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (428 of 428 strings)

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

* Translated using Weblate (Hebrew)

Currently translated at 2.3% (10 of 423 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (423 of 423 strings)

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

---------

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

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

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

* fix: strip suggest box queries of whitespace

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

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

* refactor: inline make_tables + minor cleanup

* refactor: remove unnecessary assurance

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

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

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

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

* refactor: move migrations to different file

* refactor: package each migration in a class

* fix: some syntax errors had slipped through

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

* refactor: condense imports

* fix: add override decorators

* refactor: move set_version to DBMigrations

* refactor: remove unnecessary assignment

* refactor: use _ instead of __

* fix: add missing field templates tables
2026-08-11 15:14:33 -07:00
43 changed files with 1216 additions and 702 deletions
+8 -1
View File
@@ -45,6 +45,13 @@ SPDX-FileCopyrightText = "(c) 2026 Boxicons"
SPDX-License-Identifier = "MIT"
[[annotations]]
path = ["src/tagstudio/resources/qt/images/dupe_file_stat.png"]
path = [
"src/tagstudio/resources/qt/images/dupe_file_stat.png",
"src/tagstudio/resources/qt/images/hint_field_add.png",
"src/tagstudio/resources/qt/images/hint_field_create.png",
"src/tagstudio/resources/qt/images/hint_tag_add.png",
"src/tagstudio/resources/qt/images/hint_tag_added.png",
"src/tagstudio/resources/qt/images/hint_tag_create.png",
]
SPDX-FileCopyrightText = "(c) github:google/material-design-icons Contributors"
SPDX-License-Identifier = "Apache-2.0"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 12 KiB

+38
View File
@@ -9,6 +9,44 @@ toc_depth: 2
# :material-script-text: Changelog
### 9.6.3 <small>August 15th, 2026</small>
This update includes some critical library bugfixes along with a handful QoL tweaks and additions to the tag/field search bars. The [documentation](https://docs.tagstud.io/usage/#tagging) on this feature has been updated to include the new improvements.
### Added
- feat(ui): show library format version in "About" window by @CyanVoxel in 102cfdf4a2e12635f4b6e47259aa23ea95787421
#### Tag and Field Bars
- feat(ui): keep tag/field search bars open by default by @CyanVoxel in #1472
- feat(ui): translate vertical scrolling to horizontal in tag/field search bars by @CyanVoxel in #1473
- feat(ui): use tab and shift+tab to navigate tag/field search bars by @CyanVoxel in #1474
- feat(ui): add hint icons to tag/field search bars by @CyanVoxel in #1475
### Changed
#### Internal Changes
- refactor: almost final migrations cleanup by @Computerdores in #1456
#### Translations
- **French** updated by @kitsumed
- **Hebrew** updated by @Tatrask
- **Hungarian** updated by @smileyhead
- **Japanese** updated by @coolzdaddy55-art
### Fixed
- fix: fix being unable to create new colors by @CyanVoxel in dd00f4d4a02fd74b24d40c51b55ae3cc7db09203
- fix(ui): increase spacing between color palettes by @CyanVoxel in 354c0a0afcdaae2e490dd6c1eca5a556e7724aea
- fix: fix infinite loading in various refresh operations by @CyanVoxel in 3ce65552f7d459a2edebadb29cf1c17de6a1c742
- fix(ui): fix update popup showing after closing main window by @CyanVoxel in 30c81257fa3882376111ec03637bd6f517500908
- fix(ui): fix accent color issues on Windows by @CyanVoxel in #1477
---
## 9.6.2 <small>August 6th, 2026</small>
### Added
+19 -9
View File
@@ -26,7 +26,7 @@ TagStudio automatically scans for new or updated files when opening a library by
With one or more file entries selected, you can **search for** or **create a new tag** by clicking the "Add Tag" button at the bottom of the preview panel or by pressing <kbd>Ctrl</kbd>+<kbd>T</kbd>.
This will open the the **"Tag Bar"**, a combination search/creation bar that is designed to allow you to quickly create and apply tags that don't exist yet, find and apply existing tags in your library, and verify if certain tags already exist and/or are applied to your selection.
This will open the the **"Tag Bar"**, a combination search/creation bar that is designed to allow you to quickly create and apply tags that don't exist yet, find and apply existing tags from your library, and verify if certain tags already exist and/or are applied to your selection.
Every step is keyboard-friendly, allowing for an efficient tagging "flow state" when it comes to creating and applying tags.
@@ -34,7 +34,11 @@ Every step is keyboard-friendly, allowing for an efficient tagging "flow state"
![Empty File Entry](assets/tag_field_bars/add_buttons_normal.png){ width=80% }
<br>
![Empty File Entry](assets/tag_field_bars/tag_bar_empty.png){ width=80% }
<figcaption>Clicking "Add Tag" or pressing <kbd>Ctrl</kbd>+<kbd>T</kbd> replaces the "Add" buttons with a tag search bar. Pressing <kbd>Esc</kbd> (or <kbd>Enter</kbd> with a blank search) will close the bar and return the "Add" buttons.</figcaption>
<figcaption>
Clicking "Add Tag" or pressing <kbd>Ctrl</kbd>+<kbd>T</kbd> replaces the "Add" buttons with a tag search bar.
<br>
You can close the search by pressing <kbd>Enter</kbd> or clicking away (with a blank search), or at any time by pressing <kbd>Esc</kbd>.
</figcaption>
</figure>
<!-- prettier-ignore -->
@@ -43,7 +47,7 @@ Every step is keyboard-friendly, allowing for an efficient tagging "flow state"
### :material-tag-plus: Tag Bar: Creating Tags
Type to search for an existing tag, or use that search term to quickly create and apply a new tag to your selection. Pressing <kbd>Enter</kbd> with **no results** will **create** a tag from your search query and apply it to the selected entries.
Type to search for an existing tag, or use that search term to quickly create and apply a new tag to your selection. Pressing <kbd>Enter</kbd> with **no results** will **create** :material-tag-plus-outline: a tag from your search query and apply it to the selected entries.
After applying a tag, the "Add Tag" button is now focused, allowing you to press <kbd>Enter</kbd> or <kbd>Space</kbd> to quickly reactivate the bar. Alternatively, you may edit your new tag by right-clicking the tag and selecting **"Edit"**.
@@ -62,9 +66,11 @@ Tags can be also viewed, created, edited, or deleted from the **Edit -> Manage T
### :material-tag-search: Tag Bar: Searching for Tags
Pressing <kbd>Enter</kbd> with **one or more results** will **apply the first tag** to your selection (assuming it's not already on your selection).
Pressing <kbd>Enter</kbd> with **one or more results** will **apply** :material-tag: the underlined tag to your selection (assuming it's not already on your selection).
Tags that are already present on your entries will be greyed out and ignored when pressing <kbd>Enter</kbd>. This lets you visually confirm that a tag already exists and has been applied to the selection.
Matching tags that are **already applied** :material-tag-check: to your entries will be greyed out placed at the end of the autofill results. This lets you visually confirm that a tag already exists and has been applied to the selection. Applying them again with <kbd>Enter</kbd> has no effect other than closing the search.
You can navigate the list of autofill suggestions using the scroll wheel, and change the selected tag by pressing <kbd>Tab</kbd> or <kbd>Shift</kbd>+<kbd>Tab</kbd> to navigate right and left, respectively.
<figure markdown="span">
![Empty File Entry](assets/tag_field_bars/tag_bar_search_match.png){ width=80% }
@@ -75,13 +81,17 @@ Tags that are already present on your entries will be greyed out and ignored whe
#### :material-tag-arrow-up: Force Create Tag
Holding <kbd>Shift</kbd> while pressing <kbd>Enter</kbd> will **force create** a tag from the search bar text, regardless of the results. This is indicated by the first tag in the results becoming greyed out. This is especially useful when you need to create a new tag with a name that partially overlaps with an existing tag's name.
Holding <kbd>Shift</kbd> while pressing <kbd>Enter</kbd> will **force create** :material-tag-plus-outline: a tag from the search bar text, regardless of the search results. This is especially useful when you need to create a new tag with a name that partially overlaps with an existing tag's name.
<figure markdown="span">
![Empty File Entry](assets/tag_field_bars/tag_bar_shift_off.png){ width=80% }
<figcaption>Pressing <kbd>Enter</kbd> will apply the underlined tag, "Cube".</figcaption>
<figcaption markdown="span">Pressing <kbd>Enter</kbd> will **apply** :material-tag: the underlined tag, "Cube".</figcaption>
![Empty File Entry](assets/tag_field_bars/tag_bar_shift_on.png){ width=80% }
<figcaption>With <kbd>Shift</kbd> held, pressing <kbd>Enter</kbd> will <b>create a new tag</b> called "Cub".</figcaption>
<figcaption markdown="span">
<kbd>Shift</kbd>+<kbd>Enter</kbd> will **create** :material-tag-plus-outline: a new tag called "Cub".
<br>
Note the the greyed-out tag, showing it will not be applied.
</figcaption>
</figure>
---
@@ -90,7 +100,7 @@ Holding <kbd>Shift</kbd> while pressing <kbd>Enter</kbd> will **force create** a
[Fields](./fields.md) are extra pieces of information you can add to file entries, such as titles, comments, notes, specific dates or times. Unlike tags, fields are based on [templates](./fields.md#field-templates) that contain pre-filled information such as the field type and title, and that information is _copied_ to fields when adding them to entries. Editing field information on entries **does not** modify the template it was created from.
Creating and adding fields to entries is extremely similar to [how it works for tags](#tagging), with a few notable differences:
Creating and adding fields to entries is extremely similar to how [tagging](#tagging) works, with a few notable differences:
- Click "Add Field" or press <kbd>Ctrl</kbd>+<kbd>L</kbd> to open the field bar.
- The same template can by applied to an entry any number of times.
+1 -1
View File
@@ -9,7 +9,7 @@ build-backend = "hatchling.build"
[project]
name = "TagStudio"
description = "A User-Focused Photo & File Management System."
version = "9.6.2"
version = "9.6.4"
license = "GPL-3.0-only"
readme = "README.md"
requires-python = ">=3.12,<3.14"
@@ -4,6 +4,11 @@
from sqlalchemy import text
from tagstudio.core.library.alchemy.fields import (
DatetimeFieldTemplate,
TextFieldTemplate,
)
SQL_FILENAME: str = "ts_library.sqlite"
JSON_FILENAME: str = "ts_library.json"
@@ -32,3 +37,15 @@ WITH RECURSIVE ChildTags AS (
)
SELECT tag_id FROM ChildTags;
""")
DEFAULT_FIELD_TEMPLATES = (
TextFieldTemplate(name="Title"),
TextFieldTemplate(name="Author"),
TextFieldTemplate(name="Artist"),
TextFieldTemplate(name="URL"),
TextFieldTemplate(name="Description", is_multiline=True),
TextFieldTemplate(name="Notes", is_multiline=True),
TextFieldTemplate(name="Comments", is_multiline=True),
DatetimeFieldTemplate(name="Date"),
)
+1 -39
View File
@@ -6,12 +6,9 @@ from pathlib import Path
from typing import override
import structlog
from sqlalchemy import Dialect, Engine, String, TypeDecorator, create_engine, text
from sqlalchemy.exc import OperationalError
from sqlalchemy import Dialect, String, TypeDecorator
from sqlalchemy.orm import DeclarativeBase
from tagstudio.core.constants import RESERVED_TAG_END
logger = structlog.getLogger(__name__)
@@ -34,38 +31,3 @@ class PathType(TypeDecorator):
class Base(DeclarativeBase):
type_annotation_map = {Path: PathType}
def make_engine(connection_string: str) -> Engine:
return create_engine(connection_string)
def make_tables(engine: Engine) -> None:
logger.info("[Library] Creating DB tables...")
with engine.connect() as conn:
# TODO: this should instead be migrations that create the exact tables that were added in
# the respective DB versions
Base.metadata.create_all(conn)
conn.commit()
# TODO: this needs to be a migration
# tag IDs < 1000 are reserved
# create tag and delete it to bump the autoincrement sequence
# TODO - find a better way
# is this the better way?
result = conn.execute(text("SELECT SEQ FROM sqlite_sequence WHERE name='tags'"))
autoincrement_val = result.scalar()
if not autoincrement_val or autoincrement_val <= RESERVED_TAG_END:
try:
conn.execute(
text(
"INSERT INTO tags "
"(id, name, color_namespace, color_slug, is_category, is_hidden) VALUES "
f"({RESERVED_TAG_END}, 'temp', NULL, NULL, false, false)"
)
)
conn.execute(text(f"DELETE FROM tags WHERE id = {RESERVED_TAG_END}"))
conn.commit()
except OperationalError as e:
logger.error("Could not initialize built-in tags", error=e)
conn.rollback()
+59 -454
View File
@@ -2,11 +2,6 @@
# SPDX-License-Identifier: GPL-3.0-only
# NOTE: This file contains necessary use of deprecated first-party code until that
# code is removed in a future version (prefs).
# pyright: reportDeprecated=false
import re
import shutil
import sys
@@ -21,7 +16,6 @@ from typing import TYPE_CHECKING
import sqlalchemy
import structlog
import ujson
from humanfriendly import format_timespan # pyright: ignore[reportUnknownVariableType]
from sqlalchemy import (
URL,
@@ -44,7 +38,7 @@ from sqlalchemy import (
update,
)
from sqlalchemy.dialects import sqlite
from sqlalchemy.exc import IntegrityError
from sqlalchemy.exc import IntegrityError, OperationalError
from sqlalchemy.orm import (
InstanceState,
Session,
@@ -72,16 +66,13 @@ from tagstudio.core.library.alchemy.constants import (
DB_VERSION,
DB_VERSION_CURRENT_KEY,
DB_VERSION_INITIAL_KEY,
DEFAULT_FIELD_TEMPLATES,
JSON_FILENAME,
SQL_FILENAME,
TAG_CHILDREN_QUERY,
)
from tagstudio.core.library.alchemy.db import make_tables
from tagstudio.core.library.alchemy.enums import (
MAX_SQL_VARIABLES,
BrowsingState,
SortingModeEnum,
)
from tagstudio.core.library.alchemy.db import Base as ModelBase
from tagstudio.core.library.alchemy.enums import MAX_SQL_VARIABLES, BrowsingState, SortingModeEnum
from tagstudio.core.library.alchemy.fields import (
LEGACY_FIELD_MAP,
BaseField,
@@ -92,6 +83,7 @@ from tagstudio.core.library.alchemy.fields import (
TextFieldTemplate,
)
from tagstudio.core.library.alchemy.joins import TagEntry, TagParent
from tagstudio.core.library.alchemy.migrations import DBMigrations, MigrationError
from tagstudio.core.library.alchemy.models import (
Entry,
Namespace,
@@ -104,7 +96,6 @@ from tagstudio.core.library.alchemy.visitors import SQLBoolExpressionBuilder
from tagstudio.core.library.ignore import migrate_ext_list
from tagstudio.core.library.json.library import Library as JsonLibrary
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.translations import Translations
if TYPE_CHECKING:
from sqlalchemy import Select
@@ -170,20 +161,6 @@ def get_default_tags() -> tuple[Tag, ...]:
return archive_tag, favorite_tag, meta_tag
def get_default_field_templates() -> tuple[BaseFieldTemplate, ...]:
"""Return the default field templates for a new TagStudio library."""
title = TextFieldTemplate(name="Title")
author = TextFieldTemplate(name="Author")
artist = TextFieldTemplate(name="Artist")
url = TextFieldTemplate(name="URL")
description = TextFieldTemplate(name="Description", is_multiline=True)
notes = TextFieldTemplate(name="Notes", is_multiline=True)
comments = TextFieldTemplate(name="Comments", is_multiline=True)
date = DatetimeFieldTemplate(name="Date")
return title, author, artist, url, description, notes, comments, date
# The difference in the number of default JSON tags vs default tags in the current version.
DEFAULT_TAG_DIFF: int = len(get_default_tags()) - len([TAG_ARCHIVED, TAG_FAVORITE])
@@ -431,21 +408,41 @@ class Library:
self, library_dir: Path, in_memory: bool, sql_filename: str = SQL_FILENAME
) -> LibraryStatus:
self.engine = self.__get_engine(library_dir, in_memory, sql_filename)
loaded_db_version: int = 0
logger.info(
"[Library] Opening SQLite Library",
library_dir=library_dir,
)
logger.info(f"[Library] Library DB version: {loaded_db_version}")
make_tables(self.engine)
logger.info("[Library] Creating DB tables...")
with self.engine.connect() as conn:
ModelBase.metadata.create_all(conn)
conn.commit()
# TODO - find a better way
# is this the better way?
# Could we perhaps update the row we are reading from here?
result = conn.execute(text("SELECT SEQ FROM sqlite_sequence WHERE name='tags'"))
autoincrement_val = result.scalar()
if not autoincrement_val or autoincrement_val <= RESERVED_TAG_END:
try:
conn.execute(
text(
"INSERT INTO tags "
"(id, name, color_namespace, color_slug, is_category, is_hidden) "
f"VALUES ({RESERVED_TAG_END}, 'temp', NULL, NULL, false, false)"
)
)
conn.execute(text(f"DELETE FROM tags WHERE id = {RESERVED_TAG_END}"))
conn.commit()
except OperationalError as e:
logger.error("Could not initialize built-in tags", error=e)
conn.rollback()
with Session(self.engine) as session:
# Add default tag color namespaces.
namespaces = default_color_groups.namespaces()
# TODO: are all of these commits necessary?
session.add_all(namespaces)
session.flush()
@@ -465,7 +462,7 @@ class Library:
session.flush()
# Add default field templates
for template in get_default_field_templates():
for template in DEFAULT_FIELD_TEMPLATES:
session.add(template)
session.flush()
@@ -506,414 +503,25 @@ class Library:
def open_sqlite_library(
self, library_dir: Path, in_memory: bool, sql_filename: str = SQL_FILENAME
) -> LibraryStatus:
logger.info("[Library] Opening SQLite Library", library_dir=library_dir)
self.engine = self.__get_engine(library_dir, in_memory, sql_filename)
loaded_db_version: int = 0
initial_db_version: int = DB_VERSION
logger.info(
"[Library] Opening SQLite Library",
library_dir=library_dir,
)
try:
migrations = DBMigrations(library_dir, self.engine)
# Don't check DB version when creating new library
loaded_db_version = self.get_version(DB_VERSION_CURRENT_KEY)
initial_db_version = self.get_version(DB_VERSION_INITIAL_KEY)
# save backup if patches will be applied
if migrations.required:
Library.save_library_backup_to_disk(library_dir)
# ======================== Library Database Version Checking =======================
# DB_VERSION 6 is the first supported SQLite DB version.
# If the DB_VERSION is >= 100, that means it's a compound major + minor version.
# - Dividing by 100 and flooring gives the major (breaking changes) version.
# - If a DB has major version higher than the current program, don't load it.
# - If only the minor version is higher, it's still allowed to load.
if loaded_db_version < 6 or (
loaded_db_version >= 100 and loaded_db_version // 100 > DB_VERSION // 100
):
mismatch_text = Translations["status.library_version_mismatch"]
found_text = Translations["status.library_version_found"]
expected_text = Translations["status.library_version_expected"]
return LibraryStatus(
success=False,
message=(
f"{mismatch_text}\n"
f"{found_text} v{loaded_db_version}, "
f"{expected_text} v{DB_VERSION}"
),
)
logger.info(f"[Library] Library DB version: {loaded_db_version}")
# TODO: this is very sketchy; blindly creating all tables the newest DB version should have
# without considering what version the DB is currently on and then doing all of the
# migrations after that seems like it could cause problems in some scenarios.
# instead only have this on creation and create new tables as part of migrations
# Note: this actually produces an error and fails to initialise built-in tags when opening
# a library that doesn't yet have the is_hidden property on the tags table
make_tables(self.engine)
# save backup if patches will be applied
if loaded_db_version < DB_VERSION:
self.library_dir = library_dir
self.save_library_backup_to_disk()
self.library_dir = None
# migrate DB step by step from one version to the next
# (migration_method, db_version, initial_db_version)
migrations = [
(self.__apply_db7_migration, 7, None), # changes: value_type, tags
(self.__apply_db8_migration, 8, None), # changes: tag_colors
(self.__apply_db9_migration, 9, None), # changes: entries
(self.__apply_db100_migration, 100, None), # changes: tag_parents
(self.__apply_db101_migration, 101, None), # changes: versions
(self.__apply_db102_migration, 102, None), # changes: tag_parents
(self.__apply_db103_migration, 103, None), # changes: tags
(self.__apply_db104_migration, 104, None), # changes: deletes preferences
(self.__apply_db200_migration, 200, None), # changes: field tables
(self.__apply_db201_migration, 201, 200), # changes: field tables
(self.__apply_db202_migration, 202, None), # changes: tag_parents
(self.__apply_db300_migration, 300, None), # changes: deletes folders
]
for migration, v, iv in migrations:
if loaded_db_version < v and (iv is None or initial_db_version < iv):
logger.info(f"[Library][Migration][{v}] Starting DB Migration")
with Session(self.engine) as session:
# any error causes transaction to rollback
migration(session, library_dir)
loaded_db_version = v
self.set_version(session, DB_VERSION_CURRENT_KEY, v)
session.commit()
logger.info(f"[Library][Migration][{v}] Completed DB Migration")
assert loaded_db_version >= DB_VERSION, (
"Ran all migrations, but the DB is still not on the newest version"
)
logger.info(f"[Library] Library migrated to DB version {DB_VERSION}")
migrations.run()
except MigrationError as e:
return LibraryStatus(success=False, message=e.args[0])
# everything is fine, set the library path
self.library_dir = library_dir
return LibraryStatus(success=True, library_path=library_dir)
def __apply_db7_migration(self, session: Session, _library_dir: Path):
"""Migrate DB from DB_VERSION 6 to 7."""
logger.info("[Library][Migration][7] Applying patches to DB_VERSION: 6 library...")
# Repair tags that may have a disambiguation_id pointing towards a deleted tag.
# TODO: combine into single sql statement
all_tag_ids = session.scalars(text("SELECT DISTINCT id FROM tags")).all()
disam_stmt = (
update(Tag)
.where(Tag.disambiguation_id.not_in(all_tag_ids))
.values(disambiguation_id=None)
)
session.execute(disam_stmt)
session.flush()
def __apply_db8_migration(self, session: Session, library_dir: Path):
"""Migrate DB from DB_VERSION 7 to 8."""
# Add the missing color_border column to the TagColorGroups table.
session.execute(
text("ALTER TABLE tag_colors ADD COLUMN color_border BOOLEAN DEFAULT FALSE NOT NULL")
)
session.flush()
logger.info("[Library][Migration][8] Added color_border column to tag_colors table")
# collect new default tag colors
tag_colors: list[TagColorGroup] = [
color
for color in default_color_groups.shades()
if color.slug in ["burgundy", "dark-teal", "dark_lavender"]
]
# Add any new default colors introduced in DB_VERSION 8
for color in tag_colors:
session.add(color)
session.flush()
logger.info(
"[Library][Migration][8] Migrated tag colors to DB_VERSION 8+",
color_name=tag_colors,
)
# Update Neon colors to use the the color_border property
for color in default_color_groups.neon():
neon_stmt = (
update(TagColorGroup)
.where(
and_(
TagColorGroup.namespace == color.namespace,
TagColorGroup.slug == color.slug,
)
)
.values(
slug=color.slug,
namespace=color.namespace,
name=color.name,
primary=color.primary,
secondary=color.secondary,
color_border=color.color_border,
)
)
session.execute(neon_stmt)
session.flush()
def __apply_db9_migration(self, session: Session, library_dir: Path):
"""Migrate DB from DB_VERSION 8 to 9."""
# Apply database schema changes
add_filename_column = text(
"ALTER TABLE entries ADD COLUMN filename TEXT NOT NULL DEFAULT ''"
)
session.execute(add_filename_column)
session.flush()
logger.info("[Library][Migration][9] Added filename column to entries table")
# Populate the new filename column.
for entry in self.__all_entries(session):
entry.filename = entry.path.name
session.merge(entry)
session.flush()
logger.info("[Library][Migration][9] Populated filename column in entries table")
def __apply_db100_migration(self, session: Session, library_dir: Path):
"""Migrate DB to DB_VERSION 100."""
# Repair parent-child tag relationships that are the wrong way around.
stmt = update(TagParent).values(
parent_id=TagParent.child_id,
child_id=TagParent.parent_id,
)
session.execute(stmt)
session.flush()
logger.info("[Library][Migration][100] Refactored TagParent table")
def __apply_db101_migration(self, session: Session, library_dir: Path):
"""Migrate DB to DB_VERSION 101."""
# Ensure version rows are present
session.add(Version(key=DB_VERSION_INITIAL_KEY, value=100))
session.flush()
def __apply_db102_migration(self, session: Session, library_dir: Path):
"""Migrate DB to DB_VERSION 102."""
# delete TagParents with a dangling parent reference
stmt = delete(TagParent).where(TagParent.parent_id.not_in(select(Tag.id).distinct()))
session.execute(stmt)
session.flush()
logger.info("[Library][Migration][102] Verified TagParent table data")
def __apply_db103_migration(self, session: Session, library_dir: Path):
"""Migrate DB from DB_VERSION 102 to 103."""
# add the new hidden column for tags
session.execute(text("ALTER TABLE tags ADD COLUMN is_hidden BOOLEAN NOT NULL DEFAULT 0"))
session.flush()
logger.info("[Library][Migration][103] Added is_hidden column to tags table")
# mark the "Archived" tag as hidden
session.query(Tag).filter(Tag.id == TAG_ARCHIVED).update({"is_hidden": True})
session.flush()
logger.info("[Library][Migration][103] Updated archived tag to be hidden")
def __apply_db104_migration(self, session: Session, library_dir: Path):
"""Migrate DB from DB_VERSION 103 to 104."""
# Convert file extension list to ts_ignore file, if a .ts_ignore file does not exist
self.__migrate_sql_to_ts_ignore(session, library_dir)
session.execute(text("DROP TABLE preferences"))
session.flush()
def __migrate_sql_to_ts_ignore(self, session: Session, library_dir: Path):
# Do not continue if existing '.ts_ignore' file is found
ts_ignore = library_dir / TS_FOLDER_NAME / IGNORE_NAME
if Path(ts_ignore).exists():
return
# Load legacy extension data
extensions: list[str] = ujson.loads(
unwrap(
session.scalar(text("SELECT value FROM preferences WHERE key = 'EXTENSION_LIST'"))
)
)
is_exclude_list: bool = unwrap(
session.scalar(text("SELECT value FROM preferences WHERE key = 'IS_EXCLUDE_LIST'"))
)
with open(ts_ignore, "w") as f:
f.write(migrate_ext_list(extensions, is_exclude_list))
def __apply_db200_migration(self, session: Session, library_dir: Path):
"""Migrate DB to DB_VERSION 200."""
# Drop unused 'boolean_fields' and 'value_type' tables
logger.info("[Library][Migration][200] Dropping boolean_fields and value_type tables...")
session.execute(text("DROP TABLE boolean_fields"))
session.execute(text("DROP TABLE value_type"))
# Add 'name' column to text_fields and datetime_fields tables
logger.info("[Library][Migration][200] Adding name columns to field tables...")
stmt = text('ALTER TABLE text_fields ADD COLUMN name VARCHAR DEFAULT ""')
session.execute(stmt)
stmt = text('ALTER TABLE datetime_fields ADD COLUMN name VARCHAR DEFAULT ""')
session.execute(stmt)
# Drop unnecessary 'position' columns
logger.info("[Library][Migration][200] Dropping position columns to field tables...")
session.execute(text("ALTER TABLE datetime_fields DROP COLUMN position"))
session.execute(text("ALTER TABLE text_fields DROP COLUMN position"))
# Add 'is_multiline' column to text_fields table
logger.info("[Library][Migration][200] Adding is_multiline column to text_fields...")
stmt = text("ALTER TABLE text_fields ADD COLUMN is_multiline BOOLEAN NOT NULL DEFAULT 0")
session.execute(stmt)
session.flush()
# Move values from old `type_key` columns into new `name` columns
logger.info("[Library][Migration][200] Moving values from type_key columns to name...")
session.execute(text("UPDATE text_fields SET name = type_key"))
session.execute(text("UPDATE datetime_fields SET name = type_key"))
session.flush()
# Change `name` values to title case
logger.info("[Library][Migration][200] Normalizing TextField names...")
for text_field in session.execute(select(TextField)).scalars():
# NOTE: The only exception to the "Title Case" conversion is the "URL" field.
text_field.name = text_field.name.title().replace("Url", "URL").replace("_", " ")
logger.info("[Library][Migration][200] Normalizing DatetimeField names...")
for datetime_field in session.execute(select(DatetimeField)).scalars():
datetime_field.name = datetime_field.name.title().replace("_", " ")
session.flush()
# Add correct `is_multiline` values to text_fields table
logger.info("[Library][Migration][200] Updating is_multiline for legacy TEXT_BOXes...")
text_boxes = [
x.get("name") for x in LEGACY_FIELD_MAP.values() if x.get("is_multiline") is True
]
update_stmt = (
update(TextField).where(TextField.name.in_(text_boxes)).values(is_multiline=True)
)
session.execute(update_stmt)
session.flush()
# Repair legacy "Description" fields to use is_multiline = True
logger.info("[Library][Migration][200] Repairing legacy Description fields...")
desc_stmt = (
update(TextField)
.where(TextField.name == "Description" and TextField.is_multiline == False) # noqa: E712
.values(is_multiline=True)
)
session.execute(desc_stmt)
# Repair legacy "Comments" fields to use is_multiline = True
logger.info("[Library][Migration][200] Repairing legacy Comment fields...")
comm_stmt = (
update(TextField)
.where(TextField.name == "Comments" and TextField.is_multiline == False) # noqa: E712
.values(is_multiline=True)
)
session.execute(comm_stmt)
# Add default field templates
logger.info("[Library][Migration][200] Adding default field templates...")
for template in get_default_field_templates():
session.add(template)
session.flush()
# DB indices for improved performance
session.execute(
text("CREATE INDEX IF NOT EXISTS idx_tags_name_shorthand ON tags (name, shorthand)")
)
session.execute(
text("CREATE INDEX IF NOT EXISTS idx_tag_parents_child_id ON tag_parents (child_id)")
)
session.execute(
text("CREATE INDEX IF NOT EXISTS idx_tag_entries_entry_id ON tag_entries (entry_id)")
)
def __apply_db201_migration(self, session: Session, library_dir: Path):
"""Migrate DB to DB_VERSION 201."""
create_text_fields_table = text("""
CREATE TABLE text_fields_new (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
name VARCHAR NOT NULL,
entry_id INTEGER NOT NULL,
value VARCHAR,
is_multiline BOOLEAN NOT NULL,
FOREIGN KEY(entry_id) REFERENCES entries (id)
)
""")
create_datetime_fields_table = text("""
CREATE TABLE datetime_fields_new (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
name VARCHAR NOT NULL,
entry_id INTEGER NOT NULL,
value VARCHAR,
FOREIGN KEY(entry_id) REFERENCES entries (id)
)
""")
logger.info("[Library][Migration][201] Dropping type_key from text_fields table...")
session.execute(create_text_fields_table)
session.flush()
session.execute(
text("""
INSERT INTO text_fields_new (id, name, entry_id, value, is_multiline)
SELECT id, name, entry_id, value, is_multiline
FROM text_fields
""")
)
session.execute(text("DROP TABLE text_fields"))
session.execute(text("ALTER TABLE text_fields_new RENAME TO text_fields"))
logger.info("[Library][Migration][201] Dropping type_key from datetime_fields table...")
session.execute(create_datetime_fields_table)
session.flush()
session.execute(
text("""
INSERT INTO datetime_fields_new (id, name, entry_id, value)
SELECT id, name, entry_id, value
FROM datetime_fields
""")
)
session.execute(text("DROP TABLE datetime_fields"))
session.execute(text("ALTER TABLE datetime_fields_new RENAME TO datetime_fields"))
session.flush()
def __apply_db202_migration(self, session: Session, library_dir: Path):
"""Migrate DB to DB_VERSION 202."""
stmt = delete(TagParent).where(TagParent.child_id.not_in(select(Tag.id).distinct()))
session.execute(stmt)
session.flush()
logger.info("[Library][Migration][202] Verified TagParent table data")
def __apply_db300_migration(self, session: Session, library_dir: Path):
## remove folder_id column from entries table
# create new table in the desired scheme (without folder_id column)
session.execute(
text("""
CREATE TABLE entries_new (
id INTEGER NOT NULL,
path VARCHAR NOT NULL,
suffix VARCHAR NOT NULL,
date_created DATETIME,
date_modified DATETIME,
date_added DATETIME,
filename TEXT NOT NULL DEFAULT '',
PRIMARY KEY (id),
UNIQUE (path)
)
""")
)
session.flush()
# transfer data to new table
session.execute(
text("""
INSERT INTO entries_new (id, path, suffix, date_created, date_modified, date_added,
filename)
SELECT id, path, suffix, date_created, date_modified, date_added, filename
FROM entries
""")
)
# delete old table
session.execute(text("DROP TABLE entries"))
# rename new table to old table
session.execute(text("ALTER TABLE entries_new RENAME TO entries"))
session.flush()
## drop table "folders"
session.execute(text("DROP TABLE folders"))
session.flush()
@property
def field_templates(self) -> Sequence[BaseFieldTemplate]:
with Session(self.engine) as session:
@@ -1061,7 +669,8 @@ class Library:
with Session(self.engine) as session:
return unwrap(session.scalar(select(func.count(Entry.id))))
def __all_entries(self, session: Session, with_joins: bool = False) -> Iterator[Entry]:
@staticmethod
def _all_entries(session: Session, with_joins: bool = False) -> Iterator[Entry]:
"""Load entries without joins."""
stmt = select(Entry)
if with_joins:
@@ -1090,7 +699,7 @@ class Library:
def all_entries(self, with_joins: bool = False) -> Iterator[Entry]:
"""Load entries without joins."""
with Session(self.engine) as session:
return self.__all_entries(session, with_joins)
yield from Library._all_entries(session, with_joins)
@property
def tags(self) -> list[Tag]:
@@ -1838,16 +1447,17 @@ class Library:
session.rollback()
return None
def save_library_backup_to_disk(self) -> Path:
assert isinstance(self.library_dir, Path)
makedirs(str(self.library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME), exist_ok=True)
@staticmethod
def save_library_backup_to_disk(library_dir: Path) -> Path:
assert isinstance(library_dir, Path)
makedirs(str(library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME), exist_ok=True)
filename = f"ts_library_backup_{datetime.now(UTC).strftime('%Y_%m_%d_%H%M%S')}.sqlite"
target_path = self.library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME / filename
target_path = library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME / filename
shutil.copy2(
self.library_dir / TS_FOLDER_NAME / SQL_FILENAME,
library_dir / TS_FOLDER_NAME / SQL_FILENAME,
target_path,
)
@@ -2060,8 +1670,10 @@ class Library:
)
session.execute(update_tags_stmt)
session.commit()
else:
self.add_color(new_color_group)
return
# "if not existing_color", out of the session context
self.add_color(new_color_group)
def update_aliases(self, tag: Tag, aliases: Iterable[TagAlias], session: Session) -> bool:
"""Update TagAliases for a given Tag."""
@@ -2139,8 +1751,12 @@ class Library:
Args:
key(str): The key for the name of the version type to set.
"""
with Session(self.engine) as session:
engine = sqlalchemy.inspect(self.engine)
return Library._get_version(self.engine, key)
@staticmethod
def _get_version(engine, key: str) -> int:
with Session(engine) as session:
engine = sqlalchemy.inspect(engine)
try:
# "Version" table added in DB_VERSION 101
if engine and engine.has_table("versions"):
@@ -2160,17 +1776,6 @@ class Library:
except Exception:
return 0
def set_version(self, session: Session, key: str, value: int) -> None:
"""Set a version value to the DB.
Args:
session(Session): The SQLAlchemy DB Session to use.
key(str): The key for the name of the version type to set.
value(int): The version value to set.
"""
# Insert if key has no value yet, otherwise update the value
session.merge(Version(key=key, value=value))
def mirror_entry_fields(self, entries: list[Entry]) -> None:
"""Mirror fields among multiple Entry items."""
all_fields: set[BaseField] = set()
@@ -0,0 +1,580 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: MIT
from collections.abc import Callable
from pathlib import Path
from typing import override
import structlog
import ujson
from sqlalchemy import Engine, and_, delete, select, text, update
from sqlalchemy.orm import Session
from tagstudio.core.constants import IGNORE_NAME, TAG_ARCHIVED, TS_FOLDER_NAME
from tagstudio.core.library.alchemy import default_color_groups
from tagstudio.core.library.alchemy.constants import (
DB_VERSION,
DB_VERSION_CURRENT_KEY,
DB_VERSION_INITIAL_KEY,
DEFAULT_FIELD_TEMPLATES,
)
from tagstudio.core.library.alchemy.fields import LEGACY_FIELD_MAP, DatetimeField, TextField
from tagstudio.core.library.alchemy.joins import TagParent
from tagstudio.core.library.alchemy.models import Tag, TagColorGroup, Version
from tagstudio.core.library.ignore import migrate_ext_list
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.translations import Translations
logger = structlog.get_logger(__name__)
LoggingMethod = Callable[[str], str]
class MigrationError(Exception):
pass
class DBMigration:
version: int
initial_version: int | None = None
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError
class DBMigrations:
def __init__(self, library_dir: Path, engine: Engine) -> None:
# TODO: Remove local import and don't make calls to private methods.
from tagstudio.core.library.alchemy.library import Library
self.library_dir = library_dir
self.engine = engine
# Don't check DB version when creating new library
self.loaded_db_version = Library._get_version(engine, DB_VERSION_CURRENT_KEY)
self.initial_db_version = Library._get_version(engine, DB_VERSION_INITIAL_KEY)
# ======================== Library Database Version Checking =======================
# DB_VERSION 6 is the first supported SQLite DB version.
# If the DB_VERSION is >= 100, that means it's a compound major + minor version.
# - Dividing by 100 and flooring gives the major (breaking changes) version.
# - If a DB has major version higher than the current program, don't load it.
# - If only the minor version is higher, it's still allowed to load.
if self.loaded_db_version < 6 or (
self.loaded_db_version >= 100 and self.loaded_db_version // 100 > DB_VERSION // 100
):
mismatch_text = Translations["status.library_version_mismatch"]
found_text = Translations["status.library_version_found"]
expected_text = Translations["status.library_version_expected"]
raise MigrationError(
f"{mismatch_text}\n"
f"{found_text} v{self.loaded_db_version}, "
f"{expected_text} v{DB_VERSION}"
)
logger.info(
f"[Library][Migration] "
f"Opening Library with DB Version {self.loaded_db_version}/{DB_VERSION}"
)
@property
def required(self) -> bool:
return self.loaded_db_version < DB_VERSION
def run(self):
# migrate DB step by step from one version to the next
# (migration_method, db_version, initial_db_version)
migrations: list[type[DBMigration]] = [
MigrationTo7, # changes: value_type, tags
MigrationTo8, # changes: tag_colors
MigrationTo9, # changes: entries
MigrationTo100, # changes: tag_parents
MigrationTo101, # changes: versions
MigrationTo102, # changes: tag_parents
MigrationTo103, # changes: tags
MigrationTo104, # changes: deletes preferences
MigrationTo200, # changes: field tables
MigrationTo201, # changes: field tables
MigrationTo202, # changes: tag_parents
MigrationTo300, # changes: deletes folders
]
with Session(self.engine) as session:
if self.loaded_db_version > DB_VERSION:
return
for migration in migrations:
if self.loaded_db_version < migration.version and (
migration.initial_version is None
or self.initial_db_version < migration.initial_version
):
logger.info(f"[Library][Migration][{migration.version}] Starting DB Migration")
# any error causes transaction to rollback
migration.run(
session,
self.library_dir,
lambda msg, v=migration.version: f"[Library][Migration][{v}] {msg}",
)
self.loaded_db_version = migration.version
try:
self._set_version(session, DB_VERSION_CURRENT_KEY, migration.version)
logger.info(
f"[Library][Migration][{migration.version}] Completed DB Migration"
)
except Exception as e:
logger.info(
f"[Library][Migration][{migration.version}] "
"Couldn't update version, continuing without commit",
error=e,
)
session.flush()
else:
session.commit()
assert self.loaded_db_version >= DB_VERSION, (
"Ran all migrations, but the DB is still not on the newest version"
)
def _set_version(self, session: Session, key: str, value: int) -> None:
"""Set a version value to the DB.
Args:
session(Session): The SQLAlchemy DB Session to use.
key(str): The key for the name of the version type to set.
value(int): The version value to set.
"""
# Insert if key has no value yet, otherwise update the value
session.merge(Version(key=key, value=value))
class MigrationTo7(DBMigration):
version = 7
@override
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod):
"""Migrate DB from DB_VERSION 6 to 7."""
logger.info(fmt_log("Applying patches to DB_VERSION: 6 library..."))
# Repair tags that may have a disambiguation_id pointing towards a deleted tag.
# TODO: combine into single sql statement
all_tag_ids = session.scalars(text("SELECT DISTINCT id FROM tags")).all()
disam_stmt = (
update(Tag)
.where(Tag.disambiguation_id.not_in(all_tag_ids))
.values(disambiguation_id=None)
)
session.execute(disam_stmt)
session.flush()
class MigrationTo8(DBMigration):
version = 8
@override
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod):
"""Migrate DB from DB_VERSION 7 to 8."""
# Add the missing color_border column to the TagColorGroups table.
session.execute(
text("ALTER TABLE tag_colors ADD COLUMN color_border BOOLEAN DEFAULT FALSE NOT NULL")
)
session.flush()
logger.info(fmt_log("Added color_border column to tag_colors table"))
# collect new default tag colors
tag_colors: list[TagColorGroup] = [
color
for color in default_color_groups.shades()
if color.slug in ["burgundy", "dark-teal", "dark_lavender"]
]
# Add any new default colors introduced in DB_VERSION 8
for color in tag_colors:
session.add(color)
session.flush()
logger.info(
fmt_log("Migrated tag colors to DB_VERSION 8+"),
color_name=tag_colors,
)
# Update Neon colors to use the the color_border property
for color in default_color_groups.neon():
neon_stmt = (
update(TagColorGroup)
.where(
and_(
TagColorGroup.namespace == color.namespace,
TagColorGroup.slug == color.slug,
)
)
.values(
slug=color.slug,
namespace=color.namespace,
name=color.name,
primary=color.primary,
secondary=color.secondary,
color_border=color.color_border,
)
)
session.execute(neon_stmt)
session.flush()
class MigrationTo9(DBMigration):
version = 9
@override
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod):
"""Migrate DB from DB_VERSION 8 to 9."""
# Apply database schema changes
add_filename_column = text(
"ALTER TABLE entries ADD COLUMN filename TEXT NOT NULL DEFAULT ''"
)
session.execute(add_filename_column)
session.flush()
logger.info(fmt_log("Added filename column to entries table"))
# TODO: Remove local import and don't make calls to private methods.
# Populate the new filename column.
from tagstudio.core.library.alchemy.library import Library
for entry in Library._all_entries(session):
entry.filename = entry.path.name
session.merge(entry)
session.flush()
logger.info(fmt_log("Populated filename column in entries table"))
class MigrationTo100(DBMigration):
version = 100
@override
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod):
"""Migrate DB to DB_VERSION 100."""
# Repair parent-child tag relationships that are the wrong way around.
stmt = update(TagParent).values(
parent_id=TagParent.child_id,
child_id=TagParent.parent_id,
)
session.execute(stmt)
session.flush()
logger.info(fmt_log("Refactored TagParent table"))
class MigrationTo101(DBMigration):
version = 101
@override
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod):
"""Migrate DB to DB_VERSION 101."""
# Create versions table
session.execute(
text("""
CREATE TABLE versions (
"key" VARCHAR NOT NULL PRIMARY KEY,
value INTEGER NOT NULL
)
""")
)
session.flush()
# Ensure version rows are present
session.add(Version(key=DB_VERSION_INITIAL_KEY, value=100))
session.flush()
logger.info(fmt_log("Created versions table"))
class MigrationTo102(DBMigration):
version = 102
@override
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod):
"""Migrate DB to DB_VERSION 102."""
# delete TagParents with a dangling parent reference
stmt = delete(TagParent).where(TagParent.parent_id.not_in(select(Tag.id).distinct()))
session.execute(stmt)
session.flush()
logger.info(fmt_log("Verified TagParent table data"))
class MigrationTo103(DBMigration):
version = 103
@override
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod):
"""Migrate DB from DB_VERSION 102 to 103."""
# add the new hidden column for tags
session.execute(text("ALTER TABLE tags ADD COLUMN is_hidden BOOLEAN NOT NULL DEFAULT 0"))
session.flush()
logger.info(fmt_log("Added is_hidden column to tags table"))
# mark the "Archived" tag as hidden
session.query(Tag).filter(Tag.id == TAG_ARCHIVED).update({"is_hidden": True})
session.flush()
logger.info(fmt_log("Updated archived tag to be hidden"))
class MigrationTo104(DBMigration):
version = 104
@override
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod):
"""Migrate DB from DB_VERSION 103 to 104."""
# Convert file extension list to ts_ignore file, if a .ts_ignore file does not exist
cls.__migrate_sql_to_ts_ignore(session, library_dir)
session.execute(text("DROP TABLE preferences"))
session.flush()
@classmethod
def __migrate_sql_to_ts_ignore(cls, session: Session, library_dir: Path):
# Do not continue if existing '.ts_ignore' file is found
ts_ignore = library_dir / TS_FOLDER_NAME / IGNORE_NAME
if Path(ts_ignore).exists():
return
# Load legacy extension data
extensions: list[str] = ujson.loads(
unwrap(
session.scalar(text("SELECT value FROM preferences WHERE key = 'EXTENSION_LIST'"))
)
)
is_exclude_list: bool = unwrap(
session.scalar(text("SELECT value FROM preferences WHERE key = 'IS_EXCLUDE_LIST'"))
)
with open(ts_ignore, "w") as f:
f.write(migrate_ext_list(extensions, is_exclude_list))
class MigrationTo200(DBMigration):
version = 200
@override
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod):
"""Migrate DB to DB_VERSION 200."""
# Drop unused 'boolean_fields' and 'value_type' tables
logger.info(fmt_log("Dropping boolean_fields and value_type tables..."))
session.execute(text("DROP TABLE boolean_fields"))
session.execute(text("DROP TABLE value_type"))
# Add 'name' column to text_fields and datetime_fields tables
logger.info(fmt_log("Adding name columns to field tables..."))
stmt = text('ALTER TABLE text_fields ADD COLUMN name VARCHAR DEFAULT ""')
session.execute(stmt)
stmt = text('ALTER TABLE datetime_fields ADD COLUMN name VARCHAR DEFAULT ""')
session.execute(stmt)
# Drop unnecessary 'position' columns
logger.info(fmt_log("Dropping position columns to field tables..."))
session.execute(text("ALTER TABLE datetime_fields DROP COLUMN position"))
session.execute(text("ALTER TABLE text_fields DROP COLUMN position"))
# Add 'is_multiline' column to text_fields table
logger.info(fmt_log("Adding is_multiline column to text_fields..."))
stmt = text("ALTER TABLE text_fields ADD COLUMN is_multiline BOOLEAN NOT NULL DEFAULT 0")
session.execute(stmt)
session.flush()
# Move values from old `type_key` columns into new `name` columns
logger.info(fmt_log("Moving values from type_key columns to name..."))
session.execute(text("UPDATE text_fields SET name = type_key"))
session.execute(text("UPDATE datetime_fields SET name = type_key"))
session.flush()
# Change `name` values to title case
logger.info(fmt_log("Normalizing TextField names..."))
for text_field in session.execute(select(TextField)).scalars():
# NOTE: The only exception to the "Title Case" conversion is the "URL" field.
text_field.name = text_field.name.title().replace("Url", "URL").replace("_", " ")
logger.info(fmt_log("Normalizing DatetimeField names..."))
for datetime_field in session.execute(select(DatetimeField)).scalars():
datetime_field.name = datetime_field.name.title().replace("_", " ")
session.flush()
# Add correct `is_multiline` values to text_fields table
logger.info(fmt_log("Updating is_multiline for legacy TEXT_BOXes..."))
text_boxes = [
x.get("name") for x in LEGACY_FIELD_MAP.values() if x.get("is_multiline") is True
]
update_stmt = (
update(TextField).where(TextField.name.in_(text_boxes)).values(is_multiline=True)
)
session.execute(update_stmt)
session.flush()
# Repair legacy "Description" fields to use is_multiline = True
logger.info(fmt_log("Repairing legacy Description fields..."))
desc_stmt = (
update(TextField)
.where(TextField.name == "Description" and TextField.is_multiline == False) # noqa: E712
.values(is_multiline=True)
)
session.execute(desc_stmt)
# Repair legacy "Comments" fields to use is_multiline = True
logger.info(fmt_log("Repairing legacy Comment fields..."))
comm_stmt = (
update(TextField)
.where(TextField.name == "Comments" and TextField.is_multiline == False) # noqa: E712
.values(is_multiline=True)
)
session.execute(comm_stmt)
# Add field templates tables
session.execute(
text("""
CREATE TABLE text_field_templates (
id INTEGER NOT NULL PRIMARY KEY,
is_multiline BOOLEAN NOT NULL,
name VARCHAR NOT NULL
)
""")
)
session.execute(
text("""
CREATE TABLE datetime_field_templates (
id INTEGER NOT NULL PRIMARY KEY,
name VARCHAR NOT NULL
)
""")
)
session.flush()
# Add default field templates
logger.info(fmt_log("Adding default field templates..."))
for template in DEFAULT_FIELD_TEMPLATES:
session.add(template)
session.flush()
# DB indices for improved performance
session.execute(
text("CREATE INDEX IF NOT EXISTS idx_tags_name_shorthand ON tags (name, shorthand)")
)
session.execute(
text("CREATE INDEX IF NOT EXISTS idx_tag_parents_child_id ON tag_parents (child_id)")
)
session.execute(
text("CREATE INDEX IF NOT EXISTS idx_tag_entries_entry_id ON tag_entries (entry_id)")
)
class MigrationTo201(DBMigration):
version = 201
initial_version = 200
@override
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod):
"""Migrate DB to DB_VERSION 201."""
create_text_fields_table = text("""
CREATE TABLE text_fields_new (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
name VARCHAR NOT NULL,
entry_id INTEGER NOT NULL,
value VARCHAR,
is_multiline BOOLEAN NOT NULL,
FOREIGN KEY(entry_id) REFERENCES entries (id)
)
""")
create_datetime_fields_table = text("""
CREATE TABLE datetime_fields_new (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
name VARCHAR NOT NULL,
entry_id INTEGER NOT NULL,
value VARCHAR,
FOREIGN KEY(entry_id) REFERENCES entries (id)
)
""")
logger.info(fmt_log("Dropping type_key from text_fields table..."))
session.execute(create_text_fields_table)
session.flush()
session.execute(
text("""
INSERT INTO text_fields_new (id, name, entry_id, value, is_multiline)
SELECT id, name, entry_id, value, is_multiline
FROM text_fields
""")
)
session.execute(text("DROP TABLE text_fields"))
session.execute(text("ALTER TABLE text_fields_new RENAME TO text_fields"))
logger.info(fmt_log("Dropping type_key from datetime_fields table..."))
session.execute(create_datetime_fields_table)
session.flush()
session.execute(
text("""
INSERT INTO datetime_fields_new (id, name, entry_id, value)
SELECT id, name, entry_id, value
FROM datetime_fields
""")
)
session.execute(text("DROP TABLE datetime_fields"))
session.execute(text("ALTER TABLE datetime_fields_new RENAME TO datetime_fields"))
session.flush()
class MigrationTo202(DBMigration):
version = 202
@override
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod):
"""Migrate DB to DB_VERSION 202."""
stmt = delete(TagParent).where(TagParent.child_id.not_in(select(Tag.id).distinct()))
session.execute(stmt)
session.flush()
logger.info(fmt_log("Verified TagParent table data"))
class MigrationTo300(DBMigration):
version = 300
@override
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod):
## remove folder_id column from entries table
# create new table in the desired scheme (without folder_id column)
session.execute(
text("""
CREATE TABLE entries_new (
id INTEGER NOT NULL,
path VARCHAR NOT NULL,
suffix VARCHAR NOT NULL,
date_created DATETIME,
date_modified DATETIME,
date_added DATETIME,
filename TEXT NOT NULL DEFAULT '',
PRIMARY KEY (id),
UNIQUE (path)
)
""")
)
session.flush()
# transfer data to new table
session.execute(
text("""
INSERT INTO entries_new (id, path, suffix, date_created, date_modified, date_added,
filename)
SELECT id, path, suffix, date_created, date_modified, date_added, filename
FROM entries
""")
)
# delete old table
session.execute(text("DROP TABLE entries"))
# rename new table to old table
session.execute(text("ALTER TABLE entries_new RENAME TO entries"))
session.flush()
## drop table "folders"
session.execute(text("DROP TABLE folders"))
session.flush()
+2
View File
@@ -25,6 +25,8 @@ class TagStudioCore:
@lru_cache(maxsize=1)
def get_most_recent_release_version() -> str | None:
"""Get the version of the most recent GitHub release."""
logger.info("[Core] Checking for updates...")
try:
resp = requests.get(
"https://api.github.com/repos/TagStudioDev/TagStudio/releases/latest"
@@ -5,9 +5,10 @@ from typing import override
import structlog
from PySide6 import QtCore, QtGui
from PySide6.QtCore import Signal
from PySide6.QtCore import QPoint, Signal
from PySide6.QtWidgets import (
QLineEdit,
QMenu,
QWidget,
)
@@ -22,7 +23,8 @@ logger = structlog.get_logger(__name__)
class AutofillLineEdit(QLineEdit):
return_pressed = Signal()
shift_return_pressed = Signal()
shift_holding = Signal(bool)
holding_shift = Signal(bool)
index_updated = Signal(int)
def __init__(self, popup: QWidget) -> None:
super().__init__()
@@ -39,23 +41,44 @@ class AutofillLineEdit(QLineEdit):
return super().focusInEvent(arg__1)
@override
def keyPressEvent(self, arg__1: QtGui.QKeyEvent) -> None:
if arg__1.key() == QtCore.Qt.Key.Key_Shift:
self.shift_holding.emit(True) # noqa: FBT003
def event(self, arg__1: QtCore.QEvent) -> bool:
if arg__1.type() == QtCore.QEvent.Type.KeyPress:
assert isinstance(arg__1, QtGui.QKeyEvent)
if arg__1.key() == QtCore.Qt.Key.Key_Escape:
self.setText("")
self.clearFocus()
elif arg__1.key() == QtCore.Qt.Key.Key_Enter or arg__1.key() == QtCore.Qt.Key.Key_Return:
if arg__1.modifiers() and QtCore.Qt.KeyboardModifier.ShiftModifier:
self.shift_return_pressed.emit()
else:
self.return_pressed.emit()
if arg__1.key() == QtCore.Qt.Key.Key_Tab:
self.index_updated.emit(1)
return True
elif arg__1.key() == QtCore.Qt.Key.Key_Backtab:
self.index_updated.emit(-1)
return True
return super().keyPressEvent(arg__1)
if arg__1.key() == QtCore.Qt.Key.Key_Shift:
self.holding_shift.emit(True) # noqa: FBT003
if arg__1.key() == QtCore.Qt.Key.Key_Escape:
self.setText("")
self.clearFocus()
elif (
arg__1.key() == QtCore.Qt.Key.Key_Enter or arg__1.key() == QtCore.Qt.Key.Key_Return
):
if arg__1.modifiers() == QtCore.Qt.KeyboardModifier.ShiftModifier:
self.shift_return_pressed.emit()
else:
self.return_pressed.emit()
return super().event(arg__1)
@override
def keyReleaseEvent(self, arg__1: QtGui.QKeyEvent) -> None:
if arg__1.key() == QtCore.Qt.Key.Key_Shift:
self.shift_holding.emit(False) # noqa: FBT003
self.holding_shift.emit(False) # noqa: FBT003
return super().keyReleaseEvent(arg__1)
def show_action_menu(self, pos: QPoint) -> None:
"""Show a context menu of actions."""
menu = QMenu(self)
for action in self.actions():
# Filter out icon action(s)
if action.text():
menu.addAction(action)
menu.exec(self.mapToGlobal(pos)) # pyright: ignore[reportArgumentType]
@@ -6,7 +6,7 @@ from typing import override
from warnings import catch_warnings
import structlog
from PySide6.QtGui import QAction, Qt
from PySide6.QtGui import QAction
from PySide6.QtWidgets import QWidget
from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
@@ -30,16 +30,14 @@ class FieldSuggestBox(SuggestBox[BaseFieldTemplate]):
# Context Menu Actions
edit_field_on_add_action = QAction(Translations["settings.edit_field_on_add"], self)
edit_field_on_add_action.setCheckable(True)
self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
self.addAction(edit_field_on_add_action)
self.layout().search_field.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
self.layout().search_field.addAction(edit_field_on_add_action)
edit_field_on_add_action.setChecked(self._settings.edit_field_on_add)
edit_field_on_add_action.triggered.connect(
lambda checked: self.toggle_edit_on_field_add(checked)
lambda checked: self._toggle_edit_on_field_add(checked)
)
def toggle_edit_on_field_add(self, checked: bool) -> None:
def _toggle_edit_on_field_add(self, checked: bool) -> None:
"""Toggle the setting for opening the edit window after adding a field."""
self._settings.edit_field_on_add = checked
self._settings.save()
@@ -77,7 +75,7 @@ class FieldSuggestBox(SuggestBox[BaseFieldTemplate]):
@override
def _on_item_chosen(self, item: BaseFieldTemplate) -> None:
self.item_chosen.emit(item)
self.done.emit()
self.done.emit("*")
@override
def _search_items(self, query: str) -> tuple[list[BaseFieldTemplate], list[BaseFieldTemplate]]:
@@ -86,6 +84,17 @@ class FieldSuggestBox(SuggestBox[BaseFieldTemplate]):
else:
return ([], [])
@override
def _update_hint_icon(self) -> None:
if self._is_shift_held:
self.set_hint_icon(self._rm.hint_field_create)
elif self.layout().search_field.text() and len(self._search_results) > 0:
self.set_hint_icon(self._rm.hint_field_add)
elif self.layout().search_field.text():
self.set_hint_icon(self._rm.hint_field_create)
else:
self.set_hint_icon(None)
@override
def _set_item_widget(self, item: BaseFieldTemplate | None, index: int) -> None:
"""Set the field template of a field template widget at a specific index."""
@@ -0,0 +1,35 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from typing import override
import structlog
from PySide6 import QtCore, QtGui
from PySide6.QtWidgets import QScrollArea
logger = structlog.get_logger(__name__)
class HorizontalScrollArea(QScrollArea):
"""A QScrollArea that translates vertical scrolling to horizontal movement."""
@override
def wheelEvent(self, arg__1: QtGui.QWheelEvent) -> None:
angle_y = arg__1.angleDelta().y()
pixel_y = arg__1.pixelDelta().y()
if angle_y != 0 or pixel_y != 0:
translated_event = QtGui.QWheelEvent(
arg__1.position(),
arg__1.globalPosition(),
QtCore.QPoint(pixel_y * -1, 0),
QtCore.QPoint(angle_y * -1, 0),
arg__1.buttons(),
arg__1.modifiers(),
arg__1.phase(),
arg__1.inverted(),
)
arg__1.accept()
return super().wheelEvent(translated_event)
else:
return super().wheelEvent(arg__1)
@@ -49,6 +49,7 @@ class PreviewPanel(QWidget):
super().__init__()
self._driver = driver
self._lib = self._driver.lib
self._settings = self._driver.settings
self._selected: list[int]
self._current_stats: FileAttributeData | None = None
@@ -75,13 +76,13 @@ class PreviewPanel(QWidget):
# Tag Search
self.layout().add_tag_button.clicked.connect(lambda: self._set_item_mode(_ItemMode.TAG))
self._open_tag_search_action.activated.connect(self._open_tag_search_callback)
self.layout().tag_search_box.done.connect(self._tag_added_callback)
self.layout().tag_search_box.done.connect(partial(self._tag_added_callback))
self.layout().containers.on_tags_update.connect(self._update_added_callback)
# Field Search
self.layout().add_field_button.clicked.connect(lambda: self._set_item_mode(_ItemMode.FIELD))
self._open_field_search_action.activated.connect(self._open_field_search_callback)
self.layout().field_search_box.done.connect(self._field_added_callback)
self.layout().field_search_box.done.connect(partial(self._field_added_callback))
# Previews
self.layout().preview_thumb.stats_updated.connect(self._thumb_stats_updated_callback)
@@ -122,13 +123,20 @@ class PreviewPanel(QWidget):
self.layout().add_field_button.setFocus()
self.layout().add_field_button.click()
def _tag_added_callback(self):
self._set_item_mode(None)
self.layout().add_tag_button.setFocus()
def _tag_added_callback(self, query: str):
if not query or not self._settings.keep_suggest_boxes_open:
self._set_item_mode(None)
self.layout().add_tag_button.setFocus()
else:
self._update_added_callback()
self.layout().tag_search_box.layout().search_field.setFocus()
def _field_added_callback(self):
self._set_item_mode(None)
self.layout().add_field_button.setFocus()
def _field_added_callback(self, query: str):
if not query or not self._settings.keep_suggest_boxes_open:
self._set_item_mode(None)
self.layout().add_field_button.setFocus()
else:
self.layout().field_search_box.layout().search_field.setFocus()
def _update_added_callback(self):
self.layout().tag_search_box.added = self.layout().containers.tags
+111 -31
View File
@@ -1,11 +1,13 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from functools import partial
from typing import Any, override
import structlog
from PIL import Image, ImageQt
from PySide6.QtCore import Signal
from PySide6.QtGui import QShowEvent
from PySide6.QtGui import QAction, QPixmap, QShowEvent, Qt
from PySide6.QtWidgets import QGraphicsOpacityEffect, QWidget
from tagstudio.core.library.alchemy.library import Library
@@ -13,6 +15,9 @@ from tagstudio.qt.controllers.autofill_line_edit import QtCore, QtGui
from tagstudio.qt.controllers.modal_content import ModalContent
from tagstudio.qt.controllers.underlined_widget import UnderlinedWidget
from tagstudio.qt.global_settings import GlobalSettings
from tagstudio.qt.helpers.color_overlay import auto_theme_overlay
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.stylesheets.stylesheets import (
autofill_line_edit_style,
autofill_line_edit_top_style,
@@ -42,7 +47,7 @@ def _item_name(item: object) -> str:
class SuggestBox[T](QWidget):
item_chosen = Signal(object)
done = Signal()
done = Signal(str) # Query
def __init__(
self, library: Library, settings: GlobalSettings, placeholder_text: str = ""
@@ -50,15 +55,30 @@ class SuggestBox[T](QWidget):
super().__init__()
self._lib = library
self._settings = settings
self._limit = 5
self._rm = ResourceManager()
self._limit = 25
self._is_shift_held = False
self._search_results: list[T] = []
self._selection_index = 0
self.added: list[int] = []
self.excluded: list[int] = []
self.setLayout(SuggestBoxView(placeholder_text))
self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
self._connect_callbacks()
self._keep_box_open_action = QAction(Translations["settings.keep_suggest_boxes_open"], self)
self._keep_box_open_action.setCheckable(True)
self.addAction(self._keep_box_open_action)
self.layout().search_field.addAction(self._keep_box_open_action)
self._keep_box_open_action.triggered.connect(
lambda checked: self._toggle_keep_open(checked)
)
def _toggle_keep_open(self, checked: bool) -> None:
self._settings.keep_suggest_boxes_open = checked
self._settings.save()
def set_placeholder_text(self, text: str) -> None:
self.layout().search_field.setPlaceholderText(text)
@@ -79,23 +99,70 @@ class SuggestBox[T](QWidget):
)
)
self.layout().search_field.shift_holding.connect(lambda held: self._on_shift_held(held))
self.layout().search_field.holding_shift.connect(partial(self._on_shift_held))
self.layout().search_field.index_updated.connect(partial(self._on_index_updated))
def set_hint_icon(self, icon: Image.Image | None) -> None:
if icon:
pixmap = QPixmap.fromImage(ImageQt.ImageQt(auto_theme_overlay(icon)))
self.layout().hint_icon_action.setIcon(pixmap)
else:
self.layout().hint_icon_action.setIcon(QPixmap())
def _on_shift_held(self, held: bool) -> None:
if held:
self._is_shift_held = True
opacity_effect = QGraphicsOpacityEffect(self)
opacity_effect.setOpacity(0.3)
if self.layout().content_layout.count() > 0:
underlined_widget = self.layout().content_layout.itemAt(0).widget()
assert isinstance(underlined_widget, UnderlinedWidget)
self._is_shift_held = held
for i in range(0, self.layout().content_layout.count()):
underlined_widget = self.layout().content_layout.itemAt(i).widget()
assert isinstance(underlined_widget, UnderlinedWidget)
if held and i == self._selection_index:
opacity_effect = QGraphicsOpacityEffect(self)
opacity_effect.setOpacity(0.3)
underlined_widget.widget.setGraphicsEffect(opacity_effect)
else:
self._is_shift_held = False
if self.layout().content_layout.count() > 0:
underlined_widget = self.layout().content_layout.itemAt(0).widget()
assert isinstance(underlined_widget, UnderlinedWidget)
elif (
len(self._search_results) > i
and _item_id(self._search_results[i]) not in self.added
):
underlined_widget.widget.setGraphicsEffect(None) # pyright: ignore[reportArgumentType]
self._update_hint_icon()
def _on_index_updated(self, delta: int) -> None:
# Initialize the widget count (non-hidden)
widget_count = 0
for i in range(0, self.layout().content_layout.count()):
widget = self.layout().content_layout.itemAt(i).widget()
if not widget.isHidden():
widget_count += 1
# Update the index
old_idx = self._selection_index
max_idx = widget_count - 1
if self._selection_index + delta < 0:
# Can't move further left
self._selection_index = 0
elif self._selection_index + delta > max_idx:
self._selection_index = max_idx
else:
self._selection_index = self._selection_index + delta
# Don't update the UI if there's no index change
if old_idx == self._selection_index:
return
# Draw the correct underline for the selected widget
for i in range(0, widget_count):
underlined_widget = self.layout().content_layout.itemAt(i).widget()
assert isinstance(underlined_widget, UnderlinedWidget)
if i == self._selection_index:
underlined_widget.toggle_underline(is_hidden=False)
self.layout().scroll_area.ensureWidgetVisible(
underlined_widget, xmargin=16, ymargin=0
)
else:
underlined_widget.toggle_underline(is_hidden=True)
self._on_shift_held(self._is_shift_held)
self._update_hint_icon()
def _clear_search_query(self) -> None:
self.layout().search_field.setText("")
@@ -104,14 +171,13 @@ class SuggestBox[T](QWidget):
raise NotImplementedError()
def _on_search_query_changed(self, query: str) -> None:
self._update_items(query)
self._update_items(query.strip())
self._update_hint_icon()
def _on_search_query_submitted(self, query: str, always_create: bool = False) -> None:
# Focus search field if no query
logger.info("Query submitted")
if not query:
self.done.emit()
self.hide_and_reset()
self.done.emit(query)
return
elif not self.isHidden():
self.layout().search_field.setFocus()
@@ -120,7 +186,7 @@ class SuggestBox[T](QWidget):
if (len(self._search_results) <= 0) or always_create:
self._on_item_create()
else:
self._on_item_chosen(self._search_results[0])
self._on_item_chosen(self._search_results[self._selection_index])
self._clear_search_query()
self._update_items()
@@ -137,9 +203,23 @@ class SuggestBox[T](QWidget):
def _is_excluded(self, item: T) -> bool:
return _item_id(item) in self.excluded
def _is_selected_item_added(self) -> bool:
return bool(
len(self._search_results) > self._selection_index
and _item_id(self._search_results[self._selection_index]) in self.added
)
def _update_hint_icon(self) -> None:
raise NotImplementedError()
def _update_items(self, query: str | None = None) -> None:
"""Update the item list given a search query."""
logger.info("[SearchPanel] Updating items", limit=self._limit)
self._selection_index = 0
if self.layout().content_layout.count() > 0:
self.layout().scroll_area.ensureWidgetVisible(
self.layout().content_layout.itemAt(0).widget(), xmargin=16, ymargin=0
)
# Get results for the search query
query_lower = "" if not query else query.lower()
@@ -187,7 +267,7 @@ class SuggestBox[T](QWidget):
self.layout().search_field.setStyleSheet(autofill_line_edit_style())
else:
self.layout().scroll_area.setHidden(False)
self.layout().content_layout.setContentsMargins(6, 6, 6, 6)
self.layout().content_layout.setContentsMargins(4, 6, 4, 6)
self.layout().search_field.setStyleSheet(autofill_line_edit_top_style())
def _search_items(self, query: str) -> tuple[list[T], list[T]]: # pyright: ignore[reportUnusedParameter]
@@ -197,9 +277,10 @@ class SuggestBox[T](QWidget):
raise NotImplementedError()
def _editing_finished_callback(self) -> None:
if self.layout().search_field.text() == "":
self.done.emit()
self.hide_and_reset()
# Only trigger when the search field is clicked off of and there's no query.
# NOTE: The search field is already cleared by this point when pressing enter.
if self.layout().search_field.text() == "" and not self.layout().search_field.hasFocus():
self.done.emit("")
def _create_item_from_modal(self, edit_item_panel: ModalContent) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
@@ -213,6 +294,7 @@ class SuggestBox[T](QWidget):
self._on_shift_held(held=False)
self.layout().search_field.setDisabled(False)
self._clear_search_query()
self._keep_box_open_action.setChecked(self._settings.keep_suggest_boxes_open)
return super().showEvent(event)
@override
@@ -222,9 +304,7 @@ class SuggestBox[T](QWidget):
@override
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None:
# When Escape is pressed, focus back on the search box.
if event.key() in {
QtCore.Qt.Key.Key_Escape,
QtCore.Qt.Key.Key_Enter,
QtCore.Qt.Key.Key_Return,
}:
self.hide_and_reset()
if event.key() in {QtCore.Qt.Key.Key_Enter, QtCore.Qt.Key.Key_Return}:
self.done.emit("*")
elif event.key() == QtCore.Qt.Key.Key_Escape:
self.done.emit("")
@@ -7,7 +7,7 @@ from warnings import catch_warnings
import structlog
from PySide6.QtCore import Signal
from PySide6.QtGui import QAction, Qt
from PySide6.QtGui import QAction
from PySide6.QtWidgets import QGraphicsOpacityEffect, QWidget
from tagstudio.core.library.alchemy.library import Library
@@ -33,18 +33,17 @@ class TagSuggestBox(SuggestBox[Tag]):
super().__init__(library, settings, placeholder_text)
# Context Menu Actions
edit_tag_on_create_action = QAction(Translations["settings.edit_tag_on_create"], self)
edit_tag_on_create_action.setCheckable(True)
self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
self.addAction(edit_tag_on_create_action)
self.layout().search_field.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
self.layout().search_field.addAction(edit_tag_on_create_action)
edit_tag_on_create_action.setChecked(self._settings.edit_tag_on_create)
edit_tag_on_create_action.triggered.connect(
lambda checked: self.toggle_edit_on_tag_create(checked)
lambda checked: self._toggle_edit_on_tag_create(checked)
)
def toggle_edit_on_tag_create(self, checked: bool) -> None:
def _toggle_edit_on_tag_create(self, checked: bool) -> None:
"""Toggle the setting for opening the edit window after creating a tag."""
self._settings.edit_tag_on_create = checked
self._settings.save()
@@ -89,7 +88,25 @@ class TagSuggestBox(SuggestBox[Tag]):
@override
def _on_item_chosen(self, item: Tag) -> None:
self.item_chosen.emit(item.id)
self.done.emit()
self._clear_search_query()
self.done.emit("*") # The query does not matter
@override
def _update_hint_icon(self) -> None:
results = bool(len(self._search_results) > 0)
if not self._is_shift_held and self._is_selected_item_added():
self.set_hint_icon(self._rm.hint_tag_added)
elif self._is_shift_held:
self.set_hint_icon(self._rm.hint_tag_create)
elif results and self._is_selected_item_added():
self.set_hint_icon(self._rm.hint_tag_added)
elif results:
self.set_hint_icon(self._rm.hint_tag_add)
elif self.layout().search_field.text():
self.set_hint_icon(self._rm.hint_tag_create)
else:
self.set_hint_icon(None)
@override
def _search_items(self, query: str) -> tuple[list[Tag], list[Tag]]:
@@ -117,7 +134,7 @@ class TagSuggestBox(SuggestBox[Tag]):
if item is None:
return
# TODO: Add tabbing to different items, and use underline to indicate which will be added
# Select first item
underlined_widget.toggle_underline(index != 0)
# Disconnect previous callbacks
@@ -144,7 +161,6 @@ class TagSuggestBox(SuggestBox[Tag]):
tag, parent_ids=edit_item_panel.parent_ids, aliases=edit_item_panel.aliases
)
self._on_item_chosen(tag)
self._clear_search_query()
edit_item_panel.hide()
self._on_search_query_changed(self.layout().search_field.text())
+1
View File
@@ -76,6 +76,7 @@ class GlobalSettings(BaseModel):
tag_click_action: TagClickActionOption = Field(default=TagClickActionOption.DEFAULT)
edit_tag_on_create: bool = Field(default=False)
edit_field_on_add: bool = Field(default=True)
keep_suggest_boxes_open: bool = Field(default=True)
theme: Theme = Field(default=Theme.SYSTEM)
splash: Splash = Field(default=Splash.DEFAULT)
windows_start_command: bool = Field(default=False)
+19 -12
View File
@@ -26,6 +26,7 @@ from tagstudio.core.constants import (
GITHUB_REPO_URL,
VERSION,
)
from tagstudio.core.library.alchemy.constants import DB_VERSION
from tagstudio.core.ts_core import TagStudioCore
from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus
from tagstudio.core.utils.ripgrep_status import RipgrepStatus
@@ -109,22 +110,28 @@ class AboutModal(QWidget):
self.system_info_layout.setSpacing(4)
self.system_info_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
# Version
version_title = QLabel(Translations["about.version"])
latest_version = unwrap(TagStudioCore.get_most_recent_release_version(), "0.0.0")
version_content_style = form_content_style()
if not is_version_outdated(VERSION, latest_version):
version_content = QLabel(f"{VERSION}")
# TagStudio Version
ts_version_title = QLabel(Translations["about.version"])
latest_ts_version = unwrap(TagStudioCore.get_most_recent_release_version(), "0.0.0")
ts_version_content_style = form_content_style()
if not is_version_outdated(VERSION, latest_ts_version):
ts_version_content = QLabel(f"{VERSION}")
else:
version_content = QLabel(
ts_version_content = QLabel(
Translations.format(
"about.version.latest", built_version=VERSION, latest_version=latest_version
"about.version.latest", built_version=VERSION, latest_version=latest_ts_version
)
)
version_content_style += f"color: {red};"
version_content.setStyleSheet(version_content_style)
self.system_info_layout.addRow(version_title, version_content)
version_content.setMaximumWidth(version_content.sizeHint().width())
ts_version_content_style += f"color: {red};"
ts_version_content.setStyleSheet(ts_version_content_style)
self.system_info_layout.addRow(ts_version_title, ts_version_content)
ts_version_content.setMaximumWidth(ts_version_content.sizeHint().width())
# Library DB Version
db_version_title = QLabel(Translations["about.library_version"])
db_version_content = QLabel(f"{DB_VERSION}")
db_version_content.setStyleSheet(form_content_style())
self.system_info_layout.addRow(db_version_title, db_version_content)
# Config Path
config_path_title = QLabel(f"{Translations['about.config_path']}")
+11
View File
@@ -231,6 +231,15 @@ class SettingsPanel(ModalContent):
Translations["settings.edit_field_on_add"], self.edit_field_on_add_checkbox
)
# Keep Tag/Field Suggest Boxes Open After Adding Items
self.keep_suggest_boxes_open_checkbox = QCheckBox()
self.keep_suggest_boxes_open_checkbox.setChecked(
self.driver.settings.keep_suggest_boxes_open
)
form_layout.addRow(
Translations["settings.keep_suggest_boxes_open"], self.keep_suggest_boxes_open_checkbox
)
# TODO: Implement Library Settings
def __build_library_settings(self): # pyright: ignore[reportUnusedFunction]
form_layout = QFormLayout(self.library_settings_container)
@@ -383,6 +392,7 @@ class SettingsPanel(ModalContent):
"tag_click_action": self.tag_click_action_combobox.currentData(),
"edit_tag_on_create": self.edit_tag_on_create_checkbox.isChecked(),
"edit_field_on_add": self.edit_field_on_add_checkbox.isChecked(),
"keep_suggest_boxes_open": self.keep_suggest_boxes_open_checkbox.isChecked(),
"date_format": self.dateformat_combobox.currentData(),
"hour_format": self.hourformat_checkbox.isChecked(),
"zero_padding": self.zeropadding_checkbox.isChecked(),
@@ -407,6 +417,7 @@ class SettingsPanel(ModalContent):
driver.settings.tag_click_action = settings["tag_click_action"]
driver.settings.edit_tag_on_create = settings["edit_tag_on_create"]
driver.settings.edit_field_on_add = settings["edit_field_on_add"]
driver.settings.keep_suggest_boxes_open = settings["keep_suggest_boxes_open"]
driver.settings.date_format = settings["date_format"]
driver.settings.hour_format = settings["hour_format"]
driver.settings.zero_padding = settings["zero_padding"]
+1 -1
View File
@@ -68,7 +68,7 @@ class TagColorManager(QWidget):
self.scroll_layout = QVBoxLayout()
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.scroll_layout.setContentsMargins(3, 3, 3, 3)
self.scroll_layout.setSpacing(0)
self.scroll_layout.setSpacing(6)
scroll_container: QWidget = QWidget()
scroll_container.setObjectName("entryScrollContainer")
+14 -9
View File
@@ -2,12 +2,13 @@
# SPDX-License-Identifier: GPL-3.0-only
import platform
import traceback
from enum import IntEnum
from typing import Any
import structlog
from PySide6.QtGui import QPalette
from PySide6.QtGui import QColor, QPalette
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.core.utils.singleton import Singleton
@@ -17,22 +18,26 @@ logger = structlog.get_logger(__name__)
class Palette(metaclass=Singleton):
_palette: QPalette | None = None
_accent: str | None = None
@staticmethod
def set_palette(palette: QPalette) -> None:
Palette._palette = palette
@staticmethod
def accent() -> str:
def accent() -> QColor:
# NOTE: As of PySide 6.8.0.1, the QPalette.ColorRole.Accent role no longer works on Windows.
# The QPalette.ColorRole.Highlight does for some reason, but is faded on macOS.
if not Palette._palette:
logger.error("[Style] No QPalette set!")
return get_ui_color(ColorType.PRIMARY, UiColor.BLUE)
if not Palette._accent:
Palette._accent = (
f"rgba{QPalette.color(Palette._palette, QPalette.ColorRole.Accent).toTuple()}"
)
return Palette._accent
return QColor.fromString(get_ui_color(ColorType.PRIMARY, UiColor.BLUE))
role = (
QPalette.ColorRole.Highlight
if platform.system() == "Windows"
else QPalette.ColorRole.Accent
)
return QPalette.color(Palette._palette, role)
class ColorType(IntEnum):
+5
View File
@@ -36,6 +36,11 @@ class ResourceManager:
edit: Image.Image
file_generic: Image.Image
font: Image.Image
hint_field_add: Image.Image
hint_field_create: Image.Image
hint_tag_add: Image.Image
hint_tag_added: Image.Image
hint_tag_create: Image.Image
icon: Image.Image
ignored_stat: Image.Image
ignored: Image.Image
+20
View File
@@ -71,6 +71,26 @@
"mode": "pil",
"path": "qt/images/file_icons/font.png"
},
"hint_field_add": {
"mode": "pil",
"path": "qt/images/hint_field_add.png"
},
"hint_field_create": {
"mode": "pil",
"path": "qt/images/hint_field_create.png"
},
"hint_tag_add": {
"mode": "pil",
"path": "qt/images/hint_tag_add.png"
},
"hint_tag_added": {
"mode": "pil",
"path": "qt/images/hint_tag_added.png"
},
"hint_tag_create": {
"mode": "pil",
"path": "qt/images/hint_tag_create.png"
},
"icon": {
"mode": "pil",
"path": "icon.png"
+10 -3
View File
@@ -321,6 +321,13 @@ class QtDriver(DriverMixin, QObject):
pal.setColor(
QPalette.ColorGroup.Inactive, QPalette.ColorRole.ButtonText, QColor("#666666")
)
# BUG: PySide isn't properly setting the link colors on Windows, so this is a fallback.
if platform.system() == "Windows":
pal.setColor(
QPalette.ColorGroup.Normal,
QPalette.ColorRole.Link,
QColor.fromString(get_ui_color(ColorType.PRIMARY, UiColor.BLUE)),
)
Palette.set_palette(pal)
self.app.setPalette(pal)
@@ -639,8 +646,8 @@ class QtDriver(DriverMixin, QObject):
self.main_window.search_field.setFocus()
self.app.exec()
self.check_for_update()
self.app.exec()
self.shutdown()
def show_error_message(self, error_name: str, error_desc: str | None = None):
@@ -849,7 +856,7 @@ class QtDriver(DriverMixin, QObject):
logger.info("Backing Up Library...")
self.main_window.status_bar.showMessage(Translations["status.library_backup_in_progress"])
start_time = time.time()
target_path = self.lib.save_library_backup_to_disk()
target_path = Library.save_library_backup_to_disk(unwrap(self.lib.library_dir))
end_time = time.time()
self.main_window.status_bar.showMessage(
Translations.format(
@@ -1792,4 +1799,4 @@ class QtDriver(DriverMixin, QObject):
if sc.which():
logger.info(f"[QtDriver] {name} found", which=sc.which(), version=sc.version())
else:
logger.warning(f"[QtDriver] {sc} not found")
logger.warning(f"[QtDriver] {name} not found")
@@ -74,7 +74,7 @@ def button_style() -> str:
outline: none;
border: solid;
border-width: 2px;
border-color: {Palette.accent()};
border-color: rgba{Palette.accent().toTuple()};
padding: 0px 8px;
}}
QPushButton::disabled{{
@@ -108,7 +108,7 @@ def line_edit_style_main() -> str:
QLineEdit::focus{{
border-style: solid;
border-width: 2px;
border-color: {Palette.accent()};
border-color: rgba{Palette.accent().toTuple()};
padding: 0px 2px;
}}
QLineEdit::disabled{{
@@ -412,7 +412,7 @@ def tag_remove_button_style(
def widget_underline_style() -> str:
return f"""
background: {Palette.accent()};
background: rgba{Palette.accent().toTuple()};
border-radius: 2px;
"""
@@ -475,7 +475,7 @@ def autofill_scroll_top_focus_style(object_name: str = "") -> str:
border-top-right-radius: 6px;
border: solid;
border-width: 2px 2px 0px 2px;
border-color: {Palette.accent()};
border-color: rgba{Palette.accent().toTuple()};
}}
"""
@@ -498,7 +498,7 @@ def autofill_line_edit_style() -> str:
padding: 4px 4px;
border: solid;
border-width: 2px;
border-color: {Palette.accent()};
border-color: rgba{Palette.accent().toTuple()};
}}
"""
@@ -524,7 +524,7 @@ def autofill_line_edit_top_style() -> str:
padding: 4px 4px;
border: solid;
border-width: 0px 2px 2px 2px;
border-color: {Palette.accent()};
border-color: rgba{Palette.accent().toTuple()};
}}
"""
+12 -9
View File
@@ -4,9 +4,11 @@
import structlog
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QFrame, QHBoxLayout, QScrollArea, QSizePolicy, QVBoxLayout, QWidget
from PySide6.QtGui import QPixmap
from PySide6.QtWidgets import QFrame, QHBoxLayout, QSizePolicy, QVBoxLayout, QWidget
from tagstudio.qt.controllers.autofill_line_edit import AutofillLineEdit
from tagstudio.qt.controllers.horizontal_scroll_area import HorizontalScrollArea
from tagstudio.qt.views.stylesheets.stylesheets import (
autofill_line_edit_style,
autofill_scroll_top_style,
@@ -18,18 +20,13 @@ logger = structlog.get_logger(__name__)
class SuggestBoxView(QVBoxLayout):
def __init__(self, placeholder_text: str = "") -> None:
super().__init__()
self.setContentsMargins(0, 0, 0, 0)
self.setSpacing(0)
# HACK: The transparent border allows for the focus border color to
# still show above the tags at the edges... sort of (overlaps on left when h-scrolling)
scroll_area_style = """
QScrollArea{
background: transparent;
border: solid;
border-color: transparent;
border-width: 0px 2px;
padding-left: -2px;
}
QScrollArea > QWidget > QWidget{
background: transparent;
@@ -48,8 +45,9 @@ class SuggestBoxView(QVBoxLayout):
scroll_area_container_layout.setContentsMargins(0, 0, 0, 0)
scroll_area_container_layout.setSpacing(0)
scroll_area_container.setStyleSheet(autofill_scroll_top_style("container"))
self.scroll_area = QScrollArea()
self.scroll_area = HorizontalScrollArea()
self.scroll_area.setStyleSheet(scroll_area_style)
self.scroll_area.setViewportMargins(2, 0, 2, 0)
scroll_area_container_layout.addWidget(self.scroll_area)
self.scroll_area.setWidget(contents)
search_bar_height = 28
@@ -68,9 +66,14 @@ class SuggestBoxView(QVBoxLayout):
self.search_field = AutofillLineEdit(scroll_area_container)
self.search_field.setStyleSheet(autofill_line_edit_style())
self.search_field.setObjectName("search_field")
self.search_field.setMinimumHeight(28)
self.search_field.setMinimumHeight(search_bar_height)
self.search_field.setPlaceholderText(placeholder_text)
self.hint_icon_action = self.search_field.addAction(
QPixmap(), AutofillLineEdit.ActionPosition.TrailingPosition
)
self.scroll_area.setFocusProxy(self.search_field)
self.search_field.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.search_field.customContextMenuRequested.connect(self.search_field.show_action_menu)
# Finalize Layout
self.addWidget(scroll_area_container)
+5 -45
View File
@@ -2,7 +2,6 @@
# SPDX-License-Identifier: GPL-3.0-only
import sys
from typing import override
from PySide6 import QtCore
@@ -13,11 +12,12 @@ from PySide6.QtGui import (
QPainter,
QPainterPath,
QPaintEvent,
QPalette,
QPen,
)
from PySide6.QtWidgets import QPushButton, QWidget
from tagstudio.qt.models.palette import Palette
class ThumbButton(QPushButton):
def __init__(self, parent: QWidget, thumb_size: tuple[int, int]) -> None:
@@ -25,30 +25,9 @@ class ThumbButton(QPushButton):
self.thumb_size: tuple[int, int] = thumb_size
self.hovered = False
self.selected = False
self.select_color = Palette.accent()
# NOTE: As of PySide 6.8.0.1, the QPalette.ColorRole.Accent role no longer works on Windows.
# The QPalette.ColorRole.AlternateBase does for some reason, but not on macOS.
self.select_color: QColor
if sys.platform == "win32":
self.select_color = QPalette.color(
self.palette(),
QPalette.ColorGroup.Active,
QPalette.ColorRole.AlternateBase,
)
self.select_color.setHsl(
self.select_color.hslHue(),
self.select_color.hslSaturation(),
max(self.select_color.lightness(), 100),
255,
)
else:
self.select_color = QPalette.color(
self.palette(),
QPalette.ColorGroup.Active,
QPalette.ColorRole.Accent,
)
self.select_color_faded: QColor = QColor(self.select_color)
self.select_color_faded = Palette.accent()
self.select_color_faded.setHsl(
self.select_color_faded.hslHue(),
self.select_color_faded.hslSaturation(),
@@ -56,26 +35,7 @@ class ThumbButton(QPushButton):
127,
)
self.hover_color: QColor
if sys.platform == "win32":
self.hover_color = QPalette.color(
self.palette(),
QPalette.ColorGroup.Active,
QPalette.ColorRole.AlternateBase,
)
self.hover_color.setHsl(
self.hover_color.hslHue(),
self.hover_color.hslSaturation(),
max(self.hover_color.lightness(), 100),
255,
)
else:
self.hover_color = QPalette.color(
self.palette(),
QPalette.ColorGroup.Active,
QPalette.ColorRole.Accent,
)
self.hover_color = Palette.accent()
self.hover_color.setHsl(
self.hover_color.hslHue(),
self.hover_color.hslSaturation(),
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

+3 -1
View File
@@ -9,6 +9,7 @@
"about.version": "Version",
"about.version.latest": "{built_version} (Latest Release: {latest_version})",
"about.website": "Website",
"about.library_version": "Library Format",
"app.git": "Git Commit",
"app.nightly": "Nightly",
"app.pre_release": "Pre-Release",
@@ -167,7 +168,7 @@
"home.search_or_create_fields": "Search or Create Fields…",
"home.search_or_create_tags": "Search or Create Tags…",
"home.search_tags": "Search Tags…",
"home.search.how_to_exit": "(Esc to Exit)",
"home.search.how_to_exit": "(Enter to Exit)",
"home.search.view_limit": "View Limit:",
"home.show_hidden_entries": "Show Hidden Entries",
"home.thumbnail_size": "Thumbnail Size",
@@ -329,6 +330,7 @@
"settings.global": "Global Settings",
"settings.hourformat.label": "24-Hour Time",
"settings.infinite_scroll": "Infinite Scrolling",
"settings.keep_suggest_boxes_open": "Keep Search Boxes Open After Adding Items",
"settings.language": "Language",
"settings.library": "Library Settings",
"settings.localization": "Localization",
+25 -19
View File
@@ -10,6 +10,7 @@
"about.version.latest": "{built_version} (Dernière version : {latest_version})",
"about.website": "Site Internet",
"app.git": "Git Commit",
"app.nightly": "Version de développement",
"app.pre_release": "Version Préliminaire",
"app.title": "{base_title} - Bibliothèque '{library_dir}'",
"color.color_border": "Utiliser la couleur secondaire sur la bordure",
@@ -30,7 +31,7 @@
"drop_import.description": "Les fichiers suivants correspondent à des chemins de fichiers déjà existant dans la bibliothèque",
"drop_import.duplicates_choice.plural": "Les chemins d'accès des {count} fichiers suivants existent déjà dans la Bibliothèque.",
"drop_import.duplicates_choice.singular": "Le fichier suivant correspond a un chemin d'accès déjà existant dans la bibliothèque.",
"drop_import.progress.label.initial": "Importer des Nouveaux Fichiers...",
"drop_import.progress.label.initial": "Importer des Nouveaux Fichiers",
"drop_import.progress.label.plural": "Importation des Nouveaux Fichiers...\n{count} Fichiers Importés.{suffix}",
"drop_import.progress.label.singular": "Importation des Nouveaux Fichiers...\n1 Fichier Importé.{suffix}",
"drop_import.progress.window_title": "Importer des Fichiers",
@@ -40,26 +41,26 @@
"edit.paste_fields": "Coller les Champs",
"edit.tag_manager": "Gérer les Tags",
"entries.duplicate.merge": "Fusion des entrées dupliquées",
"entries.duplicate.merge.label": "Fusionner les entrées dupliquées...",
"entries.duplicate.merge.label": "Fusionner les entrées dupliquées",
"entries.duplicate.refresh": "Rafraichir les Entrées en Doublon",
"entries.duplicates.description": "Les entrées dupliquées sont définies comme des entrées multiple qui pointent vers le même fichier sur le disque. Les fusionner va combiner les tags et metadatas de tous les duplicatas vers une seule entrée consolidée. Elles ne doivent pas être confondues avec les \"fichiers en doublon\", qui sont des doublons de vos fichiers en dehors de TagStudio.",
"entries.generic.refresh_alt": "&Recharger",
"entries.generic.remove.removing": "Suppression des Entrées",
"entries.generic.remove.removing_count": "Suppression de {count} entrées...",
"entries.generic.remove.removing_count": "Suppression de {count} entrées",
"entries.ignored.description": "Les entrées de fichier sont considérées comme « ignorées » si elles ont été ajoutées à la bibliothèque avant que les règles d'ignorance de l'utilisateur (via le fichier « .ts_ignore ») aient été mises à jour pour les exclure. Les fichiers ignorés sont conservés dans la bibliothèque par défaut afin d'éviter toute perte accidentelle de données lors de la mise à jour des règles d'ignorance.",
"entries.ignored.ignored_count": "Entrées Ignorées : {count}",
"entries.ignored.remove": "Supprimer les entrées ignorées",
"entries.ignored.remove_alt": "Supprim&er les entrées ignorées",
"entries.ignored.scanning": "Recherche des entrées ignorées dans la bibliothèque...",
"entries.ignored.scanning": "Recherche des entrées ignorées dans la bibliothèque",
"entries.ignored.title": "Corriger les entrées ignorées",
"entries.mirror": "&Répliquer",
"entries.mirror.confirmation": "Êtes-vous sûr de vouloir répliquer les {count} Entrées suivantes ?",
"entries.mirror.label": "Réplication de {idx}/{total} Entrées...",
"entries.mirror.label": "Réplication de {idx}/{total} Entrées",
"entries.mirror.title": "Réplication des Entrées",
"entries.mirror.window_title": "Entrées Répliqués",
"entries.remove.plural.confirm": "Êtes-vous sûr de vouloir supprimer les <b>{count}</b> entrées suivantes ? Aucun fichier sur votre disque ne sera supprimée.",
"entries.remove.singular.confirm": "Êtes-vous sûr de vouloir supprimer cette entrée de votre bibliothèque ? Aucun fichier sur le disque ne sera supprimé.",
"entries.running.dialog.new_entries": "Ajout de {total} Nouvelles entrées de fichier...",
"entries.running.dialog.new_entries": "Ajout de {total} Nouvelles entrées de fichier",
"entries.running.dialog.title": "Ajout de Nouvelles entrées de fichier",
"entries.tags": "Tags",
"entries.unlinked.description": "Chaque entrée dans la bibliothèque est liée à un fichier dans l'un de vos dossiers. Si un fichier lié à une entrée est déplacé ou supprimé en dehors de TagStudio, il est alors considéré non lié. <br><br>Les entrées non liées peuvent être automatiquement reliées via la recherche dans vos dossiers, reliées manuellement par l'utilisateur, ou supprimées si désiré.",
@@ -68,7 +69,7 @@
"entries.unlinked.relink.title": "Reliage des Entrées",
"entries.unlinked.remove": "Supprimer les entrées non liées",
"entries.unlinked.remove_alt": "Supprim&er les entrées non liées",
"entries.unlinked.scanning": "Balayage de la Bibliothèque pour trouver des Entrées non Liées...",
"entries.unlinked.scanning": "Balayage de la Bibliothèque pour trouver des Entrées non Liées",
"entries.unlinked.search_and_relink": "&Rechercher && Relier",
"entries.unlinked.title": "Réparation des Entrées non Liées",
"entries.unlinked.unlinked_count": "Entrées non Liées : {count}",
@@ -160,11 +161,14 @@
"generic.skip_alt": "&Passer",
"generic.yes": "Oui",
"home.search": "Rechercher",
"home.search.how_to_exit": "(Esc pour Fermer)",
"home.search.view_limit": "Limite d'affichage :",
"home.search_entries": "Recherche",
"home.search_field_templates": "Rechercher un format de champs",
"home.search_field_templates": "Rechercher un format de champs",
"home.search_library": "Rechercher dans la Bibliothèque",
"home.search_tags": "Recherche de Tags",
"home.search_or_create_fields": "Rechercher ou Créer des Champs…",
"home.search_or_create_tags": "Rechercher ou Créer des Tags…",
"home.search_tags": "Recherche des Tags…",
"home.show_hidden_entries": "Afficher les entrées cachées",
"home.thumbnail_size": "Taille de la miniature",
"home.thumbnail_size.extra_large": "Très Grandes Miniatures",
@@ -173,8 +177,8 @@
"home.thumbnail_size.mini": "Mini Miniatures",
"home.thumbnail_size.small": "Petites Miniatures",
"ignore.open_file": "Afficher le fichier \"{ts_ignore}\" sur le Disque",
"json_migration.checking_for_parity": "Vérification de la Parité...",
"json_migration.creating_database_tables": "Création des Tables de Base de Données SQL...",
"json_migration.checking_for_parity": "Vérification de la Parité",
"json_migration.creating_database_tables": "Création des Tables de Base de Données SQL",
"json_migration.description": "<br>Démarrez et prévisualisez les résultats du processus de migration de la bibliothèque. La bibliothèque convertie ne sera utilisée <i>que</i> si vous cliquez sur \"Terminer la migration\". <br><br>Les données de la bibliothèque doivent soit avoir des valeurs correspondantes, soit comporter un label \"Matched\". Les valeurs qui ne correspondent pas seront affichées en rouge et comporteront un symbole \"<b>(!)</b>\" à côté d'elles.<br><center><i>Ce processus peut prendre jusqu'à plusieurs minutes pour les bibliothèques plus volumineuses.</i></center>",
"json_migration.discrepancies_found": "Divergences Détectées dans la Bibliothèque",
"json_migration.discrepancies_found.description": "Des divergences ont été détectées entre le format d'origine et le format converti de la bibliothèque. Veuillez les examiner et choisir de poursuivre la migration ou de l'annuler.",
@@ -231,7 +235,7 @@
"library.name": "Bibliothèque",
"library.refresh.scanning.plural": "Analyse du Répertoire pour de Nouveaux Fichiers...\n{searched_count} Fichiers Trouvées, {found_count} Nouveaux Fichiers",
"library.refresh.scanning.singular": "Analyse du Répertoire pour de Nouveaux Fichiers...\n{searched_count} Fichier Trouvé, {found_count} Nouveaux Fichiers",
"library.refresh.scanning_preparing": "Recherche de Nouveaux Fichiers dans les Dossiers...\nPréparation...",
"library.refresh.scanning_preparing": "Recherche de Nouveaux Fichiers dans les Dossiers...\nPréparation",
"library.refresh.title": "Rafraîchissement des Dossiers",
"library.scan_library.title": "Balayage de la Bibliothèque",
"library_info.cleanup": "Nettoyage",
@@ -253,7 +257,7 @@
"library_object.name_required": "Nom (Requis)",
"library_object.slug": "Identifiant unique",
"library_object.slug_required": "ID Slug (Requis)",
"macros.running.dialog.new_entries": "Exécution des Macros Configurées sur {count}/{total} Nouvelles Entrées de Fichiers...",
"macros.running.dialog.new_entries": "Exécution des Macros Configurées sur {count}/{total} Nouvelles Entrées de Fichiers",
"macros.running.dialog.title": "Exécution des Macros sur les Nouvelles Entrées",
"media_player.autoplay": "Lecture automatique",
"media_player.loop": "Lire en boucle",
@@ -283,7 +287,7 @@
"menu.macros": "&Macros",
"menu.macros.folders_to_tags": "Répertoires à Tags",
"menu.select": "Sélectionner",
"menu.settings": "Paramètres...",
"menu.settings": "Paramètres",
"menu.tools": "&Outils",
"menu.tools.fix_duplicate_files": "Réparer les entrées de &fichiers en double",
"menu.tools.fix_ignored_entries": "Corriger les entrées &Ignorer",
@@ -315,6 +319,8 @@
"settings.dateformat.international": "International",
"settings.dateformat.label": "Format de Date",
"settings.dateformat.system": "Système",
"settings.edit_field_on_add": "Modifier Après avoir Ajouté un Champ",
"settings.edit_tag_on_create": "Modifier Après avoir Créé un Nouveau Tag",
"settings.filepath.label": "Visibilités du Chemin de Fichier",
"settings.filepath.option.full": "Afficher le Chemin Complet",
"settings.filepath.option.name": "Afficher Seulement le Nom du Fichier",
@@ -354,18 +360,18 @@
"sorting.direction.ascending": "Croissant",
"sorting.direction.descending": "Décroissant",
"sorting.mode.random": "Aléatoire",
"splash.opening_library": "Ouverture de la Bibliothèque \"{library_path}\"...",
"splash.opening_library": "Ouverture de la Bibliothèque \"{library_path}\"",
"status.deleted_file_plural": "Suppression de {count} fichiers!",
"status.deleted_file_singular": "Suppression de 1 fichier!",
"status.deleted_none": "Aucun fichiers supprimer.",
"status.deleted_partial_warning": "Seulement {count} fichier(s) on été supprimé! Vérifier si les fichiers restant ne serais pas manquant ou en cours d'utilisation.",
"status.deleting_file": "Suppression de fichier(s) [{i}/{count}]: \"{path}\"...",
"status.library_backup_in_progress": "Création d'une Sauvegarde de la Bibliothèque...",
"status.deleting_file": "Suppression de fichier(s) [{i}/{count}]: \"{path}\"",
"status.library_backup_in_progress": "Création d'une Sauvegarde de la Bibliothèque",
"status.library_backup_success": "Bibliothèque sauvegardée au chemin: \"{path}\" ({time_span})",
"status.library_closed": "Bibliothèque fermée ({time_span})",
"status.library_closing": "Fermeture de la Bibliothèque...",
"status.library_closing": "Fermeture de la Bibliothèque",
"status.library_save_success": "Bibliothèque Sauvegardée et Fermée!",
"status.library_search_query": "Rechercher dans la Bibliothèque...",
"status.library_search_query": "Rechercher dans la Bibliothèque",
"status.library_version_expected": "Exceptée:",
"status.library_version_found": "Trouvée:",
"status.library_version_mismatch": "La version de la library ne correspond pas!",
+2 -1
View File
@@ -7,5 +7,6 @@
"color.primary": "צבע ראשי",
"color.secondary": "צבע משני",
"color.title.no_color": "ללא צבע",
"color_manager.title": "שנה צבע תגית"
"color_manager.title": "נהל צבעיי תגיות",
"window.title.open_create_library": "פתח/צור ספרייה"
}
+9 -2
View File
@@ -3,6 +3,7 @@
"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.library_version": "Könyvtárformátum",
"about.module.found": "Telepítve",
"about.modules.title": "Nemkötelező modulok",
"about.title": "A TagStudio névjegye",
@@ -161,11 +162,14 @@
"generic.skip_alt": "&Kihagyás",
"generic.yes": "Igen",
"home.search": "Keresés",
"home.search.how_to_exit": "(Kilépés az Enter billentyűvel)",
"home.search.view_limit": "Megtekintési korlát:",
"home.search_entries": "Tételek keresése",
"home.search_field_templates": "Keresés a mezőminták között",
"home.search_field_templates": "Keresés a mezőminták között",
"home.search_library": "Keresés a könyvtárban",
"home.search_tags": "Címkék keresése",
"home.search_or_create_fields": "Mezők keresése/létrehozása…",
"home.search_or_create_tags": "Címkék keresése/létrehozása…",
"home.search_tags": "Címkék keresése…",
"home.show_hidden_entries": "Rejtett elemel megjelenítése",
"home.thumbnail_size": "Miniatűrök mérete",
"home.thumbnail_size.extra_large": "Extra nagy miniatűrök",
@@ -316,6 +320,8 @@
"settings.dateformat.international": "Nemzetközi",
"settings.dateformat.label": "Dátumformátum",
"settings.dateformat.system": "Rendszer",
"settings.edit_field_on_add": "Mező szerkesztése létrehozás után",
"settings.edit_tag_on_create": "Címke szerkesztése létrehozás után",
"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",
@@ -324,6 +330,7 @@
"settings.global": "Globális beállítások",
"settings.hourformat.label": "24-órás idő",
"settings.infinite_scroll": "Végtelen görgetés",
"settings.keep_suggest_boxes_open": "Keresőmezők nyitva tartása elemek létrehozása után",
"settings.language": "&Nyelv",
"settings.library": "Könyvtárbeállítások",
"settings.localization": "Nyelv és formátumok",
+89 -21
View File
@@ -1,11 +1,16 @@
{
"about.app_cache_path": "アプリ キャッシュのパス",
"about.config_path": "設定ファイルのパス",
"about.description": "TagStudio は、タグベースのシステムに基づく、写真とファイルの整理アプリです。独自のプログラムやフォーマットは使用せず、サイドカーファイルが大量に生成されることもありません。ファイルシステム全体に大きな変更を加えることなく、ユーザーに自由で柔軟な運用を提供します。",
"about.documentation": "ドキュメント",
"about.module.found": "インストール済み",
"about.modules.title": "オプション モジュール",
"about.title": "TagStudio について",
"about.version": "バージョン",
"about.version.latest": "{built_version} (最新リリース: {latest_version})",
"about.website": "ウェブサイト",
"app.git": "Git コミット",
"app.nightly": "ナイトリー",
"app.pre_release": "公開前",
"app.title": "{base_title} - ライブラリ '{library_dir}'",
"color.color_border": "境界線にアクセントカラーを使う",
@@ -26,7 +31,7 @@
"drop_import.description": "次のファイルは、ライブラリ内にすでに存在するファイル パスと一致しています",
"drop_import.duplicates_choice.plural": "以下の {count} 件のファイルは、ライブラリ内にすでに存在するファイル パスと一致しています。",
"drop_import.duplicates_choice.singular": "次のファイルは、ライブラリ内にすでに存在するファイル パスと一致しています。",
"drop_import.progress.label.initial": "新しいファイルをインポートしています...",
"drop_import.progress.label.initial": "新しいファイルをインポートしています",
"drop_import.progress.label.plural": "新しいファイルをインポートしています...\n{count} 件のファイルをインポートしました。{suffix}",
"drop_import.progress.label.singular": "新しいファイルをインポートしています...\n1 件のファイルをインポートしました。{suffix}",
"drop_import.progress.window_title": "ファイルをインポート",
@@ -36,26 +41,26 @@
"edit.paste_fields": "フィールドを貼り付け",
"edit.tag_manager": "タグを管理",
"entries.duplicate.merge": "重複エントリをマージ",
"entries.duplicate.merge.label": "重複エントリを統合しています...",
"entries.duplicate.merge.label": "重複エントリを統合しています",
"entries.duplicate.refresh": "重複エントリを最新の状態にする",
"entries.duplicates.description": "重複エントリとは、ディスク上の同じファイルを指す複数のエントリを指します。これらをマージすると、すべての重複エントリのタグとメタデータが1つのまとまったエントリに統合されます。TagStudio の外部にあるファイル自体の複製である「重複ファイル」と混同しないようにご注意ください。",
"entries.generic.refresh_alt": "最新の情報に更新(&R)",
"entries.generic.remove.removing": "エントリの削除",
"entries.generic.remove.removing_count": "{count} 個のエントリを削除しています...",
"entries.generic.remove.removing_count": "{count} 個のエントリを削除しています",
"entries.ignored.description": "「無視」されたエントリとは、ユーザーの無視ルール(“.ts_ignore” ファイル)を更新して対象外にした後も、更新前にライブラリへ追加されていたため残っている項目を指します。無視ルールを更新した際の誤削除によるデータ損失を防ぐため、既定ではこれらのファイルはライブラリに保持されます。",
"entries.ignored.ignored_count": "無視されたエントリ: {count}",
"entries.ignored.remove": "無視されたエントリを削除",
"entries.ignored.remove_alt": "無視されたエントリを削除(&V)",
"entries.ignored.scanning": "無視されたエントリをライブラリ内でスキャンしています...",
"entries.ignored.scanning": "無視されたエントリをライブラリ内でスキャンしています",
"entries.ignored.title": "無視されたエントリの修正",
"entries.mirror": "ミラー(&M)",
"entries.mirror.confirmation": "以下の {count} 件のエントリをミラーリングしてもよろしいですか?",
"entries.mirror.label": "{total} 件中 {idx} 件のエントリをミラーリングしています...",
"entries.mirror.label": "{total} 件中 {idx} 件のエントリをミラーリングしています",
"entries.mirror.title": "エントリをミラー",
"entries.mirror.window_title": "エントリをミラー",
"entries.remove.plural.confirm": "これら <b>{count}</b> 件のエントリをライブラリから削除しますか? ディスク上のファイルは削除されません。",
"entries.remove.singular.confirm": "このエントリをライブラリから削除しますか? ディスク上のファイルは削除されません。",
"entries.running.dialog.new_entries": "{total} 件の新しいファイル エントリを追加しています...",
"entries.running.dialog.new_entries": "{total} 件の新しいファイル エントリを追加しています",
"entries.running.dialog.title": "新しいファイルエントリを追加",
"entries.tags": "タグ",
"entries.unlinked.description": "ライブラリの各エントリは、ディレクトリ内のファイルにリンクされています。エントリにリンクされたファイルが TagStudio 以外で移動または削除された場合、そのエントリはリンク切れとして扱われます。<br><br>リンク切れのエントリは、ディレクトリを検索して自動的に再リンクすることも、必要に応じて削除することもできます。",
@@ -64,18 +69,34 @@
"entries.unlinked.relink.title": "エントリの再リンク",
"entries.unlinked.remove": "リンク切れのエントリを削除",
"entries.unlinked.remove_alt": "リンク切れのエントリを削除(&V)",
"entries.unlinked.scanning": "リンク切れのエントリをライブラリ内でスキャンしています...",
"entries.unlinked.scanning": "リンク切れのエントリをライブラリ内でスキャンしています",
"entries.unlinked.search_and_relink": "検索して再リンク(&S)",
"entries.unlinked.title": "リンク切れのエントリを修正",
"entries.unlinked.unlinked_count": "リンク切れのエントリ数: {count}",
"ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}<br>{ffprobe}: {ffprobe_status}",
"field.add": "フィールドの追加",
"field.add.plural": "フィールドの追加",
"field.confirm_remove": "「{name}」フィールドを削除してもよろしいですか?",
"field.copy": "フィールドをコピー",
"field.edit": "フィールドを編集",
"field.field_name_required": "フィールドの名前 (必須)",
"field.mixed_data": "混在データ",
"field.name": "名前",
"field.paste": "フィールドを貼り付け",
"field.remove": "フィールドの削除",
"field.text.is_multiline": "複数行",
"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_template_manager.title": "ライブラリのフィールド テンプレート",
"field_type.datetime": "日時",
"field_type.text": "テキスト",
"field_type.unknown": "不明な種類",
"file.date_added": "追加日時",
"file.date_created": "作成日時",
"file.date_modified": "更新日時",
@@ -117,6 +138,7 @@
"generic.delete_alt": "削除(&D)",
"generic.done": "完了",
"generic.done_alt": "完了(&D)",
"generic.dont_remind": "次回から表示しない",
"generic.edit": "編集",
"generic.edit_alt": "編集(&E)",
"generic.filename": "ファイル名",
@@ -139,10 +161,14 @@
"generic.skip_alt": "スキップ(&S)",
"generic.yes": "はい",
"home.search": "検索",
"home.search.how_to_exit": "(Esc で終了)",
"home.search.view_limit": "表示件数:",
"home.search_entries": "エントリを検索",
"home.search_field_templates": "フィールド テンプレートの検索…",
"home.search_library": "ライブラリを検索",
"home.search_tags": "タグを検索",
"home.search_or_create_fields": "フィールドの検索または作成…",
"home.search_or_create_tags": "タグの検索または作成…",
"home.search_tags": "タグの検索…",
"home.show_hidden_entries": "非表示のエントリを表示",
"home.thumbnail_size": "サムネイルのサイズ",
"home.thumbnail_size.extra_large": "特大サムネイル",
@@ -151,8 +177,8 @@
"home.thumbnail_size.mini": "極小サムネイル",
"home.thumbnail_size.small": "小サムネイル",
"ignore.open_file": "ディスク上の \"{ts_ignore}\" ファイルを表示",
"json_migration.checking_for_parity": "パリティチェック中...",
"json_migration.creating_database_tables": "SQLデータベース テーブルを作成しています...",
"json_migration.checking_for_parity": "パリティチェック中",
"json_migration.creating_database_tables": "SQLデータベース テーブルを作成しています",
"json_migration.description": "<br>ライブラリの移行処理を開始し、結果をプレビューします。変換されたライブラリは、 「移行完了」をクリックしない限り<i>使用されません</i>。<br><br>ライブラリ データは、値が一致しているか、「一致」ラベルが表示されている必要があります。 値が一致しない場合は赤色で表示され、その横に「<b>(!)</b>」マークが表示されます。<br><center><i>大規模なライブラリの場合、この処理に数分かかることがあります。</i></center>",
"json_migration.discrepancies_found": "ライブラリの差異が見つかりました",
"json_migration.discrepancies_found.description": "元のライブラリ形式と変換後の形式との間に差異が見つかりました。内容を確認のうえ、確認して、移行を続行するかキャンセルするかを選択してください。",
@@ -160,13 +186,14 @@
"json_migration.heading.aliases": "エイリアス:",
"json_migration.heading.colors": "色:",
"json_migration.heading.differ": "差異",
"json_migration.heading.extensions": "拡張子:",
"json_migration.heading.match": "一致",
"json_migration.heading.names": "名前:",
"json_migration.heading.parent_tags": "親タグ:",
"json_migration.heading.paths": "パス:",
"json_migration.heading.shorthands": "略称:",
"json_migration.info.description": "TagStudio バージョン<b>9.4 以前</b>で作成されたライブラリ保存ファイルは、新しいバージョン<b>9.5 以降</b>の形式に移行する必要があります。<br><h2>ご確認ください:</h2><ul><li>既存のライブラリ保存ファイルが<b><i>削除されることはありません</i></b></li><li>個人ファイルが<b><i>削除・移動・変更されることはありません</i></b></li><li>新しい v9.5 以降の保存形式は、旧バージョンの TagStudio では開くことができません</li></ul><h3>変更点:</h3><ul><li>「タグ フィールド」は「タグ カテゴリ」に置き換えられました。従来のようにタグを先にフィールドに追加するのではなく、タグを直接ファイル エントリに追加します。その後、タグ編集メニューで「カテゴリとして扱う」プロパティが有効になっている親タグに基づいて、タグが自動的にカテゴリとして整理されます。どのタグでもカテゴリとして指定でき、カテゴリに指定された親タグの下に子タグが自動で整理されます。「お気に入り」タグおよび「アーカイブ」タグは、新しく追加された「メタタグ」というデフォルトのカテゴリ タグの下に分類されます。</li><li>タグの色が調整・拡張されました。一部の色は名前が変更されたり統合されたりしましたが、すべてのタグの色は v9.5 において同一または類似の色に変換されます。</li></ul>",
"json_migration.migrating_files_entries": "{entries} 件のファイル エントリを移行しています...",
"json_migration.migrating_files_entries": "{entries} 件のファイル エントリを移行しています",
"json_migration.migration_complete": "移行が完了しました!",
"json_migration.migration_complete_with_discrepancies": "移行が完了し、差異が見つかりました",
"json_migration.start_and_preview": "開始とプレビュー",
@@ -174,13 +201,41 @@
"json_migration.title.new_lib": "<h2>v9.5+ ライブラリ</h2>",
"json_migration.title.old_lib": "<h2>v9.4 ライブラリ</h2>",
"landing.open_create_library": "ライブラリを開く/作成する {shortcut}",
"language.am": "アムハラ語",
"language.ceb": "セブアノ語",
"language.cs": "チェコ語",
"language.da": "デンマーク語",
"language.de": "ドイツ語",
"language.el": "ギリシャ語",
"language.en": "英語",
"language.es": "スペイン語",
"language.fi": "フィンランド語",
"language.fil": "フィリピノ語",
"language.fr": "フランス語",
"language.hu": "ハンガリー語",
"language.is": "アイスランド語",
"language.it": "イタリア語",
"language.ja": "日本語",
"language.nb_NO": "ノルウェー語 (ブークモール)",
"language.nl": "オランダ語",
"language.pl": "ポーランド語",
"language.pt": "ポルトガル語",
"language.pt_BR": "ポルトガル語 (ブラジル)",
"language.qpv": "ヴィオッサ語",
"language.ro": "ルーマニア語",
"language.ru": "ロシア語",
"language.sv": "スウェーデン語",
"language.ta": "タミル語",
"language.th": "タイ語",
"language.tok": "トキポナ",
"language.tr": "トルコ語",
"language.zh_Hans": "中国語 (簡体字)",
"language.zh_Hant": "中国語 (繁体字)",
"library.missing": "ライブラリの場所が見つかりません",
"library.name": "ライブラリ",
"library.refresh.scanning.plural": "新しいファイルを検索中...\n{searched_count} 件を検索、{found_count} 件の新規ファイルを検出",
"library.refresh.scanning.singular": "新しいファイルを検索中...\n{searched_count} 件を検索、{found_count} 件の新規ファイルを検出",
"library.refresh.scanning_preparing": "新しいファイルを検索中...\n準備中...",
"library.refresh.scanning_preparing": "新しいファイルを検索中...\n準備中",
"library.refresh.title": "ディレクトリを更新しています",
"library.scan_library.title": "ライブラリをスキャンしています",
"library_info.cleanup": "クリーンアップ",
@@ -202,7 +257,7 @@
"library_object.name_required": "名前 (必須)",
"library_object.slug": "ID スラッグ",
"library_object.slug_required": "ID スラッグ (必須)",
"macros.running.dialog.new_entries": "新しいファイル エントリ {total} 件中 {count} 件に設定済みマクロを実行しています...",
"macros.running.dialog.new_entries": "新しいファイル エントリ {total} 件中 {count} 件に設定済みマクロを実行しています",
"macros.running.dialog.title": "新しいエントリにマクロを実行しています",
"media_player.autoplay": "自動再生",
"media_player.loop": "繰り返し",
@@ -211,6 +266,7 @@
"menu.delete_selected_files_singular": "ファイルを {trash_term} に移動",
"menu.edit": "編集",
"menu.edit.ignore_files": "ファイルとフォルダを無視",
"menu.edit.manage_field_templates": "フィールド テンプレートの管理",
"menu.edit.manage_tags": "タグの管理",
"menu.edit.new_tag": "新しいタグ(&T)",
"menu.file": "ファイル(&F)",
@@ -231,7 +287,7 @@
"menu.macros": "マクロ(&M)",
"menu.macros.folders_to_tags": "フォルダー構造からタグを生成",
"menu.select": "選択",
"menu.settings": "設定...",
"menu.settings": "設定",
"menu.tools": "ツール(&T)",
"menu.tools.fix_duplicate_files": "重複ファイルの修正(&D)",
"menu.tools.fix_ignored_entries": "無視されたエントリの修正(&I)",
@@ -247,6 +303,8 @@
"namespace.new.button": "新しい名前空間",
"namespace.new.prompt": "カスタムカラーを追加するには、新しい名前空間を作成してください!",
"preview.ignored": "無視",
"preview.missing_module.jxl": "JPEG XL のプレビューには {module} が必要です",
"preview.missing_module.multimedia": "マルチメディアの再生には {module} が必要です",
"preview.multiple_selection": "<b>{count}</b> 件選択済み",
"preview.no_selection": "選択されていません",
"preview.unlinked": "リンク切れ",
@@ -254,11 +312,15 @@
"select.all": "すべて選択",
"select.clear": "選択を解除",
"select.inverse": "選択を反転",
"settings.appearance": "外観",
"settings.cached_thumb_resolution.label": "サムネイル キャッシュの解像度",
"settings.clear_thumb_cache.title": "サムネイルキャッシュをクリア",
"settings.dateformat.english": "English",
"settings.dateformat.international": "International",
"settings.dateformat.label": "日付形式",
"settings.dateformat.system": "システム",
"settings.edit_field_on_add": "フィールド追加後に編集",
"settings.edit_tag_on_create": "新しいタグ作成後に編集",
"settings.filepath.label": "ファイルパスの表示形式",
"settings.filepath.option.full": "フルパスを表示",
"settings.filepath.option.name": "ファイル名のみ表示",
@@ -269,12 +331,16 @@
"settings.infinite_scroll": "無限スクロール",
"settings.language": "言語",
"settings.library": "ライブラリ設定",
"settings.localization": "ローカライズ",
"settings.media": "メディア",
"settings.open_library_on_start": "起動時にライブラリを開く",
"settings.page_size": "ページサイズ",
"settings.restart_required": "変更を反映するには、TagStudio を再起動してください。",
"settings.scan_files_on_open": "新しいファイルを自動的に読み込む",
"settings.show_filenames_in_grid": "グリッドにファイル名を表示",
"settings.show_recent_libraries": "最近使用したライブラリを表示",
"settings.splash.label": "スプラッシュ スクリーン",
"settings.splash.option.aurora": "オーロラ (9.6)",
"settings.splash.option.classic": "クラシック (9.0)",
"settings.splash.option.default": "既定",
"settings.splash.option.goo_gears": "オープン ソース (9.4)",
@@ -294,18 +360,18 @@
"sorting.direction.ascending": "昇順",
"sorting.direction.descending": "降順",
"sorting.mode.random": "ランダム",
"splash.opening_library": "ライブラリ \"{library_path}\" を開いています...",
"splash.opening_library": "ライブラリ \"{library_path}\" を開いています",
"status.deleted_file_plural": "{count} 件のファイルを削除しました!",
"status.deleted_file_singular": "1 件のファイルを削除しました!",
"status.deleted_none": "ファイルは削除されませんでした。",
"status.deleted_partial_warning": "{count} 件のファイルしか削除できませんでした。ファイルが存在しないか、使用中でないかを確認してください。",
"status.deleting_file": "[{i}/{count}] 件目のファイルを削除ファイルを削除しています : \"{path}\"...",
"status.library_backup_in_progress": "ライブラリを保存しています...",
"status.deleting_file": "[{i}/{count}] 件目のファイルを削除ファイルを削除しています : \"{path}\"",
"status.library_backup_in_progress": "ライブラリを保存しています",
"status.library_backup_success": "ライブラリのバックアップを保存しました: \"{path}\" ({time_span})",
"status.library_closed": "ライブラリを閉じました ({time_span})",
"status.library_closing": "ライブラリを閉じています...",
"status.library_closing": "ライブラリを閉じています",
"status.library_save_success": "ライブラリを保存して閉じました!",
"status.library_search_query": "ライブラリを検索しています...",
"status.library_search_query": "ライブラリを検索しています",
"status.library_version_expected": "想定されるバージョン:",
"status.library_version_found": "検出されたバージョン:",
"status.library_version_mismatch": "ライブラリのバージョンが一致しません!",
@@ -331,6 +397,7 @@
"tag.parent_tags": "親タグ",
"tag.parent_tags.add": "親タグを追加",
"tag.parent_tags.description": "このタグは、検索時にこれらの親タグの代わりとして扱うことができます。",
"tag.properties": "プロパティ",
"tag.remove": "タグの削除",
"tag.search_for_tag": "このタグで検索",
"tag.shorthand": "略称",
@@ -339,8 +406,8 @@
"trash.context.ambiguous": "ファイルを {trash_term} に移動",
"trash.context.plural": "ファイルを {trash_term} に移動",
"trash.context.singular": "ファイルを {trash_term} に移動",
"trash.dialog.disambiguation_warning.plural": "これにより、TagStudio だけでなく<i>ファイル システムからも</i>削除されます!",
"trash.dialog.disambiguation_warning.singular": "これにより、TagStudio だけでなく<i>ファイル システムからも</i>削除されます!",
"trash.dialog.disambiguation_warning.plural": "これにより、TagStudio <i>と</i> ファイル システムからも削除されます!",
"trash.dialog.disambiguation_warning.singular": "これにより、TagStudio <i>と</i> ファイル システムからも削除されます!",
"trash.dialog.move.confirmation.plural": "{count} 件のファイルを{trash_term}に移動してもよろしいですか?",
"trash.dialog.move.confirmation.singular": "このファイルを{trash_term}に移動してもよろしいですか?",
"trash.dialog.permanent_delete_warning": "<b>警告:</b> このファイルを{trash_term}に移動できない場合、<b>完全に削除されます!</b>",
@@ -348,6 +415,7 @@
"trash.dialog.title.singular": "ファイルの削除",
"trash.name.generic": "ごみ箱",
"trash.name.windows": "ごみ箱",
"update.view_update": "更新を表示",
"version_modal.description": "TagStudio の新しいバージョンが利用できます。<a href=\"{github_url}\">GitHub</a> から最新リリースをダウンロードできます。",
"version_modal.status": "インストール済みのバージョン: {installed_version}<br>最新リリースのバージョン: {latest_release_version}",
"version_modal.title": "TagStudio の更新があります",
+20 -1
View File
@@ -1,9 +1,13 @@
{
"about.app_cache_path": "Applicatie Cache Pad",
"about.config_path": "Configuratie Pad",
"about.description": "TagStudio is een applicatie om foto's en bestanden te organiseren, met een onderliggend systeem gebaseerd op tags, dat zich focust op vrijheid en flexibiliteit bieden aan de gebruiker. Geen gepatenteerde programma's of bestandsformaten, geen zeëen aan sidecarbestanden, en je bestaande bestandsstructuur wordt niet volledig overhoop gegooid.",
"about.documentation": "Documentatie",
"about.module.found": "Gevonden",
"about.modules.title": "Optionele Modules",
"about.title": "Over TagSudio",
"about.version": "Versie",
"about.version.latest": "{built_version} (Laatste Uitgave: {latest_version})",
"about.website": "Website",
"app.git": "Git Commit",
"app.pre_release": "Pre-Release",
@@ -43,6 +47,7 @@
"field.mixed_data": "Gemixte Data",
"field.paste": "Veld Plakken",
"field.remove": "Veld Weghalen",
"field_type.text": "Text",
"file.date_added": "Datum Toegevoegd",
"file.date_created": "Datum Aangemaakt",
"file.date_modified": "Datum Aangepast",
@@ -80,14 +85,16 @@
"generic.save": "Opslaan",
"generic.skip": "Overslaan",
"generic.skip_alt": "&Overslaan",
"generic.yes": "Ja",
"home.search": "Zoeken",
"home.search_tags": "Labels Zoeken",
"home.search_tags": "Labels Zoeken",
"home.thumbnail_size": "Miniatuur Grootte",
"home.thumbnail_size.extra_large": "Extra Grote Miniaturen",
"home.thumbnail_size.large": "Grote Miniaturen",
"home.thumbnail_size.medium": "Gemiddelde Minituren",
"home.thumbnail_size.mini": "Mini Miniaturen",
"home.thumbnail_size.small": "Kleine Miniaturen",
"json_migration.creating_database_tables": "SQL databasetabellen worden aangemaakt…",
"json_migration.finish_migration": "Migratie Afronden",
"json_migration.heading.aliases": "Aliassen:",
"json_migration.heading.colors": "Kleuren:",
@@ -96,9 +103,16 @@
"json_migration.heading.shorthands": "Afkortingen:",
"json_migration.migration_complete": "Migratie Afgerond!",
"json_migration.title": "Migratie Formaat Opslaan: \"{path}\"",
"library.refresh.scanning.plural": "Mappen scannen voor nieuwe bestanden...\n{searched_count} bestanden doorgezocht, {found_count} nieuwe bestanden gevonden",
"library.refresh.scanning_preparing": "Mappen scannen voor nieuwe bestanden...\nVoorbereiden...",
"library.refresh.title": "Mappen Verversen",
"library_info.stats": "Statistieken",
"library_info.stats.fields": "Velden:",
"library_info.stats.macros": "Macros:",
"library_info.stats.tags": "Labels:",
"library_object.name": "Naam",
"library_object.name_required": "Naam (vereist)",
"media_player.loop": "Herhalen",
"menu.delete_selected_files_ambiguous": "Bestand(en) verplaatsen naar {trash_term}",
"menu.delete_selected_files_plural": "Bestanden verplaatsen naar {trash_term}",
"menu.delete_selected_files_singular": "Bestand verplaatsen naar {trash_term}",
@@ -107,10 +121,15 @@
"menu.edit.manage_tags": "Labels Beheren",
"menu.edit.new_tag": "Nieuw &Label",
"menu.file": "&Bestand",
"menu.file.missing_library.title": "Missende bibliotheek",
"menu.help": "&Help",
"menu.help.about": "Over",
"menu.macros": "&Macros",
"menu.macros.folders_to_tags": "Mappen naar Labels",
"menu.select": "Selecteren",
"menu.settings": "Instellingen…",
"menu.window": "Venster",
"preview.ignored": "Genegeerd",
"select.all": "Alles Selecteren",
"select.inverse": "Selectie omkeren",
"sorting.direction.ascending": "Oplopend",