mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2026-08-13 23:59:41 +02:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 57a3a6ccf1 | |||
| e2af90118a | |||
| f966d17dc6 | |||
| c97590b8ae |
@@ -4,11 +4,6 @@
|
||||
|
||||
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"
|
||||
|
||||
@@ -37,15 +32,3 @@ 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,9 +6,12 @@ from pathlib import Path
|
||||
from typing import override
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import Dialect, String, TypeDecorator
|
||||
from sqlalchemy import Dialect, Engine, String, TypeDecorator, create_engine, text
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from tagstudio.core.constants import RESERVED_TAG_END
|
||||
|
||||
logger = structlog.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -31,3 +34,38 @@ 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,6 +2,11 @@
|
||||
# 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
|
||||
@@ -16,6 +21,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import sqlalchemy
|
||||
import structlog
|
||||
import ujson
|
||||
from humanfriendly import format_timespan # pyright: ignore[reportUnknownVariableType]
|
||||
from sqlalchemy import (
|
||||
URL,
|
||||
@@ -38,7 +44,7 @@ from sqlalchemy import (
|
||||
update,
|
||||
)
|
||||
from sqlalchemy.dialects import sqlite
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import (
|
||||
InstanceState,
|
||||
Session,
|
||||
@@ -66,13 +72,16 @@ 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 Base as ModelBase
|
||||
from tagstudio.core.library.alchemy.enums import MAX_SQL_VARIABLES, BrowsingState, SortingModeEnum
|
||||
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.fields import (
|
||||
LEGACY_FIELD_MAP,
|
||||
BaseField,
|
||||
@@ -83,7 +92,6 @@ 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,
|
||||
@@ -96,6 +104,7 @@ 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
|
||||
@@ -161,6 +170,20 @@ 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])
|
||||
|
||||
@@ -408,41 +431,21 @@ 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("[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()
|
||||
logger.info(f"[Library] Library DB version: {loaded_db_version}")
|
||||
make_tables(self.engine)
|
||||
|
||||
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()
|
||||
|
||||
@@ -462,7 +465,7 @@ class Library:
|
||||
session.flush()
|
||||
|
||||
# Add default field templates
|
||||
for template in DEFAULT_FIELD_TEMPLATES:
|
||||
for template in get_default_field_templates():
|
||||
session.add(template)
|
||||
session.flush()
|
||||
|
||||
@@ -503,25 +506,414 @@ 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
|
||||
|
||||
try:
|
||||
migrations = DBMigrations(library_dir, self.engine)
|
||||
logger.info(
|
||||
"[Library] Opening SQLite Library",
|
||||
library_dir=library_dir,
|
||||
)
|
||||
|
||||
# save backup if patches will be applied
|
||||
if migrations.required:
|
||||
Library.save_library_backup_to_disk(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)
|
||||
|
||||
migrations.run()
|
||||
except MigrationError as e:
|
||||
return LibraryStatus(success=False, message=e.args[0])
|
||||
# ======================== 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.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:
|
||||
@@ -669,8 +1061,7 @@ class Library:
|
||||
with Session(self.engine) as session:
|
||||
return unwrap(session.scalar(select(func.count(Entry.id))))
|
||||
|
||||
@staticmethod
|
||||
def _all_entries(session: Session, with_joins: bool = False) -> Iterator[Entry]:
|
||||
def __all_entries(self, session: Session, with_joins: bool = False) -> Iterator[Entry]:
|
||||
"""Load entries without joins."""
|
||||
stmt = select(Entry)
|
||||
if with_joins:
|
||||
@@ -699,7 +1090,7 @@ class Library:
|
||||
def all_entries(self, with_joins: bool = False) -> Iterator[Entry]:
|
||||
"""Load entries without joins."""
|
||||
with Session(self.engine) as session:
|
||||
return Library._all_entries(session, with_joins)
|
||||
return self.__all_entries(session, with_joins)
|
||||
|
||||
@property
|
||||
def tags(self) -> list[Tag]:
|
||||
@@ -1447,17 +1838,16 @@ class Library:
|
||||
session.rollback()
|
||||
return None
|
||||
|
||||
@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)
|
||||
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)
|
||||
|
||||
filename = f"ts_library_backup_{datetime.now(UTC).strftime('%Y_%m_%d_%H%M%S')}.sqlite"
|
||||
|
||||
target_path = library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME / filename
|
||||
target_path = self.library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME / filename
|
||||
|
||||
shutil.copy2(
|
||||
library_dir / TS_FOLDER_NAME / SQL_FILENAME,
|
||||
self.library_dir / TS_FOLDER_NAME / SQL_FILENAME,
|
||||
target_path,
|
||||
)
|
||||
|
||||
@@ -1749,12 +2139,8 @@ class Library:
|
||||
Args:
|
||||
key(str): The key for the name of the version type to set.
|
||||
"""
|
||||
return Library._get_version(self.engine, key)
|
||||
|
||||
@staticmethod
|
||||
def _get_version(engine, key: str) -> int:
|
||||
with Session(engine) as session:
|
||||
engine = sqlalchemy.inspect(engine)
|
||||
with Session(self.engine) as session:
|
||||
engine = sqlalchemy.inspect(self.engine)
|
||||
try:
|
||||
# "Version" table added in DB_VERSION 101
|
||||
if engine and engine.has_table("versions"):
|
||||
@@ -1774,6 +2160,17 @@ 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()
|
||||
|
||||
@@ -1,571 +0,0 @@
|
||||
# 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()
|
||||
@@ -22,8 +22,7 @@ logger = structlog.get_logger(__name__)
|
||||
class AutofillLineEdit(QLineEdit):
|
||||
return_pressed = Signal()
|
||||
shift_return_pressed = Signal()
|
||||
holding_shift = Signal(bool)
|
||||
index_updated = Signal(int)
|
||||
shift_holding = Signal(bool)
|
||||
|
||||
def __init__(self, popup: QWidget) -> None:
|
||||
super().__init__()
|
||||
@@ -40,35 +39,23 @@ class AutofillLineEdit(QLineEdit):
|
||||
return super().focusInEvent(arg__1)
|
||||
|
||||
@override
|
||||
def event(self, arg__1: QtCore.QEvent) -> bool:
|
||||
if arg__1.type() == QtCore.QEvent.Type.KeyPress:
|
||||
assert isinstance(arg__1, QtGui.QKeyEvent)
|
||||
def keyPressEvent(self, arg__1: QtGui.QKeyEvent) -> None:
|
||||
if arg__1.key() == QtCore.Qt.Key.Key_Shift:
|
||||
self.shift_holding.emit(True) # noqa: FBT003
|
||||
|
||||
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
|
||||
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_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)
|
||||
return super().keyPressEvent(arg__1)
|
||||
|
||||
@override
|
||||
def keyReleaseEvent(self, arg__1: QtGui.QKeyEvent) -> None:
|
||||
if arg__1.key() == QtCore.Qt.Key.Key_Shift:
|
||||
self.holding_shift.emit(False) # noqa: FBT003
|
||||
self.shift_holding.emit(False) # noqa: FBT003
|
||||
return super().keyReleaseEvent(arg__1)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
from functools import partial
|
||||
from typing import Any, override
|
||||
|
||||
import structlog
|
||||
@@ -51,10 +50,9 @@ class SuggestBox[T](QWidget):
|
||||
super().__init__()
|
||||
self._lib = library
|
||||
self._settings = settings
|
||||
self._limit = 25
|
||||
self._limit = 5
|
||||
self._is_shift_held = False
|
||||
self._search_results: list[T] = []
|
||||
self._selection_index = 0
|
||||
self.added: list[int] = []
|
||||
self.excluded: list[int] = []
|
||||
|
||||
@@ -81,57 +79,23 @@ class SuggestBox[T](QWidget):
|
||||
)
|
||||
)
|
||||
|
||||
self.layout().search_field.holding_shift.connect(partial(self._on_shift_held))
|
||||
self.layout().search_field.index_updated.connect(partial(self._on_index_updated))
|
||||
self.layout().search_field.shift_holding.connect(lambda held: self._on_shift_held(held))
|
||||
|
||||
def _on_shift_held(self, held: bool) -> None:
|
||||
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)
|
||||
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)
|
||||
underlined_widget.widget.setGraphicsEffect(opacity_effect)
|
||||
else:
|
||||
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=6, ymargin=0
|
||||
)
|
||||
else:
|
||||
underlined_widget.toggle_underline(is_hidden=True)
|
||||
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)
|
||||
underlined_widget.widget.setGraphicsEffect(None) # pyright: ignore[reportArgumentType]
|
||||
|
||||
def _clear_search_query(self) -> None:
|
||||
self.layout().search_field.setText("")
|
||||
@@ -140,7 +104,7 @@ class SuggestBox[T](QWidget):
|
||||
raise NotImplementedError()
|
||||
|
||||
def _on_search_query_changed(self, query: str) -> None:
|
||||
self._update_items(query.strip())
|
||||
self._update_items(query)
|
||||
|
||||
def _on_search_query_submitted(self, query: str, always_create: bool = False) -> None:
|
||||
# Focus search field if no query
|
||||
@@ -156,7 +120,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[self._selection_index])
|
||||
self._on_item_chosen(self._search_results[0])
|
||||
|
||||
self._clear_search_query()
|
||||
self._update_items()
|
||||
@@ -176,11 +140,6 @@ class SuggestBox[T](QWidget):
|
||||
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=6, ymargin=0
|
||||
)
|
||||
|
||||
# Get results for the search query
|
||||
query_lower = "" if not query else query.lower()
|
||||
@@ -228,7 +187,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(4, 6, 4, 6)
|
||||
self.layout().content_layout.setContentsMargins(6, 6, 6, 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]
|
||||
|
||||
@@ -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 = Library.save_library_backup_to_disk(unwrap(self.lib.library_dir))
|
||||
target_path = self.lib.save_library_backup_to_disk()
|
||||
end_time = time.time()
|
||||
self.main_window.status_bar.showMessage(
|
||||
Translations.format(
|
||||
|
||||
@@ -21,9 +21,15 @@ class SuggestBoxView(QVBoxLayout):
|
||||
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;
|
||||
@@ -44,7 +50,6 @@ class SuggestBoxView(QVBoxLayout):
|
||||
scroll_area_container.setStyleSheet(autofill_scroll_top_style("container"))
|
||||
self.scroll_area = QScrollArea()
|
||||
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
|
||||
@@ -63,7 +68,7 @@ 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(search_bar_height)
|
||||
self.search_field.setMinimumHeight(28)
|
||||
self.search_field.setPlaceholderText(placeholder_text)
|
||||
self.scroll_area.setFocusProxy(self.search_field)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"about.version.latest": "{built_version} (Dernière version : {latest_version})",
|
||||
"about.website": "Site Internet",
|
||||
"app.git": "Git Commit",
|
||||
"app.nightly": "Version de développement",
|
||||
"app.pre_release": "Version Préliminaire",
|
||||
"app.title": "{base_title} - Bibliothèque '{library_dir}'",
|
||||
"color.color_border": "Utiliser la couleur secondaire sur la bordure",
|
||||
|
||||
@@ -7,5 +7,6 @@
|
||||
"color.primary": "צבע ראשי",
|
||||
"color.secondary": "צבע משני",
|
||||
"color.title.no_color": "ללא צבע",
|
||||
"color_manager.title": "שנה צבע תגית"
|
||||
"color_manager.title": "נהל צבעיי תגיות",
|
||||
"window.title.open_create_library": "פתח/צור ספרייה"
|
||||
}
|
||||
|
||||
@@ -161,11 +161,14 @@
|
||||
"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_tags": "Címkék keresése",
|
||||
"home.search_or_create_fields": "Mezők keresése/létrehozása…",
|
||||
"home.search_or_create_tags": "Címkék keresése/létrehozása…",
|
||||
"home.search_tags": "Címkék keresése…",
|
||||
"home.show_hidden_entries": "Rejtett elemel megjelenítése",
|
||||
"home.thumbnail_size": "Miniatűrök mérete",
|
||||
"home.thumbnail_size.extra_large": "Extra nagy miniatűrök",
|
||||
@@ -316,6 +319,8 @@
|
||||
"settings.dateformat.international": "Nemzetközi",
|
||||
"settings.dateformat.label": "Dátumformátum",
|
||||
"settings.dateformat.system": "Rendszer",
|
||||
"settings.edit_field_on_add": "Mező szerkesztése létrehozás után",
|
||||
"settings.edit_tag_on_create": "Címke szerkesztése létrehozás után",
|
||||
"settings.filepath.label": "&Elérési utak láthatósága",
|
||||
"settings.filepath.option.full": "Teljes elérési út megjelenítése",
|
||||
"settings.filepath.option.name": "Csak a fájlnév megjelenítése",
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
{
|
||||
"about.app_cache_path": "アプリ キャッシュのパス",
|
||||
"about.config_path": "設定ファイルのパス",
|
||||
"about.description": "TagStudio は、タグベースのシステムに基づく、写真とファイルの整理アプリです。独自のプログラムやフォーマットは使用せず、サイドカーファイルが大量に生成されることもありません。ファイルシステム全体に大きな変更を加えることなく、ユーザーに自由で柔軟な運用を提供します。",
|
||||
"about.documentation": "ドキュメント",
|
||||
"about.module.found": "インストール済み",
|
||||
"about.modules.title": "オプション モジュール",
|
||||
"about.title": "TagStudio について",
|
||||
"about.version": "バージョン",
|
||||
"about.version.latest": "{built_version} (最新リリース: {latest_version})",
|
||||
"about.website": "ウェブサイト",
|
||||
"app.git": "Git コミット",
|
||||
"app.nightly": "ナイトリー",
|
||||
"app.pre_release": "公開前",
|
||||
"app.title": "{base_title} - ライブラリ '{library_dir}'",
|
||||
"color.color_border": "境界線にアクセントカラーを使う",
|
||||
@@ -26,7 +31,7 @@
|
||||
"drop_import.description": "次のファイルは、ライブラリ内にすでに存在するファイル パスと一致しています",
|
||||
"drop_import.duplicates_choice.plural": "以下の {count} 件のファイルは、ライブラリ内にすでに存在するファイル パスと一致しています。",
|
||||
"drop_import.duplicates_choice.singular": "次のファイルは、ライブラリ内にすでに存在するファイル パスと一致しています。",
|
||||
"drop_import.progress.label.initial": "新しいファイルをインポートしています...",
|
||||
"drop_import.progress.label.initial": "新しいファイルをインポートしています…",
|
||||
"drop_import.progress.label.plural": "新しいファイルをインポートしています...\n{count} 件のファイルをインポートしました。{suffix}",
|
||||
"drop_import.progress.label.singular": "新しいファイルをインポートしています...\n1 件のファイルをインポートしました。{suffix}",
|
||||
"drop_import.progress.window_title": "ファイルをインポート",
|
||||
@@ -36,26 +41,26 @@
|
||||
"edit.paste_fields": "フィールドを貼り付け",
|
||||
"edit.tag_manager": "タグを管理",
|
||||
"entries.duplicate.merge": "重複エントリをマージ",
|
||||
"entries.duplicate.merge.label": "重複エントリを統合しています...",
|
||||
"entries.duplicate.merge.label": "重複エントリを統合しています…",
|
||||
"entries.duplicate.refresh": "重複エントリを最新の状態にする",
|
||||
"entries.duplicates.description": "重複エントリとは、ディスク上の同じファイルを指す複数のエントリを指します。これらをマージすると、すべての重複エントリのタグとメタデータが1つのまとまったエントリに統合されます。TagStudio の外部にあるファイル自体の複製である「重複ファイル」と混同しないようにご注意ください。",
|
||||
"entries.generic.refresh_alt": "最新の情報に更新(&R)",
|
||||
"entries.generic.remove.removing": "エントリの削除",
|
||||
"entries.generic.remove.removing_count": "{count} 個のエントリを削除しています...",
|
||||
"entries.generic.remove.removing_count": "{count} 個のエントリを削除しています…",
|
||||
"entries.ignored.description": "「無視」されたエントリとは、ユーザーの無視ルール(“.ts_ignore” ファイル)を更新して対象外にした後も、更新前にライブラリへ追加されていたため残っている項目を指します。無視ルールを更新した際の誤削除によるデータ損失を防ぐため、既定ではこれらのファイルはライブラリに保持されます。",
|
||||
"entries.ignored.ignored_count": "無視されたエントリ: {count}",
|
||||
"entries.ignored.remove": "無視されたエントリを削除",
|
||||
"entries.ignored.remove_alt": "無視されたエントリを削除(&V)",
|
||||
"entries.ignored.scanning": "無視されたエントリをライブラリ内でスキャンしています...",
|
||||
"entries.ignored.scanning": "無視されたエントリをライブラリ内でスキャンしています…",
|
||||
"entries.ignored.title": "無視されたエントリの修正",
|
||||
"entries.mirror": "ミラー(&M)",
|
||||
"entries.mirror.confirmation": "以下の {count} 件のエントリをミラーリングしてもよろしいですか?",
|
||||
"entries.mirror.label": "{total} 件中 {idx} 件のエントリをミラーリングしています...",
|
||||
"entries.mirror.label": "{total} 件中 {idx} 件のエントリをミラーリングしています…",
|
||||
"entries.mirror.title": "エントリをミラー",
|
||||
"entries.mirror.window_title": "エントリをミラー",
|
||||
"entries.remove.plural.confirm": "これら <b>{count}</b> 件のエントリをライブラリから削除しますか? ディスク上のファイルは削除されません。",
|
||||
"entries.remove.singular.confirm": "このエントリをライブラリから削除しますか? ディスク上のファイルは削除されません。",
|
||||
"entries.running.dialog.new_entries": "{total} 件の新しいファイル エントリを追加しています...",
|
||||
"entries.running.dialog.new_entries": "{total} 件の新しいファイル エントリを追加しています…",
|
||||
"entries.running.dialog.title": "新しいファイルエントリを追加",
|
||||
"entries.tags": "タグ",
|
||||
"entries.unlinked.description": "ライブラリの各エントリは、ディレクトリ内のファイルにリンクされています。エントリにリンクされたファイルが TagStudio 以外で移動または削除された場合、そのエントリはリンク切れとして扱われます。<br><br>リンク切れのエントリは、ディレクトリを検索して自動的に再リンクすることも、必要に応じて削除することもできます。",
|
||||
@@ -64,18 +69,34 @@
|
||||
"entries.unlinked.relink.title": "エントリの再リンク",
|
||||
"entries.unlinked.remove": "リンク切れのエントリを削除",
|
||||
"entries.unlinked.remove_alt": "リンク切れのエントリを削除(&V)",
|
||||
"entries.unlinked.scanning": "リンク切れのエントリをライブラリ内でスキャンしています...",
|
||||
"entries.unlinked.scanning": "リンク切れのエントリをライブラリ内でスキャンしています…",
|
||||
"entries.unlinked.search_and_relink": "検索して再リンク(&S)",
|
||||
"entries.unlinked.title": "リンク切れのエントリを修正",
|
||||
"entries.unlinked.unlinked_count": "リンク切れのエントリ数: {count}",
|
||||
"ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}<br>{ffprobe}: {ffprobe_status}",
|
||||
"field.add": "フィールドの追加",
|
||||
"field.add.plural": "フィールドの追加",
|
||||
"field.confirm_remove": "「{name}」フィールドを削除してもよろしいですか?",
|
||||
"field.copy": "フィールドをコピー",
|
||||
"field.edit": "フィールドを編集",
|
||||
"field.field_name_required": "フィールドの名前 (必須)",
|
||||
"field.mixed_data": "混在データ",
|
||||
"field.name": "名前",
|
||||
"field.paste": "フィールドを貼り付け",
|
||||
"field.remove": "フィールドの削除",
|
||||
"field.text.is_multiline": "複数行",
|
||||
"field.type": "種類",
|
||||
"field_template.all_field_templates": "全てのフィールド テンプレート",
|
||||
"field_template.confirm_delete": "フィールド テンプレート「{field_template_name}」を削除してもよろしいですか?",
|
||||
"field_template.create": "フィールド テンプレートを作成",
|
||||
"field_template.create_add": "\"{query}\" を作成して追加",
|
||||
"field_template.delete": "フィールド テンプレートを削除",
|
||||
"field_template.edit": "フィールド テンプレートの編集",
|
||||
"field_template.new": "新しいフィールド テンプレート",
|
||||
"field_template_manager.title": "ライブラリのフィールド テンプレート",
|
||||
"field_type.datetime": "日時",
|
||||
"field_type.text": "テキスト",
|
||||
"field_type.unknown": "不明な種類",
|
||||
"file.date_added": "追加日時",
|
||||
"file.date_created": "作成日時",
|
||||
"file.date_modified": "更新日時",
|
||||
@@ -117,6 +138,7 @@
|
||||
"generic.delete_alt": "削除(&D)",
|
||||
"generic.done": "完了",
|
||||
"generic.done_alt": "完了(&D)",
|
||||
"generic.dont_remind": "次回から表示しない",
|
||||
"generic.edit": "編集",
|
||||
"generic.edit_alt": "編集(&E)",
|
||||
"generic.filename": "ファイル名",
|
||||
@@ -139,10 +161,14 @@
|
||||
"generic.skip_alt": "スキップ(&S)",
|
||||
"generic.yes": "はい",
|
||||
"home.search": "検索",
|
||||
"home.search.how_to_exit": "(Esc で終了)",
|
||||
"home.search.view_limit": "表示件数:",
|
||||
"home.search_entries": "エントリを検索",
|
||||
"home.search_field_templates": "フィールド テンプレートの検索…",
|
||||
"home.search_library": "ライブラリを検索",
|
||||
"home.search_tags": "タグを検索",
|
||||
"home.search_or_create_fields": "フィールドの検索または作成…",
|
||||
"home.search_or_create_tags": "タグの検索または作成…",
|
||||
"home.search_tags": "タグの検索…",
|
||||
"home.show_hidden_entries": "非表示のエントリを表示",
|
||||
"home.thumbnail_size": "サムネイルのサイズ",
|
||||
"home.thumbnail_size.extra_large": "特大サムネイル",
|
||||
@@ -151,8 +177,8 @@
|
||||
"home.thumbnail_size.mini": "極小サムネイル",
|
||||
"home.thumbnail_size.small": "小サムネイル",
|
||||
"ignore.open_file": "ディスク上の \"{ts_ignore}\" ファイルを表示",
|
||||
"json_migration.checking_for_parity": "パリティチェック中...",
|
||||
"json_migration.creating_database_tables": "SQLデータベース テーブルを作成しています...",
|
||||
"json_migration.checking_for_parity": "パリティチェック中…",
|
||||
"json_migration.creating_database_tables": "SQLデータベース テーブルを作成しています…",
|
||||
"json_migration.description": "<br>ライブラリの移行処理を開始し、結果をプレビューします。変換されたライブラリは、 「移行完了」をクリックしない限り<i>使用されません</i>。<br><br>ライブラリ データは、値が一致しているか、「一致」ラベルが表示されている必要があります。 値が一致しない場合は赤色で表示され、その横に「<b>(!)</b>」マークが表示されます。<br><center><i>大規模なライブラリの場合、この処理に数分かかることがあります。</i></center>",
|
||||
"json_migration.discrepancies_found": "ライブラリの差異が見つかりました",
|
||||
"json_migration.discrepancies_found.description": "元のライブラリ形式と変換後の形式との間に差異が見つかりました。内容を確認のうえ、確認して、移行を続行するかキャンセルするかを選択してください。",
|
||||
@@ -160,13 +186,14 @@
|
||||
"json_migration.heading.aliases": "エイリアス:",
|
||||
"json_migration.heading.colors": "色:",
|
||||
"json_migration.heading.differ": "差異",
|
||||
"json_migration.heading.extensions": "拡張子:",
|
||||
"json_migration.heading.match": "一致",
|
||||
"json_migration.heading.names": "名前:",
|
||||
"json_migration.heading.parent_tags": "親タグ:",
|
||||
"json_migration.heading.paths": "パス:",
|
||||
"json_migration.heading.shorthands": "略称:",
|
||||
"json_migration.info.description": "TagStudio バージョン<b>9.4 以前</b>で作成されたライブラリ保存ファイルは、新しいバージョン<b>9.5 以降</b>の形式に移行する必要があります。<br><h2>ご確認ください:</h2><ul><li>既存のライブラリ保存ファイルが<b><i>削除されることはありません</i></b></li><li>個人ファイルが<b><i>削除・移動・変更されることはありません</i></b></li><li>新しい v9.5 以降の保存形式は、旧バージョンの TagStudio では開くことができません</li></ul><h3>変更点:</h3><ul><li>「タグ フィールド」は「タグ カテゴリ」に置き換えられました。従来のようにタグを先にフィールドに追加するのではなく、タグを直接ファイル エントリに追加します。その後、タグ編集メニューで「カテゴリとして扱う」プロパティが有効になっている親タグに基づいて、タグが自動的にカテゴリとして整理されます。どのタグでもカテゴリとして指定でき、カテゴリに指定された親タグの下に子タグが自動で整理されます。「お気に入り」タグおよび「アーカイブ」タグは、新しく追加された「メタタグ」というデフォルトのカテゴリ タグの下に分類されます。</li><li>タグの色が調整・拡張されました。一部の色は名前が変更されたり統合されたりしましたが、すべてのタグの色は v9.5 において同一または類似の色に変換されます。</li></ul>",
|
||||
"json_migration.migrating_files_entries": "{entries} 件のファイル エントリを移行しています...",
|
||||
"json_migration.migrating_files_entries": "{entries} 件のファイル エントリを移行しています…",
|
||||
"json_migration.migration_complete": "移行が完了しました!",
|
||||
"json_migration.migration_complete_with_discrepancies": "移行が完了し、差異が見つかりました",
|
||||
"json_migration.start_and_preview": "開始とプレビュー",
|
||||
@@ -174,13 +201,41 @@
|
||||
"json_migration.title.new_lib": "<h2>v9.5+ ライブラリ</h2>",
|
||||
"json_migration.title.old_lib": "<h2>v9.4 ライブラリ</h2>",
|
||||
"landing.open_create_library": "ライブラリを開く/作成する {shortcut}",
|
||||
"language.am": "アムハラ語",
|
||||
"language.ceb": "セブアノ語",
|
||||
"language.cs": "チェコ語",
|
||||
"language.da": "デンマーク語",
|
||||
"language.de": "ドイツ語",
|
||||
"language.el": "ギリシャ語",
|
||||
"language.en": "英語",
|
||||
"language.es": "スペイン語",
|
||||
"language.fi": "フィンランド語",
|
||||
"language.fil": "フィリピノ語",
|
||||
"language.fr": "フランス語",
|
||||
"language.hu": "ハンガリー語",
|
||||
"language.is": "アイスランド語",
|
||||
"language.it": "イタリア語",
|
||||
"language.ja": "日本語",
|
||||
"language.nb_NO": "ノルウェー語 (ブークモール)",
|
||||
"language.nl": "オランダ語",
|
||||
"language.pl": "ポーランド語",
|
||||
"language.pt": "ポルトガル語",
|
||||
"language.pt_BR": "ポルトガル語 (ブラジル)",
|
||||
"language.qpv": "ヴィオッサ語",
|
||||
"language.ro": "ルーマニア語",
|
||||
"language.ru": "ロシア語",
|
||||
"language.sv": "スウェーデン語",
|
||||
"language.ta": "タミル語",
|
||||
"language.th": "タイ語",
|
||||
"language.tok": "トキポナ",
|
||||
"language.tr": "トルコ語",
|
||||
"language.zh_Hans": "中国語 (簡体字)",
|
||||
"language.zh_Hant": "中国語 (繁体字)",
|
||||
"library.missing": "ライブラリの場所が見つかりません",
|
||||
"library.name": "ライブラリ",
|
||||
"library.refresh.scanning.plural": "新しいファイルを検索中...\n{searched_count} 件を検索、{found_count} 件の新規ファイルを検出",
|
||||
"library.refresh.scanning.singular": "新しいファイルを検索中...\n{searched_count} 件を検索、{found_count} 件の新規ファイルを検出",
|
||||
"library.refresh.scanning_preparing": "新しいファイルを検索中...\n準備中...",
|
||||
"library.refresh.scanning_preparing": "新しいファイルを検索中...\n準備中…",
|
||||
"library.refresh.title": "ディレクトリを更新しています",
|
||||
"library.scan_library.title": "ライブラリをスキャンしています",
|
||||
"library_info.cleanup": "クリーンアップ",
|
||||
@@ -202,7 +257,7 @@
|
||||
"library_object.name_required": "名前 (必須)",
|
||||
"library_object.slug": "ID スラッグ",
|
||||
"library_object.slug_required": "ID スラッグ (必須)",
|
||||
"macros.running.dialog.new_entries": "新しいファイル エントリ {total} 件中 {count} 件に設定済みマクロを実行しています...",
|
||||
"macros.running.dialog.new_entries": "新しいファイル エントリ {total} 件中 {count} 件に設定済みマクロを実行しています…",
|
||||
"macros.running.dialog.title": "新しいエントリにマクロを実行しています",
|
||||
"media_player.autoplay": "自動再生",
|
||||
"media_player.loop": "繰り返し",
|
||||
@@ -211,6 +266,7 @@
|
||||
"menu.delete_selected_files_singular": "ファイルを {trash_term} に移動",
|
||||
"menu.edit": "編集",
|
||||
"menu.edit.ignore_files": "ファイルとフォルダを無視",
|
||||
"menu.edit.manage_field_templates": "フィールド テンプレートの管理",
|
||||
"menu.edit.manage_tags": "タグの管理",
|
||||
"menu.edit.new_tag": "新しいタグ(&T)",
|
||||
"menu.file": "ファイル(&F)",
|
||||
@@ -231,7 +287,7 @@
|
||||
"menu.macros": "マクロ(&M)",
|
||||
"menu.macros.folders_to_tags": "フォルダー構造からタグを生成",
|
||||
"menu.select": "選択",
|
||||
"menu.settings": "設定...",
|
||||
"menu.settings": "設定…",
|
||||
"menu.tools": "ツール(&T)",
|
||||
"menu.tools.fix_duplicate_files": "重複ファイルの修正(&D)",
|
||||
"menu.tools.fix_ignored_entries": "無視されたエントリの修正(&I)",
|
||||
@@ -247,6 +303,8 @@
|
||||
"namespace.new.button": "新しい名前空間",
|
||||
"namespace.new.prompt": "カスタムカラーを追加するには、新しい名前空間を作成してください!",
|
||||
"preview.ignored": "無視",
|
||||
"preview.missing_module.jxl": "JPEG XL のプレビューには {module} が必要です",
|
||||
"preview.missing_module.multimedia": "マルチメディアの再生には {module} が必要です",
|
||||
"preview.multiple_selection": "<b>{count}</b> 件選択済み",
|
||||
"preview.no_selection": "選択されていません",
|
||||
"preview.unlinked": "リンク切れ",
|
||||
@@ -254,11 +312,15 @@
|
||||
"select.all": "すべて選択",
|
||||
"select.clear": "選択を解除",
|
||||
"select.inverse": "選択を反転",
|
||||
"settings.appearance": "外観",
|
||||
"settings.cached_thumb_resolution.label": "サムネイル キャッシュの解像度",
|
||||
"settings.clear_thumb_cache.title": "サムネイルキャッシュをクリア",
|
||||
"settings.dateformat.english": "English",
|
||||
"settings.dateformat.international": "International",
|
||||
"settings.dateformat.label": "日付形式",
|
||||
"settings.dateformat.system": "システム",
|
||||
"settings.edit_field_on_add": "フィールド追加後に編集",
|
||||
"settings.edit_tag_on_create": "新しいタグ作成後に編集",
|
||||
"settings.filepath.label": "ファイルパスの表示形式",
|
||||
"settings.filepath.option.full": "フルパスを表示",
|
||||
"settings.filepath.option.name": "ファイル名のみ表示",
|
||||
@@ -269,12 +331,16 @@
|
||||
"settings.infinite_scroll": "無限スクロール",
|
||||
"settings.language": "言語",
|
||||
"settings.library": "ライブラリ設定",
|
||||
"settings.localization": "ローカライズ",
|
||||
"settings.media": "メディア",
|
||||
"settings.open_library_on_start": "起動時にライブラリを開く",
|
||||
"settings.page_size": "ページサイズ",
|
||||
"settings.restart_required": "変更を反映するには、TagStudio を再起動してください。",
|
||||
"settings.scan_files_on_open": "新しいファイルを自動的に読み込む",
|
||||
"settings.show_filenames_in_grid": "グリッドにファイル名を表示",
|
||||
"settings.show_recent_libraries": "最近使用したライブラリを表示",
|
||||
"settings.splash.label": "スプラッシュ スクリーン",
|
||||
"settings.splash.option.aurora": "オーロラ (9.6)",
|
||||
"settings.splash.option.classic": "クラシック (9.0)",
|
||||
"settings.splash.option.default": "既定",
|
||||
"settings.splash.option.goo_gears": "オープン ソース (9.4)",
|
||||
@@ -294,18 +360,18 @@
|
||||
"sorting.direction.ascending": "昇順",
|
||||
"sorting.direction.descending": "降順",
|
||||
"sorting.mode.random": "ランダム",
|
||||
"splash.opening_library": "ライブラリ \"{library_path}\" を開いています...",
|
||||
"splash.opening_library": "ライブラリ \"{library_path}\" を開いています…",
|
||||
"status.deleted_file_plural": "{count} 件のファイルを削除しました!",
|
||||
"status.deleted_file_singular": "1 件のファイルを削除しました!",
|
||||
"status.deleted_none": "ファイルは削除されませんでした。",
|
||||
"status.deleted_partial_warning": "{count} 件のファイルしか削除できませんでした。ファイルが存在しないか、使用中でないかを確認してください。",
|
||||
"status.deleting_file": "[{i}/{count}] 件目のファイルを削除ファイルを削除しています : \"{path}\"...",
|
||||
"status.library_backup_in_progress": "ライブラリを保存しています...",
|
||||
"status.deleting_file": "[{i}/{count}] 件目のファイルを削除ファイルを削除しています : \"{path}\"…",
|
||||
"status.library_backup_in_progress": "ライブラリを保存しています…",
|
||||
"status.library_backup_success": "ライブラリのバックアップを保存しました: \"{path}\" ({time_span})",
|
||||
"status.library_closed": "ライブラリを閉じました ({time_span})",
|
||||
"status.library_closing": "ライブラリを閉じています...",
|
||||
"status.library_closing": "ライブラリを閉じています…",
|
||||
"status.library_save_success": "ライブラリを保存して閉じました!",
|
||||
"status.library_search_query": "ライブラリを検索しています...",
|
||||
"status.library_search_query": "ライブラリを検索しています…",
|
||||
"status.library_version_expected": "想定されるバージョン:",
|
||||
"status.library_version_found": "検出されたバージョン:",
|
||||
"status.library_version_mismatch": "ライブラリのバージョンが一致しません!",
|
||||
@@ -331,6 +397,7 @@
|
||||
"tag.parent_tags": "親タグ",
|
||||
"tag.parent_tags.add": "親タグを追加",
|
||||
"tag.parent_tags.description": "このタグは、検索時にこれらの親タグの代わりとして扱うことができます。",
|
||||
"tag.properties": "プロパティ",
|
||||
"tag.remove": "タグの削除",
|
||||
"tag.search_for_tag": "このタグで検索",
|
||||
"tag.shorthand": "略称",
|
||||
@@ -339,8 +406,8 @@
|
||||
"trash.context.ambiguous": "ファイルを {trash_term} に移動",
|
||||
"trash.context.plural": "ファイルを {trash_term} に移動",
|
||||
"trash.context.singular": "ファイルを {trash_term} に移動",
|
||||
"trash.dialog.disambiguation_warning.plural": "これにより、TagStudio だけでなく<i>ファイル システムからも</i>削除されます!",
|
||||
"trash.dialog.disambiguation_warning.singular": "これにより、TagStudio だけでなく<i>ファイル システムからも</i>削除されます!",
|
||||
"trash.dialog.disambiguation_warning.plural": "これにより、TagStudio <i>と</i> ファイル システムからも削除されます!",
|
||||
"trash.dialog.disambiguation_warning.singular": "これにより、TagStudio <i>と</i> ファイル システムからも削除されます!",
|
||||
"trash.dialog.move.confirmation.plural": "{count} 件のファイルを{trash_term}に移動してもよろしいですか?",
|
||||
"trash.dialog.move.confirmation.singular": "このファイルを{trash_term}に移動してもよろしいですか?",
|
||||
"trash.dialog.permanent_delete_warning": "<b>警告:</b> このファイルを{trash_term}に移動できない場合、<b>完全に削除されます!</b>",
|
||||
@@ -348,6 +415,7 @@
|
||||
"trash.dialog.title.singular": "ファイルの削除",
|
||||
"trash.name.generic": "ごみ箱",
|
||||
"trash.name.windows": "ごみ箱",
|
||||
"update.view_update": "更新を表示",
|
||||
"version_modal.description": "TagStudio の新しいバージョンが利用できます。<a href=\"{github_url}\">GitHub</a> から最新リリースをダウンロードできます。",
|
||||
"version_modal.status": "インストール済みのバージョン: {installed_version}<br>最新リリースのバージョン: {latest_release_version}",
|
||||
"version_modal.title": "TagStudio の更新があります",
|
||||
|
||||
Reference in New Issue
Block a user