diff --git a/docs/updates/schema_changes.md b/docs/updates/schema_changes.md new file mode 100644 index 00000000..aeed0983 --- /dev/null +++ b/docs/updates/schema_changes.md @@ -0,0 +1,46 @@ +# Save Format Changes + +This page outlines the various changes made the TagStudio save file format over time, sometimes referred to as the "database" or "database file". + +## JSON + +| First Used | Last Used | Format | Location | +| ---------- | ----------------------------------------------------------------------- | ------ | --------------------------------------------- | +| v1.0.0 | [v9.4.2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.4.2) | JSON | ``/.TagStudio/ts_library.json | + +The legacy database format for public TagStudio releases [v9.1](https://github.com/TagStudioDev/TagStudio/tree/Alpha-v9.1) through [v9.4.2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.4.2). Variations of this format had been used privately since v1.0.0. + +Replaced by the new SQLite format introduced in TagStudio [v9.5.0 Pre-Release 1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr1). + +## DB_VERSION 6 + +| First Used | Last Used | Format | Location | +| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------ | ----------------------------------------------- | +| [v9.5.0-PR1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr1) | [v9.5.0-PR1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr1) | SQLite | ``/.TagStudio/ts_library.sqlite | + +The first public version of the SQLite save file format. + +Migration from the legacy JSON format is provided via a walkthrough when opening a legacy library in TagStudio [v9.5.0 Pre-Release 1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr1) or later. + +## DB_VERSION 7 + +| First Used | Last Used | Format | Location | +| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------ | ----------------------------------------------- | +| [v9.5.0-PR2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr2) | [v9.5.0-PR3](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr3) | SQLite | ``/.TagStudio/ts_library.sqlite | + +### Changes + +- Repairs "Description" fields to use a TEXT_LINE key instead of a TEXT_BOX key. +- Repairs tags that may have a disambiguation_id pointing towards a deleted tag. + +## DB_VERSION 8 + +| First Used | Last Used | Format | Location | +| ------------------------------------------------------------------------------- | --------- | ------ | ----------------------------------------------- | +| [v9.5.0-PR4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr4) | _Current_ | SQLite | ``/.TagStudio/ts_library.sqlite | + +### Changes + +- Adds the `color_border` column to `tag_colors` table. Used for instructing the [secondary color](../library/tag_color.md#secondary-color) to apply to a tag's border as a new optional behavior. +- Adds three new default colors: "Burgundy (TagStudio Shades)", "Dark Teal (TagStudio Shades)", and "Dark Lavender (TagStudio Shades)". +- Updates Neon colors to use the the new `color_border` property. diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index 703a9fb4..d1fa38c0 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -184,6 +184,7 @@ class LibraryStatus: success: bool library_path: Path | None = None message: str | None = None + msg_description: str | None = None json_migration_req: bool = False @@ -460,10 +461,12 @@ class Library: # Apply any post-SQL migration patches. if not is_new: + if db_version < 8: + self.apply_db8_schema_changes(session) if db_version == 6: - self.apply_db6_patches(session) + self.apply_repairs_for_db6(session) if db_version >= 6 and db_version < 8: - self.apply_db7_patches(session) + self.apply_db8_default_data(session) # Update DB_VERSION if LibraryPrefs.DB_VERSION.default > db_version: @@ -473,11 +476,8 @@ class Library: self.library_dir = library_dir return LibraryStatus(success=True, library_path=library_dir) - def apply_db6_patches(self, session: Session): - """Apply migration patches to a library with DB_VERSION 6. - - DB_VERSION 6 was only used in v9.5.0-pr1. - """ + def apply_repairs_for_db6(self, session: Session): + """Apply database repairs introduced in DB_VERSION 7.""" logger.info("[Library][Migration] Applying patches to DB_VERSION: 6 library...") with session: # Repair "Description" fields with a TEXT_LINE key instead of a TEXT_BOX key. @@ -501,11 +501,8 @@ class Library: session.commit() - def apply_db7_patches(self, session: Session): - """Apply migration patches to a library with DB_VERSION 7 or earlier. - - DB_VERSION 7 was used from v9.5.0-pr2 to v9.5.0-pr3. - """ + def apply_db8_schema_changes(self, session: Session): + """Apply database schema changes introduced in DB_VERSION 8.""" # TODO: Use Alembic for this part instead # Add the missing color_border column to the TagColorGroups table. color_border_stmt = text( @@ -522,6 +519,8 @@ class Library: ) session.rollback() + def apply_db8_default_data(self, session: Session): + """Apply default data changes introduced in DB_VERSION 8.""" tag_colors: list[TagColorGroup] = default_color_groups.standard() tag_colors += default_color_groups.pastels() tag_colors += default_color_groups.shades() diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index f02cf8ed..6fb39121 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -710,14 +710,16 @@ class QtDriver(DriverMixin, QObject): app.exec() self.shutdown() - def show_error_message(self, message: str): - self.main_window.statusbar.showMessage(message, Qt.AlignmentFlag.AlignLeft) - self.main_window.landing_widget.set_status_label(message) - self.main_window.setWindowTitle(message) + def show_error_message(self, error_name: str, error_desc: str | None = None): + self.main_window.statusbar.showMessage(error_name, Qt.AlignmentFlag.AlignLeft) + self.main_window.landing_widget.set_status_label(error_name) + self.main_window.setWindowTitle(f"{self.base_title} - {error_name}") msg_box = QMessageBox() msg_box.setIcon(QMessageBox.Icon.Critical) - msg_box.setText(message) + msg_box.setText(error_name) + if error_desc: + msg_box.setInformativeText(error_desc) msg_box.setWindowTitle(Translations["window.title.error"]) msg_box.addButton(Translations["generic.close"], QMessageBox.ButtonRole.AcceptRole) @@ -1871,12 +1873,14 @@ class QtDriver(DriverMixin, QObject): if self.lib.library_dir: self.close_library() - open_status: LibraryStatus = None + open_status: LibraryStatus | None = None try: open_status = self.lib.open_library(path) except Exception as e: logger.exception(e) - open_status = LibraryStatus(success=False, library_path=path, message=type(e).__name__) + open_status = LibraryStatus( + success=False, library_path=path, message=type(e).__name__, msg_description=str(e) + ) # Migration is required if open_status.json_migration_req: @@ -1892,7 +1896,9 @@ class QtDriver(DriverMixin, QObject): def init_library(self, path: Path, open_status: LibraryStatus): if not open_status.success: self.show_error_message( - open_status.message or Translations["window.message.error_opening_library"] + error_name=open_status.message + or Translations["window.message.error_opening_library"], + error_desc=open_status.msg_description, ) return open_status diff --git a/tagstudio/src/qt/widgets/landing.py b/tagstudio/src/qt/widgets/landing.py index 3f6b1594..1a17f59f 100644 --- a/tagstudio/src/qt/widgets/landing.py +++ b/tagstudio/src/qt/widgets/landing.py @@ -161,7 +161,7 @@ class LandingWidget(QWidget): # self.status_pos_anim.setEndValue(self.status_label.pos()) # self.status_pos_anim.start() - def set_status_label(self, text=str): + def set_status_label(self, text: str): """Set the text of the status label. Args: diff --git a/tagstudio/tests/fixtures/empty_libraries/DB_VERSION_6/.TagStudio/ts_library.sqlite b/tagstudio/tests/fixtures/empty_libraries/DB_VERSION_6/.TagStudio/ts_library.sqlite new file mode 100644 index 00000000..4f6acfe8 Binary files /dev/null and b/tagstudio/tests/fixtures/empty_libraries/DB_VERSION_6/.TagStudio/ts_library.sqlite differ diff --git a/tagstudio/tests/fixtures/empty_libraries/DB_VERSION_7/.TagStudio/ts_library.sqlite b/tagstudio/tests/fixtures/empty_libraries/DB_VERSION_7/.TagStudio/ts_library.sqlite new file mode 100644 index 00000000..cee2bbb2 Binary files /dev/null and b/tagstudio/tests/fixtures/empty_libraries/DB_VERSION_7/.TagStudio/ts_library.sqlite differ diff --git a/tagstudio/tests/fixtures/empty_libraries/DB_VERSION_8/.TagStudio/ts_library.sqlite b/tagstudio/tests/fixtures/empty_libraries/DB_VERSION_8/.TagStudio/ts_library.sqlite new file mode 100644 index 00000000..ffb55932 Binary files /dev/null and b/tagstudio/tests/fixtures/empty_libraries/DB_VERSION_8/.TagStudio/ts_library.sqlite differ diff --git a/tagstudio/tests/test_db_migrations.py b/tagstudio/tests/test_db_migrations.py new file mode 100644 index 00000000..dd73440b --- /dev/null +++ b/tagstudio/tests/test_db_migrations.py @@ -0,0 +1,47 @@ +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +import shutil +from pathlib import Path + +import pytest +from src.core.constants import TS_FOLDER_NAME +from src.core.library.alchemy.library import Library + +CWD = Path(__file__) +FIXTURES = "fixtures" +EMPTY_LIBRARIES = "empty_libraries" + + +@pytest.mark.parametrize( + "path", + [ + str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_6")), + str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_7")), + str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_8")), + ], +) +def test_library_migrations(path: str): + library = Library() + + # Copy libraries to temp dir so modifications don't show up in version control + original_path = Path(path) + temp_path = Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_TEMP") + temp_path.mkdir(exist_ok=True) + temp_path_ts = temp_path / TS_FOLDER_NAME + temp_path_ts.mkdir(exist_ok=True) + shutil.copy( + original_path / TS_FOLDER_NAME / Library.SQL_FILENAME, + temp_path / TS_FOLDER_NAME / Library.SQL_FILENAME, + ) + + try: + status = library.open_library(library_dir=temp_path) + library.close() + shutil.rmtree(temp_path) + assert status.success + except Exception as e: + library.close() + shutil.rmtree(temp_path) + raise (e)