Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
028444f
refactor: cleanup parameters of open_library and open_sqlite_library
Computerdores Jan 18, 2026
de0a5b5
doc: notes on what tables are affected by which migration steps
Computerdores Jan 18, 2026
c90cd66
refactor(migration order): move DBv6 repairs
Computerdores Jan 18, 2026
585ad97
refactor(migration order): move DBv8 repairs
Computerdores Jan 18, 2026
7121f72
refactor(migration order): move DBv9 repairs
Computerdores Jan 18, 2026
effdbb6
refactor(migration order): move DBv100 repairs
Computerdores Jan 18, 2026
02583c2
refactor(migration order): move DBv102 repairs
Computerdores Jan 18, 2026
e97e9a0
refactor: merge migration methods
Computerdores Jan 18, 2026
ecd9978
doc: final comment changes
Computerdores Jan 18, 2026
f28adfd
fix: query tag ids independent of future DB changes
Computerdores Jan 18, 2026
803410b
feat: remove preferences table
Computerdores Jan 18, 2026
50f8a71
refactor: various references to LibraryPrefs
Computerdores Jan 18, 2026
8a278fa
fix: update josn migration UI
Computerdores Jan 19, 2026
2d7251a
refactor: remove last vestiges of preferences table
Computerdores Jan 19, 2026
c911f47
fix: remove newly unnecessary translations
Computerdores Jan 21, 2026
1d92d4d
doc: document library format changes
Computerdores Jan 21, 2026
afeafc2
refactor: merge the two methods used for migration 104
Computerdores Jan 21, 2026
ee33346
fix: typo in sql statement
Computerdores Jan 21, 2026
19e03f7
fix: add back support for preferences table in get_version
Computerdores Jan 21, 2026
ec9f2af
fix: properly remove directory in test
Computerdores Jan 21, 2026
3bba604
fix: incorrect schema check in get_version
Computerdores Jan 21, 2026
f35ecc9
fix: update search lib via migration
Computerdores Jan 21, 2026
8a5b48d
fix: update assert in test
Computerdores Jan 21, 2026
ed66200
fix: ignore element order in assert in test
Computerdores Jan 21, 2026
e28f318
fix: use correct path
Computerdores Jan 21, 2026
95e3c60
fix: better test output
Computerdores Jan 21, 2026
00fccdb
Merge branch 'main' into fix/remove_preferences_table
CyanVoxel May 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions docs/library-changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,15 @@ Migration from the legacy JSON format is provided via a walkthrough when opening

| Used From | Format | Location |
| ----------------------------------------------------------------------- | ------ | ----------------------------------------------- |
| [#1139](https://github.com/TagStudioDev/TagStudio/pull/1139) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
| [#1139](https://github.com/TagStudioDev/TagStudio/pull/1139) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |

- Adds the `is_hidden` column to the `tags` table (default `0`). Used for excluding entries tagged with hidden tags from library searches.
- Sets the `is_hidden` field on the built-in Archived tag to `1`, to match the Archived tag now being hidden by default.
- Sets the `is_hidden` field on the built-in Archived tag to `1`, to match the Archived tag now being hidden by default.

#### Version 104

| Used From | Format | Location |
| ----------------------------------------------------------------------- | ------ | ----------------------------------------------- |
| [#1298](https://github.com/TagStudioDev/TagStudio/pull/1298) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |

- Removes the `preferences` table, after migrating the contained extension list to the .ts_ignore file, if necessary.
29 changes: 0 additions & 29 deletions src/tagstudio/core/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio

import enum
from typing import Any
from uuid import uuid4


class SettingItems(str, enum.Enum):
Expand Down Expand Up @@ -57,30 +55,3 @@ class MacroID(enum.Enum):
BUILD_URL = "build_url"
MATCH = "match"
CLEAN_URL = "clean_url"


class DefaultEnum(enum.Enum):
"""Allow saving multiple identical values in property called .default."""

default: Any

def __new__(cls, value):
# Create the enum instance
obj = object.__new__(cls)
# make value random
obj._value_ = uuid4()
# assign the actual value into .default property
obj.default = value
return obj

@property
def value(self):
raise AttributeError("access the value via .default property instead")


# TODO: Remove DefaultEnum and LibraryPrefs classes once remaining values are removed.
class LibraryPrefs(DefaultEnum):
"""Library preferences with default value accessible via .default property."""

IS_EXCLUDE_LIST = True
EXTENSION_LIST = [".json", ".xmp", ".aae"]
3 changes: 1 addition & 2 deletions src/tagstudio/core/library/alchemy/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,9 @@
SQL_FILENAME: str = "ts_library.sqlite"
JSON_FILENAME: str = "ts_library.json"

DB_VERSION_LEGACY_KEY: str = "DB_VERSION"
DB_VERSION_CURRENT_KEY: str = "CURRENT"
DB_VERSION_INITIAL_KEY: str = "INITIAL"
DB_VERSION: int = 103
DB_VERSION: int = 104

TAG_CHILDREN_QUERY = text("""
WITH RECURSIVE ChildTags AS (
Expand Down
145 changes: 36 additions & 109 deletions src/tagstudio/core/library/alchemy/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from datetime import UTC, datetime
from os import makedirs
from pathlib import Path
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING
from uuid import uuid4
from warnings import catch_warnings

Expand Down Expand Up @@ -53,7 +53,6 @@
noload,
selectinload,
)
from typing_extensions import deprecated

from tagstudio.core.constants import (
BACKUP_FOLDER_NAME,
Expand All @@ -67,13 +66,11 @@
TAG_META,
TS_FOLDER_NAME,
)
from tagstudio.core.enums import LibraryPrefs
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,
DB_VERSION_LEGACY_KEY,
JSON_FILENAME,
SQL_FILENAME,
TAG_CHILDREN_QUERY,
Expand All @@ -96,14 +93,14 @@
Entry,
Folder,
Namespace,
Preferences,
Tag,
TagAlias,
TagColorGroup,
ValueType,
Version,
)
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
Expand Down Expand Up @@ -318,9 +315,10 @@ def migrate_json_to_sqlite(self, json_lib: JsonLibrary):
value=v,
)

# Preferences
self.set_prefs(LibraryPrefs.EXTENSION_LIST, [x.strip(".") for x in json_lib.ext_list])
self.set_prefs(LibraryPrefs.IS_EXCLUDE_LIST, json_lib.is_exclude_list)
# extension include/exclude list
(unwrap(self.library_dir) / TS_FOLDER_NAME / IGNORE_NAME).write_text(
migrate_ext_list([x.strip(".") for x in json_lib.ext_list], json_lib.is_exclude_list)
)

end_time = time.time()
logger.info(f"Library Converted! ({format_timespan(end_time - start_time)})")
Expand Down Expand Up @@ -458,15 +456,6 @@ def open_sqlite_library(

# Ensure version rows are present
with catch_warnings(record=True):
# NOTE: The "Preferences" table is depreciated and will be removed in the future.
# The DB_VERSION is still being set to it in order to remain backwards-compatible
# with existing TagStudio versions until it is removed.
try:
session.add(Preferences(key=DB_VERSION_LEGACY_KEY, value=DB_VERSION))
session.commit()
except IntegrityError:
session.rollback()

try:
initial = DB_VERSION if is_new else 100
session.add(Version(key=DB_VERSION_INITIAL_KEY, value=initial))
Expand All @@ -480,15 +469,6 @@ def open_sqlite_library(
except IntegrityError:
session.rollback()

# TODO: Remove this "Preferences" system.
for pref in LibraryPrefs:
with catch_warnings(record=True):
try:
session.add(Preferences(key=pref.name, value=pref.default))
session.commit()
except IntegrityError:
session.rollback()

for field in FieldID:
try:
session.add(
Expand Down Expand Up @@ -556,10 +536,9 @@ def open_sqlite_library(
if loaded_db_version < 103:
# changes: tags
self.__apply_db103_migration(session)

# Convert file extension list to ts_ignore file, if a .ts_ignore file does not exist
# TODO: do this in the migration step that will remove the preferences table
self.migrate_sql_to_ts_ignore(library_dir)
if loaded_db_version < 104:
# changes: deletes preferences
self.__apply_db104_migrations(session, library_dir)

# Update DB_VERSION
if loaded_db_version < DB_VERSION:
Expand Down Expand Up @@ -732,33 +711,30 @@ def __apply_db103_migration(self, session: Session):
)
session.rollback()

def migrate_sql_to_ts_ignore(self, library_dir: Path):
# Do not continue if existing '.ts_ignore' file is found
if Path(library_dir / TS_FOLDER_NAME / IGNORE_NAME).exists():
return
def __apply_db104_migrations(self, session: Session, library_dir: Path):
"""Migrate DB from DB_VERSION 103 to 104."""
# Convert file extension list to ts_ignore file, if a .ts_ignore file does not exist
self.__migrate_sql_to_ts_ignore(library_dir)
session.execute(text("DROP TABLE preferences"))
session.commit()

# Create blank '.ts_ignore' file
ts_ignore_template = (
Path(__file__).parents[3] / "resources/templates/ts_ignore_template_blank.txt"
)
def __migrate_sql_to_ts_ignore(self, library_dir: Path):
# Do not continue if existing '.ts_ignore' file is found
ts_ignore = library_dir / TS_FOLDER_NAME / IGNORE_NAME
try:
shutil.copy2(ts_ignore_template, ts_ignore)
except Exception as e:
logger.error("[ERROR][Library] Could not generate '.ts_ignore' file!", error=e)
if Path(ts_ignore).exists():
return

# Load legacy extension data
extensions: list[str] = self.prefs(LibraryPrefs.EXTENSION_LIST) # pyright: ignore
is_exclude_list: bool = self.prefs(LibraryPrefs.IS_EXCLUDE_LIST) # pyright: ignore

# Copy extensions to '.ts_ignore' file
if ts_ignore.exists():
with open(ts_ignore, "a") as f:
prefix = ""
if not is_exclude_list:
prefix = "!"
f.write("*\n")
f.writelines([f"{prefix}*.{x.lstrip('.')}\n" for x in extensions])
with Session(self.engine) as session:
extensions: list[str] = 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))

@property
def default_fields(self) -> list[BaseField]:
Expand Down Expand Up @@ -1856,19 +1832,20 @@ def get_version(self, key: str) -> int:
engine = sqlalchemy.inspect(self.engine)
try:
# "Version" table added in DB_VERSION 101
if engine and engine.has_table("Version"):
if engine and engine.has_table("versions"):
version = session.scalar(select(Version).where(Version.key == key))
assert version
return version.value
# NOTE: The "Preferences" table has been depreciated as of TagStudio 9.5.4
# and is set to be removed in a future release.
else:
pref_version = session.scalar(
select(Preferences).where(Preferences.key == DB_VERSION_LEGACY_KEY)
return int(
unwrap(
session.scalar(
text("SELECT value FROM preferences WHERE key == 'DB_VERSION'")
)
)
)
assert pref_version
assert isinstance(pref_version.value, int)
return pref_version.value
except Exception:
return 0

Expand All @@ -1886,60 +1863,10 @@ def set_version(self, key: str, value: int) -> None:
version.value = value
session.add(version)
session.commit()

# If a depreciated "Preferences" table is found, update the version value to be read
# by older TagStudio versions.
engine = sqlalchemy.inspect(self.engine)
if engine and engine.has_table("Preferences"):
pref = unwrap(
session.scalar(
select(Preferences).where(Preferences.key == DB_VERSION_LEGACY_KEY)
)
)
pref.value = value # pyright: ignore
session.add(pref)
session.commit()
except (IntegrityError, AssertionError) as e:
logger.error("[Library][ERROR] Couldn't add default tag color namespaces", error=e)
session.rollback()

# TODO: Remove this once the 'preferences' table is removed.
@deprecated("Use `get_version() for version and `ts_ignore` system for extension exclusion.")
def prefs(self, key: str | LibraryPrefs): # pyright: ignore[reportUnknownParameterType]
# load given item from Preferences table
with Session(self.engine) as session:
if isinstance(key, LibraryPrefs):
return unwrap(
session.scalar(select(Preferences).where(Preferences.key == key.name))
).value # pyright: ignore[reportUnknownVariableType]
else:
return unwrap(
session.scalar(select(Preferences).where(Preferences.key == key))
).value # pyright: ignore[reportUnknownVariableType]

# TODO: Remove this once the 'preferences' table is removed.
@deprecated("Use `get_version() for version and `ts_ignore` system for extension exclusion.")
def set_prefs(self, key: str | LibraryPrefs, value: Any) -> None: # pyright: ignore[reportExplicitAny]
# set given item in Preferences table
with Session(self.engine) as session:
# load existing preference and update value
stuff = session.scalars(select(Preferences))
logger.info([x.key for x in list(stuff)])

pref: Preferences = unwrap(
session.scalar(
select(Preferences).where(
Preferences.key == (key.name if isinstance(key, LibraryPrefs) else key)
)
)
)

logger.info("loading pref", pref=pref, key=key, value=value)
pref.value = value
session.add(pref)
session.commit()
# TODO - try/except

def mirror_entry_fields(self, *entries: Entry) -> None:
"""Mirror fields among multiple Entry items."""
fields = {}
Expand Down
13 changes: 1 addition & 12 deletions src/tagstudio/core/library/alchemy/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@
from pathlib import Path
from typing import override

from sqlalchemy import JSON, ForeignKey, ForeignKeyConstraint, Integer, event
from sqlalchemy import ForeignKey, ForeignKeyConstraint, Integer, event
from sqlalchemy.orm import Mapped, mapped_column, relationship
from typing_extensions import deprecated

from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE
from tagstudio.core.library.alchemy.db import Base, PathType
Expand Down Expand Up @@ -327,16 +326,6 @@ def slugify_field_key(mapper, connection, target): # pyright: ignore
target.key = slugify(target.tag)


# NOTE: The "Preferences" table has been depreciated as of TagStudio 9.5.4
# and is set to be removed in a future release.
@deprecated("Use `Version` for storing version, and `ts_ignore` system for file exclusion.")
class Preferences(Base):
__tablename__ = "preferences"

key: Mapped[str] = mapped_column(primary_key=True)
value: Mapped[dict] = mapped_column(JSON, nullable=False)


class Version(Base):
__tablename__ = "versions"

Expand Down
17 changes: 17 additions & 0 deletions src/tagstudio/core/library/ignore.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,23 @@ def ignore_to_glob(ignore_patterns: list[str]) -> list[str]:
return glob_patterns


def migrate_ext_list(exts: list[str], is_exclude_list: bool) -> str:
# read template
ts_ignore_template = (
Path(__file__).parents[2] / "resources/templates/ts_ignore_template_blank.txt"
)
with open(ts_ignore_template) as f:
out = f.read()

# actual conversion
prefix = ""
if not is_exclude_list:
prefix = "!"
out += "*\n"
out += "\n".join([f"{prefix}*.{x.lstrip('.')}\n" for x in exts])
return out


class Ignore(metaclass=Singleton):
"""Class for processing and managing glob-like file ignore file patterns."""

Expand Down
Loading
Loading