Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 102cfdf4a2 | |||
| 47b123569e | |||
| 09a54d5dfc | |||
| 7782d2438f | |||
| 92e29635c7 | |||
| 6e288afd8c | |||
| 354c0a0afc | |||
| dd00f4d4a0 | |||
| 1bfea60d7a | |||
| c015b1d4f2 | |||
| 4b200a674e | |||
| 507823bfcd | |||
| 3def6c5239 | |||
| f04421fa93 | |||
| b29e612e7f |
@@ -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"
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 12 KiB |
@@ -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"
|
||||
{ width=80% }
|
||||
<br>
|
||||
{ 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">
|
||||
{ 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">
|
||||
{ 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>
|
||||
{ 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.
|
||||
|
||||
@@ -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.3"
|
||||
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"),
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
return 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,571 @@
|
||||
# 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__)
|
||||
|
||||
|
||||
class MigrationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class DBMigration:
|
||||
version: int
|
||||
initial_version: int | None = None
|
||||
|
||||
@classmethod
|
||||
def run(cls, session: Session, library_dir: Path, fmt_log: Callable[[str], str]):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class DBMigrations:
|
||||
def __init__(self, library_dir: Path, engine: Engine) -> None:
|
||||
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] Starting with library DB version: {self.loaded_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:
|
||||
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)
|
||||
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()
|
||||
logger.info(f"[Library][Migration][{migration.version}] Completed DB Migration")
|
||||
|
||||
assert self.loaded_db_version >= DB_VERSION, (
|
||||
"Ran all migrations, but the DB is still not on the newest version"
|
||||
)
|
||||
logger.info(f"[Library][Migration] Library migrated to DB version {DB_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):
|
||||
"""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):
|
||||
"""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):
|
||||
"""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"))
|
||||
|
||||
# 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):
|
||||
"""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):
|
||||
"""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):
|
||||
"""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):
|
||||
"""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):
|
||||
"""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):
|
||||
"""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):
|
||||
"""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):
|
||||
"""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):
|
||||
## 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()
|
||||
@@ -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,24 @@ class FieldSuggestBox(SuggestBox[BaseFieldTemplate]):
|
||||
else:
|
||||
return ([], [])
|
||||
|
||||
@override
|
||||
def _on_shift_held(self, held: bool) -> None:
|
||||
if held:
|
||||
self.set_hint_icon(self._rm.hint_field_create)
|
||||
else:
|
||||
self._update_hint_icon()
|
||||
|
||||
return super()._on_shift_held(held)
|
||||
|
||||
@override
|
||||
def _update_hint_icon(self) -> None:
|
||||
if 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
|
||||
|
||||
@@ -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,24 +99,69 @@ 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)
|
||||
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:
|
||||
self._is_shift_held = True
|
||||
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
|
||||
):
|
||||
self._is_shift_held = False
|
||||
underlined_widget.widget.setGraphicsEffect(None) # pyright: ignore[reportArgumentType]
|
||||
|
||||
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=24, ymargin=0
|
||||
)
|
||||
else:
|
||||
underlined_widget.toggle_underline(is_hidden=True)
|
||||
self._update_hint_icon()
|
||||
|
||||
def _clear_search_query(self) -> None:
|
||||
self.layout().search_field.setText("")
|
||||
|
||||
@@ -104,14 +169,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 +184,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 +201,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=24, ymargin=0
|
||||
)
|
||||
|
||||
# Get results for the search query
|
||||
query_lower = "" if not query else query.lower()
|
||||
@@ -187,7 +265,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 +275,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 +292,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 +302,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,33 @@ 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 _on_shift_held(self, held: bool) -> None:
|
||||
# Bypass normal _update_hint_icon() behavior first
|
||||
if not held and self._is_selected_item_added():
|
||||
self.set_hint_icon(self._rm.hint_tag_added)
|
||||
elif held:
|
||||
self.set_hint_icon(self._rm.hint_tag_create)
|
||||
else:
|
||||
self._update_hint_icon()
|
||||
|
||||
return super()._on_shift_held(held)
|
||||
|
||||
@override
|
||||
def _update_hint_icon(self) -> None:
|
||||
results = bool(len(self._search_results) > 0)
|
||||
|
||||
if 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 +142,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 +169,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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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']}")
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -849,7 +849,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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
@@ -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",
|
||||
|
||||