Compare commits

..

2 Commits

Author SHA1 Message Date
Travis Abendshien 87115ee7d2 fix: close dangling session, migration corrections 2026-08-11 19:50:03 -07:00
Jann Stute b29e612e7f refactor: almost final migrations cleanup (#1456)
* fix: backup library before making any changes

* refactor: inline make_tables + minor cleanup

* refactor: remove unnecessary assurance

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

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

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

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

* refactor: move migrations to different file

* refactor: package each migration in a class

* fix: some syntax errors had slipped through

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

* refactor: condense imports

* fix: add override decorators

* refactor: move set_version to DBMigrations

* refactor: remove unnecessary assignment

* refactor: use _ instead of __

* fix: add missing field templates tables
2026-08-11 15:14:33 -07:00
10 changed files with 689 additions and 620 deletions
@@ -4,6 +4,11 @@
from sqlalchemy import text
from tagstudio.core.library.alchemy.fields import (
DatetimeFieldTemplate,
TextFieldTemplate,
)
SQL_FILENAME: str = "ts_library.sqlite"
JSON_FILENAME: str = "ts_library.json"
@@ -32,3 +37,15 @@ WITH RECURSIVE ChildTags AS (
)
SELECT tag_id FROM ChildTags;
""")
DEFAULT_FIELD_TEMPLATES = (
TextFieldTemplate(name="Title"),
TextFieldTemplate(name="Author"),
TextFieldTemplate(name="Artist"),
TextFieldTemplate(name="URL"),
TextFieldTemplate(name="Description", is_multiline=True),
TextFieldTemplate(name="Notes", is_multiline=True),
TextFieldTemplate(name="Comments", is_multiline=True),
DatetimeFieldTemplate(name="Date"),
)
+1 -39
View File
@@ -6,12 +6,9 @@ from pathlib import Path
from typing import override
import structlog
from sqlalchemy import Dialect, Engine, String, TypeDecorator, create_engine, text
from sqlalchemy.exc import OperationalError
from sqlalchemy import Dialect, String, TypeDecorator
from sqlalchemy.orm import DeclarativeBase
from tagstudio.core.constants import RESERVED_TAG_END
logger = structlog.getLogger(__name__)
@@ -34,38 +31,3 @@ class PathType(TypeDecorator):
class Base(DeclarativeBase):
type_annotation_map = {Path: PathType}
def make_engine(connection_string: str) -> Engine:
return create_engine(connection_string)
def make_tables(engine: Engine) -> None:
logger.info("[Library] Creating DB tables...")
with engine.connect() as conn:
# TODO: this should instead be migrations that create the exact tables that were added in
# the respective DB versions
Base.metadata.create_all(conn)
conn.commit()
# TODO: this needs to be a migration
# tag IDs < 1000 are reserved
# create tag and delete it to bump the autoincrement sequence
# TODO - find a better way
# is this the better way?
result = conn.execute(text("SELECT SEQ FROM sqlite_sequence WHERE name='tags'"))
autoincrement_val = result.scalar()
if not autoincrement_val or autoincrement_val <= RESERVED_TAG_END:
try:
conn.execute(
text(
"INSERT INTO tags "
"(id, name, color_namespace, color_slug, is_category, is_hidden) VALUES "
f"({RESERVED_TAG_END}, 'temp', NULL, NULL, false, false)"
)
)
conn.execute(text(f"DELETE FROM tags WHERE id = {RESERVED_TAG_END}"))
conn.commit()
except OperationalError as e:
logger.error("Could not initialize built-in tags", error=e)
conn.rollback()
+70 -479
View File
@@ -2,11 +2,6 @@
# SPDX-License-Identifier: GPL-3.0-only
# NOTE: This file contains necessary use of deprecated first-party code until that
# code is removed in a future version (prefs).
# pyright: reportDeprecated=false
import re
import shutil
import sys
@@ -21,7 +16,6 @@ from typing import TYPE_CHECKING
import sqlalchemy
import structlog
import ujson
from humanfriendly import format_timespan # pyright: ignore[reportUnknownVariableType]
from sqlalchemy import (
URL,
@@ -44,7 +38,7 @@ from sqlalchemy import (
update,
)
from sqlalchemy.dialects import sqlite
from sqlalchemy.exc import IntegrityError
from sqlalchemy.exc import IntegrityError, OperationalError
from sqlalchemy.orm import (
InstanceState,
Session,
@@ -72,16 +66,13 @@ from tagstudio.core.library.alchemy.constants import (
DB_VERSION,
DB_VERSION_CURRENT_KEY,
DB_VERSION_INITIAL_KEY,
DEFAULT_FIELD_TEMPLATES,
JSON_FILENAME,
SQL_FILENAME,
TAG_CHILDREN_QUERY,
)
from tagstudio.core.library.alchemy.db import make_tables
from tagstudio.core.library.alchemy.enums import (
MAX_SQL_VARIABLES,
BrowsingState,
SortingModeEnum,
)
from tagstudio.core.library.alchemy.db import Base as ModelBase
from tagstudio.core.library.alchemy.enums import MAX_SQL_VARIABLES, BrowsingState, SortingModeEnum
from tagstudio.core.library.alchemy.fields import (
LEGACY_FIELD_MAP,
BaseField,
@@ -92,6 +83,7 @@ from tagstudio.core.library.alchemy.fields import (
TextFieldTemplate,
)
from tagstudio.core.library.alchemy.joins import TagEntry, TagParent
from tagstudio.core.library.alchemy.migrations import DBMigrations, MigrationError
from tagstudio.core.library.alchemy.models import (
Entry,
Namespace,
@@ -104,7 +96,6 @@ from tagstudio.core.library.alchemy.visitors import SQLBoolExpressionBuilder
from tagstudio.core.library.ignore import migrate_ext_list
from tagstudio.core.library.json.library import Library as JsonLibrary
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.translations import Translations
if TYPE_CHECKING:
from sqlalchemy import Select
@@ -170,20 +161,6 @@ def get_default_tags() -> tuple[Tag, ...]:
return archive_tag, favorite_tag, meta_tag
def get_default_field_templates() -> tuple[BaseFieldTemplate, ...]:
"""Return the default field templates for a new TagStudio library."""
title = TextFieldTemplate(name="Title")
author = TextFieldTemplate(name="Author")
artist = TextFieldTemplate(name="Artist")
url = TextFieldTemplate(name="URL")
description = TextFieldTemplate(name="Description", is_multiline=True)
notes = TextFieldTemplate(name="Notes", is_multiline=True)
comments = TextFieldTemplate(name="Comments", is_multiline=True)
date = DatetimeFieldTemplate(name="Date")
return title, author, artist, url, description, notes, comments, date
# The difference in the number of default JSON tags vs default tags in the current version.
DEFAULT_TAG_DIFF: int = len(get_default_tags()) - len([TAG_ARCHIVED, TAG_FAVORITE])
@@ -244,7 +221,6 @@ class Library:
if self.engine:
self.engine.dispose()
self.library_dir = None
self.folder = None
self.included_files = set()
self.dupe_entries_count = -1
@@ -401,8 +377,7 @@ class Library:
return self.open_sqlite_library(library_dir, in_memory)
@staticmethod
def __get_engine(library_dir: Path, in_memory: bool, sql_filename: str):
def _get_engine(self, library_dir: Path, in_memory: bool, sql_filename: str):
connection_string = URL.create(
drivername="sqlite",
database=(
@@ -430,22 +405,42 @@ class Library:
def create_sqlite_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
self.engine = self._get_engine(library_dir, in_memory, sql_filename)
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 +460,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 +501,25 @@ class Library:
def open_sqlite_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
initial_db_version: int = DB_VERSION
logger.info("[Library] Opening SQLite Library", library_dir=library_dir)
logger.info(
"[Library] Opening SQLite Library",
library_dir=library_dir,
)
# 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)
# ======================== 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}")
# everything is fine, set the library path
self.engine = self._get_engine(library_dir, in_memory, sql_filename)
self.library_dir = library_dir
try:
migrations = DBMigrations(self)
# save backup if patches will be applied
if migrations.required:
self.save_library_backup_to_disk()
migrations.run()
except MigrationError as e:
self.library_dir = None
return LibraryStatus(success=False, message=e.args[0])
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,36 +667,32 @@ 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]:
"""Load entries without joins."""
stmt = select(Entry)
if with_joins:
# load Entry with all joins and all tags
stmt = (
stmt.outerjoin(Entry.text_fields)
.outerjoin(Entry.datetime_fields)
.outerjoin(Entry.tags)
)
stmt = stmt.options(
contains_eager(Entry.text_fields),
contains_eager(Entry.datetime_fields),
contains_eager(Entry.tags),
)
stmt = stmt.distinct()
entries = session.execute(stmt).scalars()
if with_joins:
entries = entries.unique()
for entry in entries:
yield entry
session.expunge(entry)
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)
stmt = select(Entry)
if with_joins:
# load Entry with all joins and all tags
stmt = (
stmt.outerjoin(Entry.text_fields)
.outerjoin(Entry.datetime_fields)
.outerjoin(Entry.tags)
)
stmt = stmt.options(
contains_eager(Entry.text_fields),
contains_eager(Entry.datetime_fields),
contains_eager(Entry.tags),
)
stmt = stmt.distinct()
entries = session.execute(stmt).scalars()
if with_joins:
entries = entries.unique()
for entry in entries:
yield entry
session.expunge(entry)
@property
def tags(self) -> list[Tag]:
@@ -2160,17 +1762,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,572 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: MIT
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING, override
import structlog
import ujson
from sqlalchemy import 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 Entry, 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
if TYPE_CHECKING:
from tagstudio.core.library.alchemy.library import Library
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: "Library", fmt_log: Callable[[str], str]) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError
class DBMigrations:
def __init__(self, library: "Library") -> None:
self.lib = library
self.engine = self.lib.engine
# Don't check DB version when creating new library
self.loaded_db_version = self.lib.get_version(DB_VERSION_CURRENT_KEY)
self.initial_db_version = self.lib.get_version(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.lib,
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: "Library", fmt_log: Callable[[str], str]):
"""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: "Library", fmt_log: Callable[[str], str]):
"""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: "Library", fmt_log: Callable[[str], str]):
"""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.
entries = session.execute(select(Entry).distinct()).scalars()
for entry in entries:
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: "Library", fmt_log: Callable[[str], str]):
"""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: "Library", fmt_log: Callable[[str], str]):
"""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: "Library", fmt_log: Callable[[str], str]):
"""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: "Library", fmt_log: Callable[[str], str]):
"""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: "Library", fmt_log: Callable[[str], str]):
"""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)
session.execute(text("DROP TABLE preferences"))
session.flush()
@classmethod
def __migrate_sql_to_ts_ignore(cls, session: Session, library: "Library"):
# Do not continue if existing '.ts_ignore' file is found
ts_ignore = unwrap(library.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: "Library", fmt_log: Callable[[str], str]):
"""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: "Library", fmt_log: Callable[[str], str]):
"""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: "Library", fmt_log: Callable[[str], str]):
"""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: "Library", fmt_log: Callable[[str], str]):
## 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()
+4 -2
View File
@@ -1651,7 +1651,8 @@ class QtDriver(DriverMixin, QObject):
else:
self._init_library(path, open_status)
def _init_library(self, path: Path, open_status: LibraryStatus):
def _init_library(self, path: Path, open_status: LibraryStatus, is_test: bool = False):
# TODO: Don't have an is_test parameter, the frontend and backend tasks here can be split.
if not open_status.success:
self.show_error_message(
error_name=open_status.message
@@ -1661,7 +1662,8 @@ class QtDriver(DriverMixin, QObject):
return open_status
assert self.lib.library_dir
self.init_workers()
if not is_test:
self.init_workers()
Ignore.get_patterns(self.lib.library_dir, include_global=True)
self.__reset_navigation()
@@ -10,7 +10,6 @@
"about.version.latest": "{built_version} (Dernière version : {latest_version})",
"about.website": "Site Internet",
"app.git": "Git Commit",
"app.nightly": "Version de développement",
"app.pre_release": "Version Préliminaire",
"app.title": "{base_title} - Bibliothèque '{library_dir}'",
"color.color_border": "Utiliser la couleur secondaire sur la bordure",
+1 -2
View File
@@ -7,6 +7,5 @@
"color.primary": "צבע ראשי",
"color.secondary": "צבע משני",
"color.title.no_color": "ללא צבע",
"color_manager.title": "נהל צבעיי תגיות",
"window.title.open_create_library": "פתח/צור ספרייה"
"color_manager.title": "שנה צבע תגית"
}
+2 -7
View File
@@ -161,14 +161,11 @@
"generic.skip_alt": "&Kihagyás",
"generic.yes": "Igen",
"home.search": "Keresés",
"home.search.how_to_exit": "(Kilépés az Esc billentyűvel)",
"home.search.view_limit": "Megtekintési korlát:",
"home.search_entries": "Tételek keresése",
"home.search_field_templates": "Keresés a mezőminták között",
"home.search_field_templates": "Keresés a mezőminták között",
"home.search_library": "Keresés a könyvtárban",
"home.search_or_create_fields": "Mezők keresése/létrehozása…",
"home.search_or_create_tags": "Címkék keresése/létrehozása…",
"home.search_tags": "Címkék keresése…",
"home.search_tags": "Címkék keresése",
"home.show_hidden_entries": "Rejtett elemel megjelenítése",
"home.thumbnail_size": "Miniatűrök mérete",
"home.thumbnail_size.extra_large": "Extra nagy miniatűrök",
@@ -319,8 +316,6 @@
"settings.dateformat.international": "Nemzetközi",
"settings.dateformat.label": "Dátumformátum",
"settings.dateformat.system": "Rendszer",
"settings.edit_field_on_add": "Mező szerkesztése létrehozás után",
"settings.edit_tag_on_create": "Címke szerkesztése létrehozás után",
"settings.filepath.label": "&Elérési utak láthatósága",
"settings.filepath.option.full": "Teljes elérési út megjelenítése",
"settings.filepath.option.name": "Csak a fájlnév megjelenítése",
+21 -89
View File
@@ -1,16 +1,11 @@
{
"about.app_cache_path": "アプリ キャッシュのパス",
"about.config_path": "設定ファイルのパス",
"about.description": "TagStudio は、タグベースのシステムに基づく、写真とファイルの整理アプリです。独自のプログラムやフォーマットは使用せず、サイドカーファイルが大量に生成されることもありません。ファイルシステム全体に大きな変更を加えることなく、ユーザーに自由で柔軟な運用を提供します。",
"about.documentation": "ドキュメント",
"about.module.found": "インストール済み",
"about.modules.title": "オプション モジュール",
"about.title": "TagStudio について",
"about.version": "バージョン",
"about.version.latest": "{built_version} (最新リリース: {latest_version})",
"about.website": "ウェブサイト",
"app.git": "Git コミット",
"app.nightly": "ナイトリー",
"app.pre_release": "公開前",
"app.title": "{base_title} - ライブラリ '{library_dir}'",
"color.color_border": "境界線にアクセントカラーを使う",
@@ -31,7 +26,7 @@
"drop_import.description": "次のファイルは、ライブラリ内にすでに存在するファイル パスと一致しています",
"drop_import.duplicates_choice.plural": "以下の {count} 件のファイルは、ライブラリ内にすでに存在するファイル パスと一致しています。",
"drop_import.duplicates_choice.singular": "次のファイルは、ライブラリ内にすでに存在するファイル パスと一致しています。",
"drop_import.progress.label.initial": "新しいファイルをインポートしています",
"drop_import.progress.label.initial": "新しいファイルをインポートしています...",
"drop_import.progress.label.plural": "新しいファイルをインポートしています...\n{count} 件のファイルをインポートしました。{suffix}",
"drop_import.progress.label.singular": "新しいファイルをインポートしています...\n1 件のファイルをインポートしました。{suffix}",
"drop_import.progress.window_title": "ファイルをインポート",
@@ -41,26 +36,26 @@
"edit.paste_fields": "フィールドを貼り付け",
"edit.tag_manager": "タグを管理",
"entries.duplicate.merge": "重複エントリをマージ",
"entries.duplicate.merge.label": "重複エントリを統合しています",
"entries.duplicate.merge.label": "重複エントリを統合しています...",
"entries.duplicate.refresh": "重複エントリを最新の状態にする",
"entries.duplicates.description": "重複エントリとは、ディスク上の同じファイルを指す複数のエントリを指します。これらをマージすると、すべての重複エントリのタグとメタデータが1つのまとまったエントリに統合されます。TagStudio の外部にあるファイル自体の複製である「重複ファイル」と混同しないようにご注意ください。",
"entries.generic.refresh_alt": "最新の情報に更新(&R)",
"entries.generic.remove.removing": "エントリの削除",
"entries.generic.remove.removing_count": "{count} 個のエントリを削除しています",
"entries.generic.remove.removing_count": "{count} 個のエントリを削除しています...",
"entries.ignored.description": "「無視」されたエントリとは、ユーザーの無視ルール(“.ts_ignore” ファイル)を更新して対象外にした後も、更新前にライブラリへ追加されていたため残っている項目を指します。無視ルールを更新した際の誤削除によるデータ損失を防ぐため、既定ではこれらのファイルはライブラリに保持されます。",
"entries.ignored.ignored_count": "無視されたエントリ: {count}",
"entries.ignored.remove": "無視されたエントリを削除",
"entries.ignored.remove_alt": "無視されたエントリを削除(&V)",
"entries.ignored.scanning": "無視されたエントリをライブラリ内でスキャンしています",
"entries.ignored.scanning": "無視されたエントリをライブラリ内でスキャンしています...",
"entries.ignored.title": "無視されたエントリの修正",
"entries.mirror": "ミラー(&M)",
"entries.mirror.confirmation": "以下の {count} 件のエントリをミラーリングしてもよろしいですか?",
"entries.mirror.label": "{total} 件中 {idx} 件のエントリをミラーリングしています",
"entries.mirror.label": "{total} 件中 {idx} 件のエントリをミラーリングしています...",
"entries.mirror.title": "エントリをミラー",
"entries.mirror.window_title": "エントリをミラー",
"entries.remove.plural.confirm": "これら <b>{count}</b> 件のエントリをライブラリから削除しますか? ディスク上のファイルは削除されません。",
"entries.remove.singular.confirm": "このエントリをライブラリから削除しますか? ディスク上のファイルは削除されません。",
"entries.running.dialog.new_entries": "{total} 件の新しいファイル エントリを追加しています",
"entries.running.dialog.new_entries": "{total} 件の新しいファイル エントリを追加しています...",
"entries.running.dialog.title": "新しいファイルエントリを追加",
"entries.tags": "タグ",
"entries.unlinked.description": "ライブラリの各エントリは、ディレクトリ内のファイルにリンクされています。エントリにリンクされたファイルが TagStudio 以外で移動または削除された場合、そのエントリはリンク切れとして扱われます。<br><br>リンク切れのエントリは、ディレクトリを検索して自動的に再リンクすることも、必要に応じて削除することもできます。",
@@ -69,34 +64,18 @@
"entries.unlinked.relink.title": "エントリの再リンク",
"entries.unlinked.remove": "リンク切れのエントリを削除",
"entries.unlinked.remove_alt": "リンク切れのエントリを削除(&V)",
"entries.unlinked.scanning": "リンク切れのエントリをライブラリ内でスキャンしています",
"entries.unlinked.scanning": "リンク切れのエントリをライブラリ内でスキャンしています...",
"entries.unlinked.search_and_relink": "検索して再リンク(&S)",
"entries.unlinked.title": "リンク切れのエントリを修正",
"entries.unlinked.unlinked_count": "リンク切れのエントリ数: {count}",
"ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}<br>{ffprobe}: {ffprobe_status}",
"field.add": "フィールドの追加",
"field.add.plural": "フィールドの追加",
"field.confirm_remove": "「{name}」フィールドを削除してもよろしいですか?",
"field.copy": "フィールドをコピー",
"field.edit": "フィールドを編集",
"field.field_name_required": "フィールドの名前 (必須)",
"field.mixed_data": "混在データ",
"field.name": "名前",
"field.paste": "フィールドを貼り付け",
"field.remove": "フィールドの削除",
"field.text.is_multiline": "複数行",
"field.type": "種類",
"field_template.all_field_templates": "全てのフィールド テンプレート",
"field_template.confirm_delete": "フィールド テンプレート「{field_template_name}」を削除してもよろしいですか?",
"field_template.create": "フィールド テンプレートを作成",
"field_template.create_add": "\"{query}\" を作成して追加",
"field_template.delete": "フィールド テンプレートを削除",
"field_template.edit": "フィールド テンプレートの編集",
"field_template.new": "新しいフィールド テンプレート",
"field_template_manager.title": "ライブラリのフィールド テンプレート",
"field_type.datetime": "日時",
"field_type.text": "テキスト",
"field_type.unknown": "不明な種類",
"file.date_added": "追加日時",
"file.date_created": "作成日時",
"file.date_modified": "更新日時",
@@ -138,7 +117,6 @@
"generic.delete_alt": "削除(&D)",
"generic.done": "完了",
"generic.done_alt": "完了(&D)",
"generic.dont_remind": "次回から表示しない",
"generic.edit": "編集",
"generic.edit_alt": "編集(&E)",
"generic.filename": "ファイル名",
@@ -161,14 +139,10 @@
"generic.skip_alt": "スキップ(&S)",
"generic.yes": "はい",
"home.search": "検索",
"home.search.how_to_exit": "(Esc で終了)",
"home.search.view_limit": "表示件数:",
"home.search_entries": "エントリを検索",
"home.search_field_templates": "フィールド テンプレートの検索…",
"home.search_library": "ライブラリを検索",
"home.search_or_create_fields": "フィールドの検索または作成…",
"home.search_or_create_tags": "タグの検索または作成…",
"home.search_tags": "タグの検索…",
"home.search_tags": "タグを検索",
"home.show_hidden_entries": "非表示のエントリを表示",
"home.thumbnail_size": "サムネイルのサイズ",
"home.thumbnail_size.extra_large": "特大サムネイル",
@@ -177,8 +151,8 @@
"home.thumbnail_size.mini": "極小サムネイル",
"home.thumbnail_size.small": "小サムネイル",
"ignore.open_file": "ディスク上の \"{ts_ignore}\" ファイルを表示",
"json_migration.checking_for_parity": "パリティチェック中",
"json_migration.creating_database_tables": "SQLデータベース テーブルを作成しています",
"json_migration.checking_for_parity": "パリティチェック中...",
"json_migration.creating_database_tables": "SQLデータベース テーブルを作成しています...",
"json_migration.description": "<br>ライブラリの移行処理を開始し、結果をプレビューします。変換されたライブラリは、 「移行完了」をクリックしない限り<i>使用されません</i>。<br><br>ライブラリ データは、値が一致しているか、「一致」ラベルが表示されている必要があります。 値が一致しない場合は赤色で表示され、その横に「<b>(!)</b>」マークが表示されます。<br><center><i>大規模なライブラリの場合、この処理に数分かかることがあります。</i></center>",
"json_migration.discrepancies_found": "ライブラリの差異が見つかりました",
"json_migration.discrepancies_found.description": "元のライブラリ形式と変換後の形式との間に差異が見つかりました。内容を確認のうえ、確認して、移行を続行するかキャンセルするかを選択してください。",
@@ -186,14 +160,13 @@
"json_migration.heading.aliases": "エイリアス:",
"json_migration.heading.colors": "色:",
"json_migration.heading.differ": "差異",
"json_migration.heading.extensions": "拡張子:",
"json_migration.heading.match": "一致",
"json_migration.heading.names": "名前:",
"json_migration.heading.parent_tags": "親タグ:",
"json_migration.heading.paths": "パス:",
"json_migration.heading.shorthands": "略称:",
"json_migration.info.description": "TagStudio バージョン<b>9.4 以前</b>で作成されたライブラリ保存ファイルは、新しいバージョン<b>9.5 以降</b>の形式に移行する必要があります。<br><h2>ご確認ください:</h2><ul><li>既存のライブラリ保存ファイルが<b><i>削除されることはありません</i></b></li><li>個人ファイルが<b><i>削除・移動・変更されることはありません</i></b></li><li>新しい v9.5 以降の保存形式は、旧バージョンの TagStudio では開くことができません</li></ul><h3>変更点:</h3><ul><li>「タグ フィールド」は「タグ カテゴリ」に置き換えられました。従来のようにタグを先にフィールドに追加するのではなく、タグを直接ファイル エントリに追加します。その後、タグ編集メニューで「カテゴリとして扱う」プロパティが有効になっている親タグに基づいて、タグが自動的にカテゴリとして整理されます。どのタグでもカテゴリとして指定でき、カテゴリに指定された親タグの下に子タグが自動で整理されます。「お気に入り」タグおよび「アーカイブ」タグは、新しく追加された「メタタグ」というデフォルトのカテゴリ タグの下に分類されます。</li><li>タグの色が調整・拡張されました。一部の色は名前が変更されたり統合されたりしましたが、すべてのタグの色は v9.5 において同一または類似の色に変換されます。</li></ul>",
"json_migration.migrating_files_entries": "{entries} 件のファイル エントリを移行しています",
"json_migration.migrating_files_entries": "{entries} 件のファイル エントリを移行しています...",
"json_migration.migration_complete": "移行が完了しました!",
"json_migration.migration_complete_with_discrepancies": "移行が完了し、差異が見つかりました",
"json_migration.start_and_preview": "開始とプレビュー",
@@ -201,41 +174,13 @@
"json_migration.title.new_lib": "<h2>v9.5+ ライブラリ</h2>",
"json_migration.title.old_lib": "<h2>v9.4 ライブラリ</h2>",
"landing.open_create_library": "ライブラリを開く/作成する {shortcut}",
"language.am": "アムハラ語",
"language.ceb": "セブアノ語",
"language.cs": "チェコ語",
"language.da": "デンマーク語",
"language.de": "ドイツ語",
"language.el": "ギリシャ語",
"language.en": "英語",
"language.es": "スペイン語",
"language.fi": "フィンランド語",
"language.fil": "フィリピノ語",
"language.fr": "フランス語",
"language.hu": "ハンガリー語",
"language.is": "アイスランド語",
"language.it": "イタリア語",
"language.ja": "日本語",
"language.nb_NO": "ノルウェー語 (ブークモール)",
"language.nl": "オランダ語",
"language.pl": "ポーランド語",
"language.pt": "ポルトガル語",
"language.pt_BR": "ポルトガル語 (ブラジル)",
"language.qpv": "ヴィオッサ語",
"language.ro": "ルーマニア語",
"language.ru": "ロシア語",
"language.sv": "スウェーデン語",
"language.ta": "タミル語",
"language.th": "タイ語",
"language.tok": "トキポナ",
"language.tr": "トルコ語",
"language.zh_Hans": "中国語 (簡体字)",
"language.zh_Hant": "中国語 (繁体字)",
"library.missing": "ライブラリの場所が見つかりません",
"library.name": "ライブラリ",
"library.refresh.scanning.plural": "新しいファイルを検索中...\n{searched_count} 件を検索、{found_count} 件の新規ファイルを検出",
"library.refresh.scanning.singular": "新しいファイルを検索中...\n{searched_count} 件を検索、{found_count} 件の新規ファイルを検出",
"library.refresh.scanning_preparing": "新しいファイルを検索中...\n準備中",
"library.refresh.scanning_preparing": "新しいファイルを検索中...\n準備中...",
"library.refresh.title": "ディレクトリを更新しています",
"library.scan_library.title": "ライブラリをスキャンしています",
"library_info.cleanup": "クリーンアップ",
@@ -257,7 +202,7 @@
"library_object.name_required": "名前 (必須)",
"library_object.slug": "ID スラッグ",
"library_object.slug_required": "ID スラッグ (必須)",
"macros.running.dialog.new_entries": "新しいファイル エントリ {total} 件中 {count} 件に設定済みマクロを実行しています",
"macros.running.dialog.new_entries": "新しいファイル エントリ {total} 件中 {count} 件に設定済みマクロを実行しています...",
"macros.running.dialog.title": "新しいエントリにマクロを実行しています",
"media_player.autoplay": "自動再生",
"media_player.loop": "繰り返し",
@@ -266,7 +211,6 @@
"menu.delete_selected_files_singular": "ファイルを {trash_term} に移動",
"menu.edit": "編集",
"menu.edit.ignore_files": "ファイルとフォルダを無視",
"menu.edit.manage_field_templates": "フィールド テンプレートの管理",
"menu.edit.manage_tags": "タグの管理",
"menu.edit.new_tag": "新しいタグ(&T)",
"menu.file": "ファイル(&F)",
@@ -287,7 +231,7 @@
"menu.macros": "マクロ(&M)",
"menu.macros.folders_to_tags": "フォルダー構造からタグを生成",
"menu.select": "選択",
"menu.settings": "設定",
"menu.settings": "設定...",
"menu.tools": "ツール(&T)",
"menu.tools.fix_duplicate_files": "重複ファイルの修正(&D)",
"menu.tools.fix_ignored_entries": "無視されたエントリの修正(&I)",
@@ -303,8 +247,6 @@
"namespace.new.button": "新しい名前空間",
"namespace.new.prompt": "カスタムカラーを追加するには、新しい名前空間を作成してください!",
"preview.ignored": "無視",
"preview.missing_module.jxl": "JPEG XL のプレビューには {module} が必要です",
"preview.missing_module.multimedia": "マルチメディアの再生には {module} が必要です",
"preview.multiple_selection": "<b>{count}</b> 件選択済み",
"preview.no_selection": "選択されていません",
"preview.unlinked": "リンク切れ",
@@ -312,15 +254,11 @@
"select.all": "すべて選択",
"select.clear": "選択を解除",
"select.inverse": "選択を反転",
"settings.appearance": "外観",
"settings.cached_thumb_resolution.label": "サムネイル キャッシュの解像度",
"settings.clear_thumb_cache.title": "サムネイルキャッシュをクリア",
"settings.dateformat.english": "English",
"settings.dateformat.international": "International",
"settings.dateformat.label": "日付形式",
"settings.dateformat.system": "システム",
"settings.edit_field_on_add": "フィールド追加後に編集",
"settings.edit_tag_on_create": "新しいタグ作成後に編集",
"settings.filepath.label": "ファイルパスの表示形式",
"settings.filepath.option.full": "フルパスを表示",
"settings.filepath.option.name": "ファイル名のみ表示",
@@ -331,16 +269,12 @@
"settings.infinite_scroll": "無限スクロール",
"settings.language": "言語",
"settings.library": "ライブラリ設定",
"settings.localization": "ローカライズ",
"settings.media": "メディア",
"settings.open_library_on_start": "起動時にライブラリを開く",
"settings.page_size": "ページサイズ",
"settings.restart_required": "変更を反映するには、TagStudio を再起動してください。",
"settings.scan_files_on_open": "新しいファイルを自動的に読み込む",
"settings.show_filenames_in_grid": "グリッドにファイル名を表示",
"settings.show_recent_libraries": "最近使用したライブラリを表示",
"settings.splash.label": "スプラッシュ スクリーン",
"settings.splash.option.aurora": "オーロラ (9.6)",
"settings.splash.option.classic": "クラシック (9.0)",
"settings.splash.option.default": "既定",
"settings.splash.option.goo_gears": "オープン ソース (9.4)",
@@ -360,18 +294,18 @@
"sorting.direction.ascending": "昇順",
"sorting.direction.descending": "降順",
"sorting.mode.random": "ランダム",
"splash.opening_library": "ライブラリ \"{library_path}\" を開いています",
"splash.opening_library": "ライブラリ \"{library_path}\" を開いています...",
"status.deleted_file_plural": "{count} 件のファイルを削除しました!",
"status.deleted_file_singular": "1 件のファイルを削除しました!",
"status.deleted_none": "ファイルは削除されませんでした。",
"status.deleted_partial_warning": "{count} 件のファイルしか削除できませんでした。ファイルが存在しないか、使用中でないかを確認してください。",
"status.deleting_file": "[{i}/{count}] 件目のファイルを削除ファイルを削除しています : \"{path}\"",
"status.library_backup_in_progress": "ライブラリを保存しています",
"status.deleting_file": "[{i}/{count}] 件目のファイルを削除ファイルを削除しています : \"{path}\"...",
"status.library_backup_in_progress": "ライブラリを保存しています...",
"status.library_backup_success": "ライブラリのバックアップを保存しました: \"{path}\" ({time_span})",
"status.library_closed": "ライブラリを閉じました ({time_span})",
"status.library_closing": "ライブラリを閉じています",
"status.library_closing": "ライブラリを閉じています...",
"status.library_save_success": "ライブラリを保存して閉じました!",
"status.library_search_query": "ライブラリを検索しています",
"status.library_search_query": "ライブラリを検索しています...",
"status.library_version_expected": "想定されるバージョン:",
"status.library_version_found": "検出されたバージョン:",
"status.library_version_mismatch": "ライブラリのバージョンが一致しません!",
@@ -397,7 +331,6 @@
"tag.parent_tags": "親タグ",
"tag.parent_tags.add": "親タグを追加",
"tag.parent_tags.description": "このタグは、検索時にこれらの親タグの代わりとして扱うことができます。",
"tag.properties": "プロパティ",
"tag.remove": "タグの削除",
"tag.search_for_tag": "このタグで検索",
"tag.shorthand": "略称",
@@ -406,8 +339,8 @@
"trash.context.ambiguous": "ファイルを {trash_term} に移動",
"trash.context.plural": "ファイルを {trash_term} に移動",
"trash.context.singular": "ファイルを {trash_term} に移動",
"trash.dialog.disambiguation_warning.plural": "これにより、TagStudio <i>と</i> ファイル システムからも削除されます!",
"trash.dialog.disambiguation_warning.singular": "これにより、TagStudio <i>と</i> ファイル システムからも削除されます!",
"trash.dialog.disambiguation_warning.plural": "これにより、TagStudio だけでなく<i>ファイル システムからも</i>削除されます!",
"trash.dialog.disambiguation_warning.singular": "これにより、TagStudio だけでなく<i>ファイル システムからも</i>削除されます!",
"trash.dialog.move.confirmation.plural": "{count} 件のファイルを{trash_term}に移動してもよろしいですか?",
"trash.dialog.move.confirmation.singular": "このファイルを{trash_term}に移動してもよろしいですか?",
"trash.dialog.permanent_delete_warning": "<b>警告:</b> このファイルを{trash_term}に移動できない場合、<b>完全に削除されます!</b>",
@@ -415,7 +348,6 @@
"trash.dialog.title.singular": "ファイルの削除",
"trash.name.generic": "ごみ箱",
"trash.name.windows": "ごみ箱",
"update.view_update": "更新を表示",
"version_modal.description": "TagStudio の新しいバージョンが利用できます。<a href=\"{github_url}\">GitHub</a> から最新リリースをダウンロードできます。",
"version_modal.status": "インストール済みのバージョン: {installed_version}<br>最新リリースのバージョン: {latest_release_version}",
"version_modal.title": "TagStudio の更新があります",
+1 -1
View File
@@ -144,7 +144,7 @@ def test_title_update(
qt_driver.main_window.menu_bar.folders_to_tags_action = QAction(menu_bar)
# Trigger the update
qt_driver._init_library(library_dir, open_status)
qt_driver._init_library(library_dir, open_status, is_test=True)
# Assert the title is updated correctly
qt_driver.main_window.setWindowTitle.assert_called_with(expected_title(library_dir, base_title))