From 529a9c67afd1963c127da3236a39346bcc96d67c Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Fri, 8 May 2026 12:49:17 -0700 Subject: [PATCH 1/6] refactor!: uncouple fields from hardcoded values --- docs/library-changes.md | 89 ++-- .../core/library/alchemy/constants.py | 2 +- src/tagstudio/core/library/alchemy/db.py | 5 - src/tagstudio/core/library/alchemy/enums.py | 8 - src/tagstudio/core/library/alchemy/fields.py | 155 +++--- src/tagstudio/core/library/alchemy/library.py | 490 +++++++++++------- src/tagstudio/core/library/alchemy/models.py | 56 +- src/tagstudio/core/library/refresh.py | 4 +- src/tagstudio/core/ts_core.py | 170 ------ .../library_info_window_controller.py | 2 +- .../controllers/preview_panel_controller.py | 3 + src/tagstudio/qt/mixed/add_field.py | 13 +- src/tagstudio/qt/mixed/field_containers.py | 131 +++-- src/tagstudio/qt/mixed/migration_modal.py | 21 +- .../qt/mixed/mirror_entries_modal.py | 2 +- src/tagstudio/qt/translations.py | 8 + src/tagstudio/qt/ts_qt.py | 31 +- src/tagstudio/resources/translations/en.json | 3 + tests/conftest.py | 12 +- tests/macros/test_dupe_files.py | 5 +- tests/test_db_migrations.py | 4 + tests/test_library.py | 169 +++--- 22 files changed, 635 insertions(+), 748 deletions(-) diff --git a/docs/library-changes.md b/docs/library-changes.md index 1c78aad82..3ae570aa7 100644 --- a/docs/library-changes.md +++ b/docs/library-changes.md @@ -64,8 +64,8 @@ Migration from the legacy JSON format is provided via a walkthrough when opening | ------------------------------------------------------------------------------- | ------ | ----------------------------------------------- | | [v9.5.0-pr2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr2) | SQLite | ``/.TagStudio/ts_library.sqlite | -- 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. +- ~~Repairs "Description" fields to use a TEXT_LINE key instead of a TEXT_BOX key.~~ _See [Version 200](#version-200)_ +- Repairs tags that may have a disambiguation_id pointing towards a deleted tag. --- @@ -75,9 +75,9 @@ Migration from the legacy JSON format is provided via a walkthrough when opening | ------------------------------------------------------------------------------- | ------ | ----------------------------------------------- | | [v9.5.0-pr4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr4) | SQLite | ``/.TagStudio/ts_library.sqlite | -- Adds the `color_border` column to the `tag_colors` table. Used for instructing the [secondary color](colors.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 new `color_border` property. +- Adds the `color_border` column to the `tag_colors` table. Used for instructing the [secondary color](colors.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 new `color_border` property. --- @@ -87,56 +87,75 @@ Migration from the legacy JSON format is provided via a walkthrough when opening | ----------------------------------------------------------------------- | ------ | ----------------------------------------------- | | [v9.5.2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.2) | SQLite | ``/.TagStudio/ts_library.sqlite | -- Adds the `filename` column to the `entries` table. Used for sorting entries by filename in search results. +- Adds the `filename` column to the `entries` table. Used for sorting entries by filename in search results. --- -### Version 100 +### Versions 100 - 1xx + +#### Version 100 | Used From | Format | Location | | ---------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------- | | [74383e3](https://github.com/TagStudioDev/TagStudio/commit/74383e3c3c12f72be1481ab0b86c7360b95c2d85) | SQLite | ``/.TagStudio/ts_library.sqlite | -- Introduces built-in minor versioning - - The version number divided by 100 (and floored) constitutes the **major** version. Major version indicate breaking changes that prevent libraries from being opened in TagStudio versions older than the ones they were created in. - - Values more precise than this ("ones" through "tens" columns) constitute the **minor** version. These indicate minor changes that don't prevent a newer library from being opened in an older version of TagStudio, as long as the major version is not also increased. -- Swaps `parent_id` and `child_id` values in the `tag_parents` table +- Introduces built-in minor versioning + - The version number divided by 100 (and floored) constitutes the **major** version. Major version indicate breaking changes that prevent libraries from being opened in TagStudio versions older than the ones they were created in. + - Values more precise than this ("ones" through "tens" columns) constitute the **minor** version. These indicate minor changes that don't prevent a newer library from being opened in an older version of TagStudio, as long as the major version is not also increased. +- Swaps `parent_id` and `child_id` values in the `tag_parents` table #### Version 101 -| Used From | Format | Location | -| ----------------------------------------------------------------------- | ------ | ----------------------------------------------- | -| [v9.5.4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.4) | SQLite | ``/.TagStudio/ts_library.sqlite | +| Used From | Format | Location | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------- | +| [12e074b](https://github.com/TagStudioDev/TagStudio/commit/12e074b71d8860282b44e49e0e1a41b7a2e4bae8)/[v9.5.4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.4) | SQLite | ``/.TagStudio/ts_library.sqlite | -- Deprecates the `preferences` table, set to be removed in a future TagStudio version. -- Introduces the `versions` table - - Has a string `key` column and an int `value` column - - The `key` column stores one of two values: `'INITIAL'` and `'CURRENT'` - - `'INITIAL'` stores the database version number in which in was created - - Pre-existing databases set this number to `100` - - `'CURRENT'` stores the current database version number +- Deprecates the `preferences` table, set to be removed in a future TagStudio version. +- Introduces the `versions` table + - Has a string `key` column and an int `value` column + - The `key` column stores one of two values: `'INITIAL'` and `'CURRENT'` + - `'INITIAL'` stores the database version number in which in was created + - Pre-existing databases set this number to `100` + - `'CURRENT'` stores the current database version number #### Version 102 -| Used From | Format | Location | -| ----------------------------------------------------------------------- | ------ | ----------------------------------------------- | -| [v9.5.4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.4) | SQLite | ``/.TagStudio/ts_library.sqlite | - -- Applies repairs to the `tag_parents` table created in [version 100](#version-100), removing rows that reference tags that have been deleted. +| Used From | Format | Location | +| ---------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------- | +| [71d0425](https://github.com/TagStudioDev/TagStudio/commit/71d04254cf87f4200bb7ffc81656e50dfb122e4d) | SQLite | ``/.TagStudio/ts_library.sqlite | -#### Version 103 +- Applies repairs to the `tag_parents` table created in [version 100](#version-100), removing rows that reference tags that have been deleted. -| Used From | Format | Location | -| ----------------------------------------------------------------------- | ------ | ----------------------------------------------- | -| [#1139](https://github.com/TagStudioDev/TagStudio/pull/1139) | SQLite | ``/.TagStudio/ts_library.sqlite | +| Used From | Format | Location | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------- | +| [88d0b47](https://github.com/TagStudioDev/TagStudio/commit/88d0b47a86821ccfadba653f30a515abce5b24b0)/[v9.5.7](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.7) | SQLite | ``/.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. +- 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. #### Version 104 -| Used From | Format | Location | -| ----------------------------------------------------------------------- | ------ | ----------------------------------------------- | -| [#1298](https://github.com/TagStudioDev/TagStudio/pull/1298) | SQLite | ``/.TagStudio/ts_library.sqlite | +| Used From | Format | Location | +| ---------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------- | +| [ad2cbbc](https://github.com/TagStudioDev/TagStudio/commit/ad2cbbca483018d245b44348e2c4f5a0e0bb28f1) | SQLite | ``/.TagStudio/ts_library.sqlite | - Removes the `preferences` table, after migrating the contained extension list to the .ts_ignore file, if necessary. + +### Versions 200 - 2xx + +#### Version 200 + +| Used From | Format | Location | +| --------- | ------ | ----------------------------------------------- | +| TBD | SQLite | ``/.TagStudio/ts_library.sqlite | + +- Adds `text_field_templates` and `date_field_templates` tables. +- Drops `boolean_fields` and `value_type` tables. +- Adds `name` columns to `text_fields` and `datetime_fields` tables. + - Values in the `name` columns are taken from the `type_key` columns and are changed to "Title Case". + - **Example:** "DATE_CREATED" -> "Date Created" +- Drops `position` columns from `text_fields` and `datetime_fields` tables. +- Adds `is_multiline` column to `text_fields` table. + - Values are set to `TRUE` if the field row was previously a "TEXT_BOX" type. +- Repairs existing "Description" fields inside the `text_fields` table to have their `is_multiline` column set to `TRUE` _(Previously done in [Version 7](#version-7))_. +- Repairs existing "Comments" fields inside the `text_fields` table to have their `is_multiline` column set to `TRUE`. diff --git a/src/tagstudio/core/library/alchemy/constants.py b/src/tagstudio/core/library/alchemy/constants.py index ffc6ede63..31065c2cb 100644 --- a/src/tagstudio/core/library/alchemy/constants.py +++ b/src/tagstudio/core/library/alchemy/constants.py @@ -10,7 +10,7 @@ DB_VERSION_CURRENT_KEY: str = "CURRENT" DB_VERSION_INITIAL_KEY: str = "INITIAL" -DB_VERSION: int = 104 +DB_VERSION: int = 200 TAG_CHILDREN_QUERY = text(""" WITH RECURSIVE ChildTags AS ( diff --git a/src/tagstudio/core/library/alchemy/db.py b/src/tagstudio/core/library/alchemy/db.py index 8e3e6a618..7e728f79c 100644 --- a/src/tagstudio/core/library/alchemy/db.py +++ b/src/tagstudio/core/library/alchemy/db.py @@ -66,8 +66,3 @@ def make_tables(engine: Engine) -> None: except OperationalError as e: logger.error("Could not initialize built-in tags", error=e) conn.rollback() - - -def drop_tables(engine: Engine) -> None: - logger.info("dropping db tables") - Base.metadata.drop_all(engine) diff --git a/src/tagstudio/core/library/alchemy/enums.py b/src/tagstudio/core/library/alchemy/enums.py index 15e6efa93..020420f4b 100644 --- a/src/tagstudio/core/library/alchemy/enums.py +++ b/src/tagstudio/core/library/alchemy/enums.py @@ -152,11 +152,3 @@ def with_search_query(self, search_query: str) -> "BrowsingState": def with_show_hidden_entries(self, show_hidden_entries: bool) -> "BrowsingState": return replace(self, show_hidden_entries=show_hidden_entries) - - -class FieldTypeEnum(enum.Enum): - TEXT_LINE = "Text Line" - TEXT_BOX = "Text Box" - TAGS = "Tags" - DATETIME = "Datetime" - BOOLEAN = "Checkbox" diff --git a/src/tagstudio/core/library/alchemy/fields.py b/src/tagstudio/core/library/alchemy/fields.py index faffae079..e674b955a 100644 --- a/src/tagstudio/core/library/alchemy/fields.py +++ b/src/tagstudio/core/library/alchemy/fields.py @@ -5,18 +5,15 @@ from __future__ import annotations -from dataclasses import dataclass, field -from enum import Enum -from typing import TYPE_CHECKING, Any, override +from typing import TYPE_CHECKING, Any from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, declared_attr, mapped_column, relationship from tagstudio.core.library.alchemy.db import Base -from tagstudio.core.library.alchemy.enums import FieldTypeEnum if TYPE_CHECKING: - from tagstudio.core.library.alchemy.models import Entry, ValueType + from tagstudio.core.library.alchemy.models import Entry class BaseField(Base): @@ -27,12 +24,8 @@ def id(self) -> Mapped[int]: return mapped_column(primary_key=True, autoincrement=True) @declared_attr - def type_key(self) -> Mapped[str]: - return mapped_column(ForeignKey("value_type.key")) - - @declared_attr - def type(self) -> Mapped[ValueType]: - return relationship(foreign_keys=[self.type_key], lazy=False) # type: ignore # pyright: ignore[reportArgumentType] + def name(self) -> Mapped[str]: + return mapped_column(nullable=False, default="") @declared_attr def entry_id(self) -> Mapped[int]: @@ -42,50 +35,14 @@ def entry_id(self) -> Mapped[int]: def entry(self) -> Mapped[Entry]: return relationship(foreign_keys=[self.entry_id]) # type: ignore # pyright: ignore[reportArgumentType] - @declared_attr - def position(self) -> Mapped[int]: - return mapped_column(default=0) - - @override - def __hash__(self): - return hash(self.__key()) - - def __key(self): # pyright: ignore[reportUnknownParameterType] - raise NotImplementedError - value: Any # pyright: ignore -class BooleanField(BaseField): - __tablename__ = "boolean_fields" - - value: Mapped[bool] - - def __key(self): - return (self.type, self.value) - - @override - def __eq__(self, value: object) -> bool: - if isinstance(value, BooleanField): - return self.__key() == value.__key() - raise NotImplementedError - - class TextField(BaseField): __tablename__ = "text_fields" value: Mapped[str | None] - - def __key(self) -> tuple[ValueType, str | None]: - return self.type, self.value - - @override - def __eq__(self, value: object) -> bool: - if isinstance(value, TextField): - return self.__key() == value.__key() - elif isinstance(value, DatetimeField): - return False - raise NotImplementedError + is_multiline: Mapped[bool] = mapped_column(nullable=False, default=False) class DatetimeField(BaseField): @@ -93,52 +50,56 @@ class DatetimeField(BaseField): value: Mapped[str | None] - def __key(self): - return (self.type, self.value) - - @override - def __eq__(self, value: object) -> bool: - if isinstance(value, DatetimeField): - return self.__key() == value.__key() - raise NotImplementedError - - -@dataclass -class DefaultField: - id: int - name: str - type: FieldTypeEnum - is_default: bool = field(default=False) - - -class FieldID(Enum): - """Only for bootstrapping content of DB table.""" - - TITLE = DefaultField(id=0, name="Title", type=FieldTypeEnum.TEXT_LINE, is_default=True) - AUTHOR = DefaultField(id=1, name="Author", type=FieldTypeEnum.TEXT_LINE) - ARTIST = DefaultField(id=2, name="Artist", type=FieldTypeEnum.TEXT_LINE) - URL = DefaultField(id=3, name="URL", type=FieldTypeEnum.TEXT_LINE) - DESCRIPTION = DefaultField(id=4, name="Description", type=FieldTypeEnum.TEXT_BOX) - NOTES = DefaultField(id=5, name="Notes", type=FieldTypeEnum.TEXT_BOX) - COLLATION = DefaultField(id=9, name="Collation", type=FieldTypeEnum.TEXT_LINE) - DATE = DefaultField(id=10, name="Date", type=FieldTypeEnum.DATETIME) - DATE_CREATED = DefaultField(id=11, name="Date Created", type=FieldTypeEnum.DATETIME) - DATE_MODIFIED = DefaultField(id=12, name="Date Modified", type=FieldTypeEnum.DATETIME) - DATE_TAKEN = DefaultField(id=13, name="Date Taken", type=FieldTypeEnum.DATETIME) - DATE_PUBLISHED = DefaultField(id=14, name="Date Published", type=FieldTypeEnum.DATETIME) - # ARCHIVED = DefaultField(id=15, name="Archived", type=CheckboxField.checkbox) - # FAVORITE = DefaultField(id=16, name="Favorite", type=CheckboxField.checkbox) - BOOK = DefaultField(id=17, name="Book", type=FieldTypeEnum.TEXT_LINE) - COMIC = DefaultField(id=18, name="Comic", type=FieldTypeEnum.TEXT_LINE) - SERIES = DefaultField(id=19, name="Series", type=FieldTypeEnum.TEXT_LINE) - MANGA = DefaultField(id=20, name="Manga", type=FieldTypeEnum.TEXT_LINE) - SOURCE = DefaultField(id=21, name="Source", type=FieldTypeEnum.TEXT_LINE) - DATE_UPLOADED = DefaultField(id=22, name="Date Uploaded", type=FieldTypeEnum.DATETIME) - DATE_RELEASED = DefaultField(id=23, name="Date Released", type=FieldTypeEnum.DATETIME) - VOLUME = DefaultField(id=24, name="Volume", type=FieldTypeEnum.TEXT_LINE) - ANTHOLOGY = DefaultField(id=25, name="Anthology", type=FieldTypeEnum.TEXT_LINE) - MAGAZINE = DefaultField(id=26, name="Magazine", type=FieldTypeEnum.TEXT_LINE) - PUBLISHER = DefaultField(id=27, name="Publisher", type=FieldTypeEnum.TEXT_LINE) - GUEST_ARTIST = DefaultField(id=28, name="Guest Artist", type=FieldTypeEnum.TEXT_LINE) - COMPOSER = DefaultField(id=29, name="Composer", type=FieldTypeEnum.TEXT_LINE) - COMMENTS = DefaultField(id=30, name="Comments", type=FieldTypeEnum.TEXT_LINE) + +class BaseFieldTemplate(Base): + __abstract__ = True + + @declared_attr + def id(self) -> Mapped[int]: + return mapped_column(primary_key=True, autoincrement=True) + + @declared_attr + def name(self) -> Mapped[str]: + return mapped_column(nullable=False, default="") + + +class TextFieldTemplate(BaseFieldTemplate): + __tablename__ = "text_field_templates" + is_multiline: Mapped[bool] = mapped_column(nullable=False, default=False) + + +class DatetimeFieldTemplate(BaseFieldTemplate): + __tablename__ = "datetime_field_templates" + + +# Used for migrating legacy libraries. +# Legacy JSON libraries ( str: def get_default_tags() -> tuple[Tag, ...]: + """Return the built-in tags for a new TagStudio library.""" meta_tag = Tag( id=TAG_META, name="Meta Tags", @@ -168,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]) @@ -296,24 +312,48 @@ def migrate_json_to_sqlite(self, json_lib: JsonLibrary): path=entry.path / entry.filename, folder=folder, fields=[], - id=entry.id + 1, # JSON IDs start at 0 instead of 1 + id=entry.id + 1, # NOTE: JSON IDs start at 0 instead of 1 date_added=datetime.now(), ) for entry in json_lib.entries ] ) + for entry in json_lib.entries: for field in entry.fields: # pyright: ignore[reportUnknownVariableType] - for k, v in field.items(): # pyright: ignore[reportUnknownVariableType] + for legacy_field_id, value in field.items(): # pyright: ignore[reportUnknownVariableType] # Old tag fields get added as tags - if k in LEGACY_TAG_FIELD_IDS: - self.add_tags_to_entries(entry_ids=entry.id + 1, tag_ids=v) + if legacy_field_id in LEGACY_TAG_FIELD_IDS: + self.add_tags_to_entries(entry_ids=entry.id + 1, tag_ids=value) else: - self.add_field_to_entry( - entry_id=(entry.id + 1), # JSON IDs start at 0 instead of 1 - field_id=self.get_field_name_from_id(k), - value=v, - ) + try: + if LEGACY_FIELD_MAP[legacy_field_id]["type"] == TextField: + self.add_text_field_to_entry( + entry_id=( + entry.id + 1 + ), # NOTE: JSON IDs start at 0 instead of 1 + name=str(LEGACY_FIELD_MAP[legacy_field_id]["name"]), + value=value, + is_multiline=bool( + LEGACY_FIELD_MAP[legacy_field_id]["is_multiline"] + ), + ) + elif LEGACY_FIELD_MAP[legacy_field_id]["type"] == DatetimeField: + self.add_text_field_to_entry( + entry_id=( + entry.id + 1 + ), # NOTE: JSON IDs start at 0 instead of 1 + name=str(LEGACY_FIELD_MAP[legacy_field_id]["name"]), + value=value, + ) + except Exception as e: + logger.error( + "[Library][JSON Migration] Error reading field", + error=e, + entry_id=entry.id + 1, + legacy_field_id=legacy_field_id, + value=value, + ) # extension include/exclude list (unwrap(self.library_dir) / TS_FOLDER_NAME / IGNORE_NAME).write_text( @@ -323,12 +363,6 @@ def migrate_json_to_sqlite(self, json_lib: JsonLibrary): end_time = time.time() logger.info(f"Library Converted! ({format_timespan(end_time - start_time)})") - def get_field_name_from_id(self, field_id: int) -> FieldID | None: - for f in FieldID: - if field_id == f.value.id: - return f - return None - def tag_display_name(self, tag: Tag | None) -> str: if not tag: return "" @@ -454,6 +488,22 @@ def open_sqlite_library( except IntegrityError: session.rollback() + # Add default field templates + if is_new: + for ft in get_default_field_templates(): + try: + if type(ft) is TextFieldTemplate: + session.add( + TextFieldTemplate(name=ft.name, is_multiline=ft.is_multiline) + ) + elif type(ft) is DatetimeFieldTemplate: + session.add(DatetimeFieldTemplate(name=ft.name)) + + session.commit() + except IntegrityError: + logger.info("[Library] FieldTemplate already exists", field_template=ft) + session.rollback() + # Ensure version rows are present with catch_warnings(record=True): try: @@ -469,22 +519,6 @@ def open_sqlite_library( except IntegrityError: session.rollback() - for field in FieldID: - try: - session.add( - ValueType( - key=field.name, - name=field.value.name, - type=field.value.type, - position=field.value.id, - is_default=field.value.is_default, - ) - ) - session.commit() - except IntegrityError: - logger.debug("ValueType already exists", field=field) - session.rollback() - # check if folder matching current path exists already self.folder = session.scalar(select(Folder).where(Folder.path == library_dir)) if not self.folder: @@ -539,6 +573,9 @@ def open_sqlite_library( if loaded_db_version < 104: # changes: deletes preferences self.__apply_db104_migrations(session, library_dir) + if loaded_db_version < 200: + self.__apply_db200_migrations(session) + self.__apply_db200_data_repairs(session) # Update DB_VERSION if loaded_db_version < DB_VERSION: @@ -552,15 +589,6 @@ def __apply_db7_migration(self, session: Session): """Migrate DB from DB_VERSION 6 to 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. - desc_stmt = ( - update(ValueType) - .where(ValueType.key == FieldID.DESCRIPTION.name) - .values(type=FieldTypeEnum.TEXT_BOX.name) - ) - session.execute(desc_stmt) - session.flush() - # Repair tags that may have a disambiguation_id pointing towards a deleted tag. all_tag_ids = session.scalars(text("SELECT DISTINCT id FROM tags")).all() disam_stmt = ( @@ -703,7 +731,6 @@ def __apply_db103_migration(self, session: Session): session.query(Tag).filter(Tag.id == TAG_ARCHIVED).update({"is_hidden": True}) session.commit() logger.info("[Library][Migration] Updated archived tag to be hidden") - session.commit() except Exception as e: logger.error( "[Library][Migration] Could not update archived tag to be hidden!", @@ -736,16 +763,112 @@ def __migrate_sql_to_ts_ignore(self, library_dir: Path): with open(ts_ignore, "w") as f: f.write(migrate_ext_list(extensions, is_exclude_list)) + def __apply_db200_migrations(self, session: Session): + """Migrate DB to DB_VERSION 200.""" + with session: + # Drop unused 'boolean_fields' and 'value_type' tables + session.execute(text("DROP TABLE boolean_fields")) + session.execute(text("DROP TABLE value_type")) + session.commit() + logger.info("[Library][Migration][200] Dropped boolean_fields and value_type tables") + + # Add 'name' column to text_fields and datetime_fields tables + stmt = text('ALTER TABLE text_fields ADD COLUMN name VARCHAR NOT NULL DEFAULT ""') + session.execute(stmt) + stmt = text('ALTER TABLE datetime_fields ADD COLUMN name VARCHAR NOT NULL DEFAULT ""') + session.execute(stmt) + session.commit() + logger.info("[Library][Migration][200] Added name columns to field tables") + + # Drop unnecessary 'position' columns + session.execute(text("ALTER TABLE datetime_fields DROP COLUMN position")) + session.execute(text("ALTER TABLE text_fields DROP COLUMN position")) + session.commit() + logger.info("[Library][Migration][200] Dropped position columns to field tables") + + # Add 'is_multiline' column to text_fields table + stmt = text( + "ALTER TABLE text_fields ADD COLUMN is_multiline BOOLEAN NOT NULL DEFAULT 0" + ) + session.execute(stmt) + session.commit() + logger.info("[Library][Migration][200] Added is_multiline column to text_fields table") + + # Move values from old `type_key` columns into new `name` columns + session.execute(text("UPDATE text_fields SET name = type_key")) + session.execute(text("UPDATE datetime_fields SET name = type_key")) + session.commit() + logger.info("[Library][Migration][200] Moved values from type_key columns to name") + + # TODO: Remove `type_key` columns from text_fields and datetime_fields tables. + # See issue with dropping columns foreign keys in SQLite: + # https://www.sqlite.org/lang_altertable.html#making_other_kinds_of_table_schema_changes + + # Change `name` values to title case + 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] Normalized TextField names") + session.commit() + for datetime_field in session.execute(select(DatetimeField)).scalars(): + datetime_field.name = datetime_field.name.title().replace("_", " ") + logger.info("[Library][Migration][200] Normalized DatetimeField names") + session.commit() + + # Add correct `is_multiline` values to text_fields table + 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) + logger.info( + "[Library][Migration][200] Updated is_multiline columns for legacy TEXT_BOX fields" + ) + session.commit() + + pass + + def __apply_db200_data_repairs(self, session: Session): + logger.info("[Library][Migration] Repairing data for library below version 200...") + with session: + # Repair legacy "Description" fields to use is_multiline = True + 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 + comm_stmt = ( + update(TextField) + .where(TextField.name == "Comments" and TextField.is_multiline == False) # noqa: E712 + .values(is_multiline=True) + ) + session.execute(comm_stmt) + session.commit() + + # Add default field templates + for ft in get_default_field_templates(): + try: + if type(ft) is TextFieldTemplate: + session.add(TextFieldTemplate(name=ft.name, is_multiline=ft.is_multiline)) + elif type(ft) is DatetimeFieldTemplate: + session.add(DatetimeFieldTemplate(name=ft.name)) + + session.commit() + except IntegrityError: + logger.info("[Library] FieldTemplate already exists", field_template=ft) + session.rollback() + @property - def default_fields(self) -> list[BaseField]: + def field_templates(self) -> Sequence[BaseFieldTemplate]: with Session(self.engine) as session: - types = session.scalars( - select(ValueType).where( - # check if field is default - ValueType.is_default.is_(True) - ) - ) - return [x.as_field for x in types] + text_templates = list(session.scalars(select(TextFieldTemplate))) + datetime_templates = list(session.scalars(select(DatetimeFieldTemplate))) + return text_templates + datetime_templates def get_entry(self, entry_id: int) -> Entry | None: """Load entry without joins.""" @@ -976,8 +1099,8 @@ def remove_entries(self, entry_ids: list[int]) -> None: session.query(Entry).where(Entry.id.in_(sub_list)).delete() session.commit() - def has_path_entry(self, path: Path) -> bool: - """Check if item with given path is in library already.""" + def has_entry_with_path(self, path: Path) -> bool: + """Check if an entry with this path is in the library.""" with Session(self.engine) as session: return session.query(exists().where(Entry.path == path)).scalar() @@ -1125,7 +1248,7 @@ def update_entry_path(self, entry_id: int | Entry, path: Path) -> bool: Returns True if the action succeeded and False if the path already exists. """ - if self.has_path_entry(path): + if self.has_entry_with_path(path): return False if isinstance(entry_id, Entry): entry_id = entry_id.id @@ -1169,165 +1292,136 @@ def remove_tag(self, tag_id: int) -> bool: return False return True - def update_field_position( - self, - field_class: type[BaseField], - field_type: str, - entry_ids: list[int] | int, - ): - if isinstance(entry_ids, int): - entry_ids = [entry_ids] - - with Session(self.engine) as session: - for entry_id in entry_ids: - rows = list( - session.scalars( - select(field_class) - .where( - and_( - field_class.entry_id == entry_id, - field_class.type_key == field_type, - ) - ) - .order_by(field_class.id) - ) - ) - - # Reassign `order` starting from 0 - for index, row in enumerate(rows): - row.position = index - session.add(row) - session.flush() - if rows: - session.commit() - def remove_entry_field( self, field: BaseField, entry_ids: list[int], ) -> None: - FieldClass = type(field) # noqa: N806 + field_ = type(field) logger.info( "remove_entry_field", field=field, + type=field_, entry_ids=entry_ids, - field_type=field.type, - cls=FieldClass, - pos=field.position, ) with Session(self.engine) as session: # remove all fields matching entry and field_type - delete_stmt = delete(FieldClass).where( + delete_stmt = delete(field_).where( and_( - FieldClass.position == field.position, - FieldClass.type_key == field.type_key, - FieldClass.entry_id.in_(entry_ids), + field_.id == field.id, ) ) session.execute(delete_stmt) - session.commit() - # recalculate the remaining positions - # self.update_field_position(type(field), field.type, entry_ids) + def update_text_field( + self, entry_ids: list[int] | int, field: TextField, value: str, is_multiline: bool + ): + """Update a TextField field on one or more Entries.""" + if isinstance(entry_ids, int): + entry_ids = [entry_ids] + + field_ = type(field) - def update_entry_field( + with Session(self.engine) as session: + update_stmt = ( + update(field_) + .where( + and_( + field_.id == field.id, + ) + ) + .values(value=value, is_multiline=is_multiline) + ) + + session.execute(update_stmt) + session.commit() + + def update_datetime_field( self, entry_ids: list[int] | int, - field: BaseField, - content: str | datetime, + field: DatetimeField, + value: datetime, ): + """Update a DatetimeField field on one or more Entries.""" if isinstance(entry_ids, int): entry_ids = [entry_ids] - FieldClass = type(field) # noqa: N806 + field_ = type(field) with Session(self.engine) as session: update_stmt = ( - update(FieldClass) + update(field_) .where( and_( - FieldClass.position == field.position, - FieldClass.type == field.type, - FieldClass.entry_id.in_(entry_ids), + field_.id == field.id, ) ) - .values(value=content) + .values(value=value) ) session.execute(update_stmt) session.commit() - @property - def field_types(self) -> dict[str, ValueType]: - with Session(self.engine) as session: - return {x.key: x for x in session.scalars(select(ValueType)).all()} + def add_text_field_to_entry( + self, entry_id: int, name: str, value: str | None = None, is_multiline: bool = False + ) -> bool: + """Add a TextField field to an Entry.""" + logger.info( + "[Library] Adding text field to entry", + entry_id=entry_id, + name=name, + value=value, + is_multiline=is_multiline, + ) + + field = TextField(entry_id=entry_id, name=name, value=value, is_multiline=is_multiline) - def get_value_type(self, field_key: str) -> ValueType: with Session(self.engine) as session: - field = unwrap(session.scalar(select(ValueType).where(ValueType.key == field_key))) - session.expunge(field) - return field + try: + session.add(field) + session.flush() + session.commit() + except IntegrityError as e: + logger.error(e) + session.rollback() + return False + + return True - def add_field_to_entry( + def add_datetime_field_to_entry( self, entry_id: int, - *, - field: ValueType | None = None, - field_id: FieldID | str | None = None, - value: str | datetime | None = None, + name: str, + value: str | None = None, ) -> bool: + """Add a DatetimeField field to an Entry.""" logger.info( - "[Library][add_field_to_entry]", + "[Library] Adding datetime field to entry", entry_id=entry_id, - field_type=field, - field_id=field_id, + name=name, value=value, ) - # supply only instance or ID, not both - assert bool(field) != (field_id is not None) - - if not field: - if isinstance(field_id, FieldID): - field_id = field_id.name - field = self.get_value_type(unwrap(field_id)) - - field_model: TextField | DatetimeField - if field.type in (FieldTypeEnum.TEXT_LINE, FieldTypeEnum.TEXT_BOX): - field_model = TextField( - type_key=field.key, - value=value or "", - ) - elif field.type == FieldTypeEnum.DATETIME: - field_model = DatetimeField( - type_key=field.key, - value=value, - ) - else: - raise NotImplementedError(f"field type not implemented: {field.type}") + field = DatetimeField( + entry_id=entry_id, + name=name, + value=value, + ) with Session(self.engine) as session: try: - field_model.entry_id = entry_id - session.add(field_model) + session.add(field) session.flush() session.commit() except IntegrityError as e: logger.error(e) session.rollback() return False - # TODO - trigger error signal - # recalculate the positions of fields - self.update_field_position( - field_class=type(field_model), - field_type=field.key, - entry_ids=entry_id, - ) return True def tag_from_strings(self, strings: list[str] | str) -> list[int]: @@ -1867,39 +1961,75 @@ def set_version(self, key: str, value: int) -> None: logger.error("[Library][ERROR] Couldn't add default tag color namespaces", error=e) session.rollback() - def mirror_entry_fields(self, *entries: Entry) -> None: + def mirror_entry_fields(self, entries: list[Entry]) -> None: """Mirror fields among multiple Entry items.""" - fields = {} - # load all fields - existing_fields = {field.type_key for field in entries[0].fields} + all_tuples_to_fields_map = {} + + # Track all fields across all entries for entry in entries: - for entry_field in entry.fields: - fields[entry_field.type_key] = entry_field + for field in entry.fields: + field_tuple: tuple | None = None + if type(field) is TextField: + field_tuple = (type(field), field.name, field.value, field.is_multiline) + elif type(field) is DatetimeField: + field_tuple = (type(field), field.name, field.value) + all_tuples_to_fields_map[field_tuple] = field + logger.info( + "[Library][mirror_fields]", entry_id=entry.id, field_count_before=len(entry.fields) + ) - # assign the field to all entries + # Apply all (remaining) fields to all entries, avoiding duplicates for entry in entries: - for field_key, field in fields.items(): # pyright: ignore[reportUnknownVariableType] - if field_key not in existing_fields: - self.add_field_to_entry( - entry_id=entry.id, - field_id=field.type_key, - value=field.value, - ) + for field_tuple, field in all_tuples_to_fields_map.items(): # pyright: ignore[reportUnknownVariableType] + entry_field_tuples: set[tuple[Any, ...]] = set() # pyright: ignore[reportExplicitAny] + # Locally process the entry's fields into parsable tuples + for entry_field in entry.fields: + entry_field_tuple: tuple | None = None + if type(entry_field) is TextField: + entry_field_tuple = ( + type(entry_field), + entry_field.name, + entry_field.value, + entry_field.is_multiline, + ) + entry_field_tuples.add(entry_field_tuple) + elif type(entry_field) is DatetimeField: + entry_field_tuple = (type(entry_field), entry_field.name, entry_field.value) + entry_field_tuples.add(entry_field_tuple) + + if field_tuple not in entry_field_tuples: + if type(field) is TextField: + self.add_text_field_to_entry( + entry_id=entry.id, + name=field.name, + value=field.value, + is_multiline=field.is_multiline, + ) + elif type(field) is DatetimeField: + self.add_datetime_field_to_entry( + entry_id=entry.id, name=field.name, value=field.value + ) + logger.info( + "[Library][mirror_fields]", entry_id=entry.id, field_count_after=len(entry.fields) + ) def merge_entries(self, from_entry: Entry, into_entry: Entry) -> bool: """Add fields and tags from the first entry to the second, and then delete the first.""" - success = True - for field in from_entry.fields: - result = self.add_field_to_entry( - entry_id=into_entry.id, - field_id=field.type_key, - value=field.value, + success = False + + try: + self.mirror_entry_fields([from_entry, into_entry]) + tag_ids = [tag.id for tag in from_entry.tags] + self.add_tags_to_entries(into_entry.id, tag_ids) + self.remove_entries([from_entry.id]) + success = True + except Exception as e: + logger.error( + "[Library][merge_entries] Could not merge entires", + error=e, + from_entry_id=from_entry.id, + into_entry_id=into_entry.id, ) - if not result: - success = False - tag_ids = [tag.id for tag in from_entry.tags] - self.add_tags_to_entries(into_entry.id, tag_ids) - self.remove_entries([from_entry.id]) return success diff --git a/src/tagstudio/core/library/alchemy/models.py b/src/tagstudio/core/library/alchemy/models.py index 170a666b0..dc65e56ae 100644 --- a/src/tagstudio/core/library/alchemy/models.py +++ b/src/tagstudio/core/library/alchemy/models.py @@ -6,15 +6,13 @@ from pathlib import Path from typing import override -from sqlalchemy import ForeignKey, ForeignKeyConstraint, Integer, event +from sqlalchemy import ForeignKey, ForeignKeyConstraint, Integer, event, JSON from sqlalchemy.orm import Mapped, mapped_column, relationship from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE from tagstudio.core.library.alchemy.db import Base, PathType -from tagstudio.core.library.alchemy.enums import FieldTypeEnum from tagstudio.core.library.alchemy.fields import ( BaseField, - BooleanField, DatetimeField, TextField, ) @@ -223,7 +221,6 @@ def fields(self) -> list[BaseField]: fields: list[BaseField] = [] fields.extend(self.text_fields) fields.extend(self.datetime_fields) - fields = sorted(fields, key=lambda field: field.type.position) return fields @property @@ -275,57 +272,6 @@ def remove_tag(self, tag: Tag) -> None: self.tags.remove(tag) -class ValueType(Base): - """Define Field Types in the Library. - - Example: - key: content_tags (this field is slugified `name`) - name: Content Tags (this field is human readable name) - kind: type of content (Text Line, Text Box, Tags, Datetime, Checkbox) - is_default: Should the field be present in new Entry? - order: position of the field widget in the Entry form - - """ - - __tablename__ = "value_type" - - key: Mapped[str] = mapped_column(primary_key=True) - name: Mapped[str] = mapped_column(nullable=False) - type: Mapped[FieldTypeEnum] = mapped_column(default=FieldTypeEnum.TEXT_LINE) - is_default: Mapped[bool] # pyright: ignore[reportUninitializedInstanceVariable] - position: Mapped[int] # pyright: ignore[reportUninitializedInstanceVariable] - - # add relations to other tables - text_fields: Mapped[list[TextField]] = relationship("TextField", back_populates="type") - datetime_fields: Mapped[list[DatetimeField]] = relationship( - "DatetimeField", back_populates="type" - ) - boolean_fields: Mapped[list[BooleanField]] = relationship("BooleanField", back_populates="type") - - @property - def as_field(self) -> BaseField: - FieldClass = { # noqa: N806 - FieldTypeEnum.TEXT_LINE: TextField, - FieldTypeEnum.TEXT_BOX: TextField, - FieldTypeEnum.DATETIME: DatetimeField, - FieldTypeEnum.BOOLEAN: BooleanField, - } - - return FieldClass[self.type]( - type_key=self.key, - position=self.position, - ) - - -@event.listens_for(ValueType, "before_insert") -def slugify_field_key(mapper, connection, target): # pyright: ignore - """Slugify the field key before inserting into the database.""" - if not target.key: - from tagstudio.core.library.alchemy.library import slugify - - target.key = slugify(target.tag) - - class Version(Base): __tablename__ = "versions" diff --git a/src/tagstudio/core/library/refresh.py b/src/tagstudio/core/library/refresh.py index a6225d6af..531363f7b 100644 --- a/src/tagstudio/core/library/refresh.py +++ b/src/tagstudio/core/library/refresh.py @@ -145,7 +145,7 @@ def __rg_add(self, library_dir: Path, dir_list: list[str]) -> Iterator[int]: dir_file_count += 1 self.library.included_files.add(f) - if not self.library.has_path_entry(f): + if not self.library.has_entry_with_path(f): self.files_not_in_library.append(f) end_time_total = time() @@ -190,7 +190,7 @@ def __wc_add(self, library_dir: Path, ignore_patterns: list[str]) -> Iterator[in relative_path = f.relative_to(library_dir) - if not self.library.has_path_entry(relative_path): + if not self.library.has_entry_with_path(relative_path): self.files_not_in_library.append(relative_path) except ValueError: logger.info("[Refresh]: ValueError when refreshing directory with wcmatch!") diff --git a/src/tagstudio/core/ts_core.py b/src/tagstudio/core/ts_core.py index c91641dd9..1e5e53c53 100644 --- a/src/tagstudio/core/ts_core.py +++ b/src/tagstudio/core/ts_core.py @@ -4,19 +4,13 @@ """The core classes and methods of TagStudio.""" -import json import re from functools import lru_cache -from pathlib import Path import requests import structlog -from tagstudio.core.constants import TS_FOLDER_NAME -from tagstudio.core.library.alchemy.fields import FieldID from tagstudio.core.library.alchemy.library import Library -from tagstudio.core.library.alchemy.models import Entry -from tagstudio.core.utils.types import unwrap logger = structlog.get_logger(__name__) @@ -27,170 +21,6 @@ class TagStudioCore: def __init__(self): self.lib: Library = Library() - @classmethod - def get_gdl_sidecar(cls, filepath: Path, source: str = "") -> dict: - """Attempt to open and dump a Gallery-DL Sidecar file for the filepath. - - Return a formatted object with notable values or an empty object if none is found. - """ - raise NotImplementedError("This method is currently broken and needs to be fixed.") - info = {} - _filepath = filepath.parent / (filepath.name + ".json") - - # NOTE: This fixes an unknown (recent?) bug in Gallery-DL where Instagram sidecar - # files may be downloaded with indices starting at 1 rather than 0, unlike the posts. - # This may only occur with sidecar files that are downloaded separate from posts. - if source == "instagram" and not _filepath.is_file(): - newstem = _filepath.stem[:-16] + "1" + _filepath.stem[-15:] - _filepath = _filepath.parent / (newstem + ".json") - - logger.info("get_gdl_sidecar", filepath=filepath, source=source, sidecar=_filepath) - - try: - with open(_filepath, encoding="utf8") as f: - json_dump = json.load(f) - if not json_dump: - return {} - - if source == "twitter": - info[FieldID.DESCRIPTION] = json_dump["content"].strip() - info[FieldID.DATE_PUBLISHED] = json_dump["date"] - elif source == "instagram": - info[FieldID.DESCRIPTION] = json_dump["description"].strip() - info[FieldID.DATE_PUBLISHED] = json_dump["date"] - elif source == "artstation": - info[FieldID.TITLE] = json_dump["title"].strip() - info[FieldID.ARTIST] = json_dump["user"]["full_name"].strip() - info[FieldID.DESCRIPTION] = json_dump["description"].strip() - info[FieldID.TAGS] = json_dump["tags"] - # info["tags"] = [x for x in json_dump["mediums"]["name"]] - info[FieldID.DATE_PUBLISHED] = json_dump["date"] - elif source == "newgrounds": - # info["title"] = json_dump["title"] - # info["artist"] = json_dump["artist"] - # info["description"] = json_dump["description"] - info[FieldID.TAGS] = json_dump["tags"] - info[FieldID.DATE_PUBLISHED] = json_dump["date"] - info[FieldID.ARTIST] = json_dump["user"].strip() - info[FieldID.DESCRIPTION] = json_dump["description"].strip() - info[FieldID.SOURCE] = json_dump["post_url"].strip() - - except Exception: - logger.exception("Error handling sidecar file.", path=_filepath) - - return info - - # def scrape(self, entry_id): - # entry = self.lib.get_entry(entry_id) - # if entry.fields: - # urls: list[str] = [] - # if self.lib.get_field_index_in_entry(entry, 21): - # urls.extend([self.lib.get_field_attr(entry.fields[x], 'content') - # for x in self.lib.get_field_index_in_entry(entry, 21)]) - # if self.lib.get_field_index_in_entry(entry, 3): - # urls.extend([self.lib.get_field_attr(entry.fields[x], 'content') - # for x in self.lib.get_field_index_in_entry(entry, 3)]) - # # try: - # if urls: - # for url in urls: - # url = "https://" + url if 'https://' not in url else url - # html_doc = requests.get(url).text - # soup = bs(html_doc, "html.parser") - # print(soup) - # input() - - # # except: - # # # print("Could not resolve URL.") - # # pass - - @classmethod - def match_conditions(cls, lib: Library, entry_id: int) -> bool: - """Match defined conditions against a file to add Entry data.""" - # TODO - what even is this file format? - # TODO: Make this stored somewhere better instead of temporarily in this JSON file. - cond_file = unwrap(lib.library_dir) / TS_FOLDER_NAME / "conditions.json" - if not cond_file.is_file(): - return False - - entry: Entry = unwrap(lib.get_entry(entry_id)) - - try: - with open(cond_file, encoding="utf8") as f: - json_dump = json.load(f) - for c in json_dump["conditions"]: - match: bool = False - for path_c in c["path_conditions"]: - if Path(path_c).is_relative_to(entry.path): - match = True - break - - if not match: - return False - - if not c.get("fields"): - return False - - fields = c["fields"] - entry_field_types = {field.type_key: field for field in entry.fields} - - for field in fields: - is_new = field["id"] not in entry_field_types - field_key = field["id"] - if is_new: - lib.add_field_to_entry( - entry.id, field_id=field_key, value=field["value"] - ) - else: - lib.update_entry_field(entry.id, field_key, field["value"]) - - except Exception: - logger.exception("Error matching conditions.", entry=entry) - - return False - - @classmethod - def build_url(cls, entry: Entry, source: str): - """Try to rebuild a source URL given a specific filename structure.""" - source = source.lower().replace("-", " ").replace("_", " ") - if "twitter" in source: - return cls._build_twitter_url(entry) - elif "instagram" in source: - return cls._build_instagram_url(entry) - - @classmethod - def _build_twitter_url(cls, entry: Entry): - """Build a Twitter URL given a specific filename structure. - - Method expects filename to be formatted as 'USERNAME_TWEET-ID_INDEX_YEAR-MM-DD' - """ - try: - stubs = str(entry.path.name).rsplit("_", 3) - url = f"www.twitter.com/{stubs[0]}/status/{stubs[-3]}/photo/{stubs[-2]}" - return url - except Exception: - logger.exception("Error building Twitter URL.", entry=entry) - return "" - - @classmethod - def _build_instagram_url(cls, entry: Entry): - """Build an Instagram URL given a specific filename structure. - - Method expects filename to be formatted as 'USERNAME_POST-ID_INDEX_YEAR-MM-DD' - """ - try: - stubs = str(entry.path.name).rsplit("_", 2) - # stubs[0] = stubs[0].replace(f"{author}_", '', 1) - # print(stubs) - # NOTE: Both Instagram usernames AND their ID can have underscores in them, - # so unless you have the exact username (which can change) on hand to remove, - # your other best bet is to hope that the ID is only 11 characters long, which - # seems to more or less be the case... for now... - url = f"www.instagram.com/p/{stubs[-3][-11:]}" - return url - except Exception: - logger.exception("Error building Instagram URL.", entry=entry) - return "" - @staticmethod @lru_cache(maxsize=1) def get_most_recent_release_version() -> str: diff --git a/src/tagstudio/qt/controllers/library_info_window_controller.py b/src/tagstudio/qt/controllers/library_info_window_controller.py index e435a7988..ad4a0127b 100644 --- a/src/tagstudio/qt/controllers/library_info_window_controller.py +++ b/src/tagstudio/qt/controllers/library_info_window_controller.py @@ -65,7 +65,7 @@ def update_title(self): def update_stats(self): self.entry_count_label.setText(f"{self.lib.entries_count}") self.tag_count_label.setText(f"{len(self.lib.tags)}") - self.field_count_label.setText(f"{len(self.lib.field_types)}") + self.field_count_label.setText(f"{len(self.lib.field_templates)}") self.namespaces_count_label.setText(f"{len(self.lib.namespaces)}") colors_total = 0 for c in self.lib.tag_color_groups.values(): diff --git a/src/tagstudio/qt/controllers/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel_controller.py index 0cf666198..360f05589 100644 --- a/src/tagstudio/qt/controllers/preview_panel_controller.py +++ b/src/tagstudio/qt/controllers/preview_panel_controller.py @@ -22,12 +22,15 @@ def __init__(self, library: Library, driver: "QtDriver"): self.__add_field_modal = AddFieldModal(self.lib) self.__add_tag_modal = TagSearchModal(self.lib, is_tag_chooser=True) + @typing.override def _add_field_button_callback(self): self.__add_field_modal.show() + @typing.override def _add_tag_button_callback(self): self.__add_tag_modal.show() + @typing.override def _set_selection_callback(self): with catch_warnings(record=True): self.__add_field_modal.done.disconnect() diff --git a/src/tagstudio/qt/mixed/add_field.py b/src/tagstudio/qt/mixed/add_field.py index 917dab50d..bfaac9310 100644 --- a/src/tagstudio/qt/mixed/add_field.py +++ b/src/tagstudio/qt/mixed/add_field.py @@ -19,7 +19,7 @@ ) from tagstudio.core.library.alchemy.library import Library -from tagstudio.qt.translations import Translations +from tagstudio.qt.translations import FIELD_TYPE_KEYS, Translations logger = structlog.get_logger(__name__) @@ -73,13 +73,18 @@ def __init__(self, library: Library): self.root_layout.addStretch(1) self.root_layout.addWidget(self.button_container) + @override def show(self): self.list_widget.clear() - for df in self.lib.field_types.values(): - item = QListWidgetItem(f"{df.name} ({df.type.value})") - item.setData(Qt.ItemDataRole.UserRole, df.key) + for field_template in self.lib.field_templates: + field_name_key: str = FIELD_TYPE_KEYS.get( + field_template.__class__.__name__, "field_type.unknown" + ) + item = QListWidgetItem(f"{field_template.name} ({Translations[field_name_key]})") + item.setData(Qt.ItemDataRole.UserRole, field_template) self.list_widget.addItem(item) self.list_widget.setFocus() + self.list_widget.setCurrentRow(0) super().show() diff --git a/src/tagstudio/qt/mixed/field_containers.py b/src/tagstudio/qt/mixed/field_containers.py index ae8df9107..594738f71 100644 --- a/src/tagstudio/qt/mixed/field_containers.py +++ b/src/tagstudio/qt/mixed/field_containers.py @@ -15,6 +15,7 @@ from PySide6.QtWidgets import ( QFrame, QHBoxLayout, + QListWidgetItem, QMessageBox, QScrollArea, QSizePolicy, @@ -23,11 +24,13 @@ ) from tagstudio.core.enums import Theme -from tagstudio.core.library.alchemy.enums import FieldTypeEnum from tagstudio.core.library.alchemy.fields import ( BaseField, + BaseFieldTemplate, DatetimeField, + DatetimeFieldTemplate, TextField, + TextFieldTemplate, ) from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Entry, Tag @@ -36,7 +39,7 @@ from tagstudio.qt.mixed.datetime_picker import DatetimePicker from tagstudio.qt.mixed.field_widget import FieldContainer from tagstudio.qt.mixed.text_field import TextWidget -from tagstudio.qt.translations import Translations +from tagstudio.qt.translations import FIELD_TYPE_KEYS, Translations from tagstudio.qt.views.edit_text_box_modal import EditTextBox from tagstudio.qt.views.edit_text_line_modal import EditTextLine from tagstudio.qt.views.panel_modal import PanelModal @@ -205,7 +208,7 @@ def get_tag_categories(self, tags: set[Tag]) -> dict[Tag | None, set[Tag]]: def remove_field_prompt(self, name: str) -> str: return Translations.format("library.field.confirm_remove", name=name) - def add_field_to_selected(self, field_list: list): + def add_field_to_selected(self, field_list: list[QListWidgetItem]): """Add list of entry fields to one or more selected items. Uses the current driver selection, NOT the field containers cache. @@ -216,11 +219,24 @@ def add_field_to_selected(self, field_list: list): fields=field_list, ) for entry_id in self.driver.selected: - for field_item in field_list: - self.lib.add_field_to_entry( - entry_id, - field_id=field_item.data(Qt.ItemDataRole.UserRole), + for field in field_list: + field_: BaseFieldTemplate = field.data(Qt.ItemDataRole.UserRole) + logger.info( + "[FieldContainers][add_field_to_selected] Adding field", + name=field_.name, + type=field_.__class__.__name__, ) + if type(field_) is TextFieldTemplate: + self.lib.add_text_field_to_entry( + entry_id=entry_id, + name=field_.name, + is_multiline=field_.is_multiline, + ) + elif type(field_) is DatetimeFieldTemplate: + self.lib.add_datetime_field_to_entry( + entry_id=entry_id, + name=field_.name, + ) def add_tags_to_selected(self, tags: int | list[int]): """Add list of tags to one or more selected items. @@ -250,7 +266,12 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): If True, field is not present in all selected items. """ - logger.info("[FieldContainers][write_field_container]", index=index) + logger.info( + "[FieldContainers][write_container]", + index=index, + name=field.name, + type=field.__class__.__name__, + ) if len(self.containers) < (index + 1): container = FieldContainer() self.containers.append(container) @@ -258,8 +279,13 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): else: container = self.containers[index] - if field.type.type == FieldTypeEnum.TEXT_LINE: - container.set_title(field.type.name) + # Set field title + field_name_key: str = FIELD_TYPE_KEYS.get(field.__class__.__name__, "field_type.unknown") + title = f"{field.name} ({Translations[field_name_key]})" + + # Single-line Text + if type(field) is TextField and not field.is_multiline: + container.set_title(field.name) container.set_inline(False) # Normalize line endings in any text content. @@ -267,19 +293,18 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): assert isinstance(field.value, str | type(None)) text = field.value or "" else: - text = "Mixed Data" + text = "Mixed Data" # TODO: Localize this - title = f"{field.type.name} ({field.type.type.value})" inner_widget = TextWidget(title, text) container.set_inner_widget(inner_widget) if not is_mixed: modal = PanelModal( EditTextLine(field.value), title=title, - window_title=f"Edit {field.type.type.value}", - save_callback=( + window_title=f"Edit {field.name}", # TODO: Localize this + save_callback=( # pyright: ignore[reportArgumentType] lambda content: ( - self.update_field(field, content), # type: ignore + self.update_text_field(field, content, is_multiline=False), self.update_from_entry(self.cached_entries[0].id), ) ), @@ -291,7 +316,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): container.set_edit_callback(modal.show) container.set_remove_callback( lambda: self.remove_message_box( - prompt=self.remove_field_prompt(field.type.type.value), + prompt=self.remove_field_prompt(title), callback=lambda: ( self.remove_field(field), self.update_from_entry(self.cached_entries[0].id), @@ -299,26 +324,26 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): ) ) - elif field.type.type == FieldTypeEnum.TEXT_BOX: - container.set_title(field.type.name) + # Multiline Text + elif type(field) is TextField and field.is_multiline: + container.set_title(field.name) container.set_inline(False) # Normalize line endings in any text content. if not is_mixed: assert isinstance(field.value, str | type(None)) text = (field.value or "").replace("\r", "\n") else: - text = "Mixed Data" - title = f"{field.type.name} (Text Box)" + text = "Mixed Data" # TODO: Localize this inner_widget = TextWidget(title, text) container.set_inner_widget(inner_widget) if not is_mixed: modal = PanelModal( EditTextBox(field.value), title=title, - window_title=f"Edit {field.type.name}", - save_callback=( + window_title=f"Edit {field.name}", # TODO: Localize this + save_callback=( # pyright: ignore[reportArgumentType] lambda content: ( - self.update_field(field, content), # type: ignore + self.update_text_field(field, content, is_multiline=True), self.update_from_entry(self.cached_entries[0].id), ) ), @@ -326,7 +351,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): container.set_edit_callback(modal.show) container.set_remove_callback( lambda: self.remove_message_box( - prompt=self.remove_field_prompt(field.type.name), + prompt=self.remove_field_prompt(field.name), callback=lambda: ( self.remove_field(field), self.update_from_entry(self.cached_entries[0].id), @@ -334,20 +359,18 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): ) ) - elif field.type.type == FieldTypeEnum.DATETIME: + elif type(field) is DatetimeField: logger.info("[FieldContainers][write_container] Datetime Field", field=field) if not is_mixed: - container.set_title(field.type.name) + container.set_title(field.name) container.set_inline(False) - title = f"{field.type.name} (Date)" try: assert field.value is not None text = self.driver.settings.format_datetime( DatetimePicker.string2dt(field.value) ) except (ValueError, AssertionError): - title += " (Unknown Format)" text = str(field.value) inner_widget = TextWidget(title, text) @@ -355,10 +378,10 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): modal = PanelModal( DatetimePicker(self.driver, field.value or dt.now()), - title=f"Edit {field.type.name}", - save_callback=( + title=f"Edit {field.name}", + save_callback=( # pyright: ignore[reportArgumentType] lambda content: ( - self.update_field(field, content), # type: ignore + self.update_datetime_field(field, content), self.update_from_entry(self.cached_entries[0].id), ) ), @@ -367,7 +390,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): container.set_edit_callback(modal.show) container.set_remove_callback( lambda: self.remove_message_box( - prompt=self.remove_field_prompt(field.type.name), + prompt=self.remove_field_prompt(field.name), callback=lambda: ( self.remove_field(field), self.update_from_entry(self.cached_entries[0].id), @@ -375,20 +398,20 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): ) ) else: - text = "Mixed Data" - title = f"{field.type.name} (Wacky Date)" + text = "Mixed Data" # TODO: Localize this inner_widget = TextWidget(title, text) container.set_inner_widget(inner_widget) else: - logger.warning("[FieldContainers][write_container] Unknown Field", field=field) - container.set_title(field.type.name) + logger.warning( + "[FieldContainers][write_container] Unknown Field", field=field + ) # TODO: Localize this + container.set_title(field.name) container.set_inline(False) - title = f"{field.type.name} (Unknown Field Type)" - inner_widget = TextWidget(title, field.type.name) + inner_widget = TextWidget(title, field.name) container.set_inner_widget(inner_widget) container.set_remove_callback( lambda: self.remove_message_box( - prompt=self.remove_field_prompt(field.type.name), + prompt=self.remove_field_prompt(field.name), callback=lambda: ( self.remove_field(field), self.update_from_entry(self.cached_entries[0].id), @@ -419,7 +442,9 @@ def write_tag_container( else: container = self.containers[index] - container.set_title("Tags" if not category_tag else category_tag.name) + container.set_title( + "Tags" if not category_tag else category_tag.name + ) # TODO: Localize this container.set_inline(False) if not is_mixed: @@ -431,7 +456,7 @@ def write_tag_container( else: inner_widget = TagBoxWidget( - "Tags", + "Tags", # TODO: Localize this self.driver, ) container.set_inner_widget(inner_widget) @@ -460,26 +485,24 @@ def remove_field(self, field: BaseField): entry_ids = [e.id for e in self.cached_entries] self.lib.remove_entry_field(field, entry_ids) - def update_field(self, field: BaseField, content: str) -> None: - """Update a field in all selected Entries, given a field object.""" - assert isinstance( - field, - TextField | DatetimeField, - ), f"instance: {type(field)}" - + def update_text_field(self, field: TextField, value: str, is_multiline: bool): + """Update a text field across selected entries.""" entry_ids = [e.id for e in self.cached_entries] + assert entry_ids, "No entries selected" + + self.lib.update_text_field(entry_ids, field, value, is_multiline) + def update_datetime_field(self, field: DatetimeField, value: str): + """Update a datetime field across selected entries.""" + entry_ids = [e.id for e in self.cached_entries] assert entry_ids, "No entries selected" - self.lib.update_entry_field( - entry_ids, - field, - content, - ) + + self.lib.update_datetime_field(entry_ids, field, dt.fromisoformat(value)) def remove_message_box(self, prompt: str, callback: Callable) -> None: remove_mb = QMessageBox() remove_mb.setText(prompt) - remove_mb.setWindowTitle("Remove Field") + remove_mb.setWindowTitle("Remove Field") # TODO: Localize remove_mb.setIcon(QMessageBox.Icon.Warning) cancel_button = remove_mb.addButton( Translations["generic.cancel_alt"], QMessageBox.ButtonRole.DestructiveRole diff --git a/src/tagstudio/qt/mixed/migration_modal.py b/src/tagstudio/qt/mixed/migration_modal.py index 4508e8b38..fc5504c39 100644 --- a/src/tagstudio/qt/mixed/migration_modal.py +++ b/src/tagstudio/qt/mixed/migration_modal.py @@ -34,6 +34,7 @@ ) from tagstudio.core.library.alchemy import default_color_groups from tagstudio.core.library.alchemy.constants import SQL_FILENAME +from tagstudio.core.library.alchemy.fields import LEGACY_FIELD_MAP from tagstudio.core.library.alchemy.joins import TagParent from tagstudio.core.library.alchemy.library import Library as SqliteLibrary from tagstudio.core.library.alchemy.models import Entry, TagAlias @@ -544,9 +545,6 @@ def check_ignore_parity(self) -> bool: def check_field_parity(self) -> bool: """Check if all JSON field and tag data matches the new SQL data.""" - def sanitize_field(entry: Entry, value, type, type_key): - return value if value else None - def sanitize_json_field(value): if isinstance(value, list): return set(value) if value else None @@ -557,7 +555,7 @@ def sanitize_json_field(value): sql_fields: list[tuple] = [] json_fields: list[tuple] = [] - sql_entry: Entry = unwrap(self.sql_lib.get_entry_full(json_entry.id + 1)) + sql_entry: Entry | None = self.sql_lib.get_entry_full(json_entry.id + 1) if not sql_entry: logger.info( "[Field Comparison]", @@ -570,14 +568,13 @@ def sanitize_json_field(value): return self.field_parity for sf in sql_entry.fields: - if sf.type.type.value not in LEGACY_TAG_FIELD_IDS: - sql_fields.append( - ( - sql_entry.id, - sf.type.key, - sanitize_field(sql_entry, sf.value, sf.type.type, sf.type_key), - ) + sql_fields.append( + ( + sql_entry.id, + sf.name.upper().replace(" ", "_"), + sf.value if sf.value else None, ) + ) sql_fields.sort() # NOTE: The JSON database stored tags inside of special "tag field" types which @@ -591,7 +588,7 @@ def sanitize_json_field(value): tags_count += 1 json_tags = json_tags.union(value or []) else: - key: str = unwrap(self.sql_lib.get_field_name_from_id(int_key)).name + key: str = str(LEGACY_FIELD_MAP[int_key]["name"]).upper().replace(" ", "_") json_fields.append((json_entry.id + 1, key, value)) json_fields.sort() diff --git a/src/tagstudio/qt/mixed/mirror_entries_modal.py b/src/tagstudio/qt/mixed/mirror_entries_modal.py index d67cf12ed..20c378810 100644 --- a/src/tagstudio/qt/mixed/mirror_entries_modal.py +++ b/src/tagstudio/qt/mixed/mirror_entries_modal.py @@ -95,7 +95,7 @@ def mirror_entries_runnable(self): mirrored: list = [] lib = self.driver.lib for i, entries in enumerate(self.tracker.groups): - lib.mirror_entry_fields(*entries) + lib.mirror_entry_fields(entries) sleep(0.005) yield i diff --git a/src/tagstudio/qt/translations.py b/src/tagstudio/qt/translations.py index 0d9983a62..0875bcf12 100644 --- a/src/tagstudio/qt/translations.py +++ b/src/tagstudio/qt/translations.py @@ -39,6 +39,14 @@ "Viossa": "qpv", } +# A map of field class names to their respective translation keys. +FIELD_TYPE_KEYS = { + "DatetimeField": "field_type.datetime", + "DatetimeFieldTemplate": "field_type.datetime", + "TextField": "field_type.text", + "TextFieldTemplate": "field_type.text", +} + class Translator: _default_strings: dict[str, str] diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index 5a7b2fd63..4046e93f5 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -52,10 +52,8 @@ from tagstudio.core.enums import MacroID, SettingItems, ShowFilepathOption from tagstudio.core.library.alchemy.enums import ( BrowsingState, - FieldTypeEnum, SortingModeEnum, ) -from tagstudio.core.library.alchemy.fields import FieldID from tagstudio.core.library.alchemy.library import Library, LibraryStatus from tagstudio.core.library.alchemy.models import Entry from tagstudio.core.library.ignore import Ignore @@ -63,7 +61,7 @@ from tagstudio.core.media_types import MediaCategories from tagstudio.core.query_lang.util import ParsingError from tagstudio.core.ts_core import TagStudioCore -from tagstudio.core.utils.str_formatting import is_version_outdated, strip_web_protocol +from tagstudio.core.utils.str_formatting import is_version_outdated from tagstudio.core.utils.types import unwrap from tagstudio.qt.cache_manager import CacheManager from tagstudio.qt.controllers.ffmpeg_missing_message_box import FfmpegMissingMessageBox @@ -1123,7 +1121,6 @@ def run_macros(self, name: MacroID, entry_ids: list[int]): def run_macro(self, name: MacroID, entry_id: int): """Run a specific Macro on an Entry given a Macro name.""" entry: Entry = unwrap(self.lib.get_entry(entry_id)) - full_path = unwrap(self.lib.library_dir) / entry.path source = "" if entry.path.parent == Path(".") else entry.path.parts[0].lower() logger.info( @@ -1140,32 +1137,6 @@ def run_macro(self, name: MacroID, entry_id: int): continue self.run_macro(macro_id, entry_id) - elif name == MacroID.SIDECAR: - parsed_items = TagStudioCore.get_gdl_sidecar(full_path, source) - for field_id, value in parsed_items.items(): - if isinstance(value, list) and len(value) > 0 and isinstance(value[0], str): - value = self.lib.tag_from_strings(value) - self.lib.add_field_to_entry( - entry.id, - field_id=field_id, - value=value, - ) - - elif name == MacroID.BUILD_URL: - url = TagStudioCore.build_url(entry, source) - if url is not None: - self.lib.add_field_to_entry(entry.id, field_id=FieldID.SOURCE, value=url) - elif name == MacroID.MATCH: - TagStudioCore.match_conditions(self.lib, entry.id) - elif name == MacroID.CLEAN_URL: - for field in entry.text_fields: - if field.type.type == FieldTypeEnum.TEXT_LINE and field.value: - self.lib.update_entry_field( - entry_ids=entry.id, - field=field, - content=strip_web_protocol(field.value), - ) - def sorting_direction_callback(self): logger.info("Sorting Direction Changed", ascending=self.main_window.sorting_direction) self.update_browsing_state( diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index d214d1aa4..52a3bda9f 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -71,6 +71,9 @@ "entries.unlinked.unlinked_count": "Unlinked Entries: {count}", "ffmpeg.missing.description": "FFmpeg and/or FFprobe were not found. FFmpeg is required for multimedia playback and thumbnails.", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", + "field_type.datetime": "Datetime", + "field_type.text": "Text", + "field_type.unknown": "Unknown Type", "field.copy": "Copy Field", "field.edit": "Edit Field", "field.paste": "Paste Field", diff --git a/tests/conftest.py b/tests/conftest.py index eea72cbab..b9f4c0c5d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,6 +12,8 @@ import pytest from PySide6.QtWidgets import QScrollArea +from tagstudio.core.library.alchemy.fields import TextField + CWD = Path(__file__).parent # this needs to be above `src` imports sys.path.insert(0, str(CWD.parent)) @@ -40,19 +42,19 @@ def file_mediatypes_library(): entry1 = Entry( folder=folder, path=Path("foo.png"), - fields=lib.default_fields, + fields=[TextField(name="Title", value="I'm a Test Title")], ) entry2 = Entry( folder=folder, path=Path("bar.png"), - fields=lib.default_fields, + fields=[TextField(name="Title", value="I'm a Test Title")], ) entry3 = Entry( folder=folder, path=Path("baz.apng"), - fields=lib.default_fields, + fields=[TextField(name="Title", value="I'm a Test Title")], ) assert lib.add_entries([entry1, entry2, entry3]) @@ -117,7 +119,7 @@ def library(request, library_dir: Path): # pyright: ignore id=1, folder=folder, path=Path("foo.txt"), - fields=lib.default_fields, + fields=[TextField(name="Title", value="I'm a Test Title")], ) assert lib.add_tags_to_entries(entry.id, tag.id) @@ -125,7 +127,7 @@ def library(request, library_dir: Path): # pyright: ignore id=2, folder=folder, path=Path("one/two/bar.md"), - fields=lib.default_fields, + fields=[TextField(name="Title", value="I'm a Test Title")], ) assert lib.add_tags_to_entries(entry2.id, tag2.id) diff --git a/tests/macros/test_dupe_files.py b/tests/macros/test_dupe_files.py index e449bd552..dac71127b 100644 --- a/tests/macros/test_dupe_files.py +++ b/tests/macros/test_dupe_files.py @@ -4,6 +4,7 @@ from pathlib import Path +from tagstudio.core.library.alchemy.fields import TextField from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Entry from tagstudio.core.library.alchemy.registries.dupe_files_registry import DupeFilesRegistry @@ -19,13 +20,13 @@ def test_refresh_dupe_files(library: Library): entry = Entry( folder=folder, path=Path("bar/foo.txt"), - fields=library.default_fields, + fields=[TextField(name="Title", value="I'm a Test Title")], ) entry2 = Entry( folder=folder, path=Path("foo/foo.txt"), - fields=library.default_fields, + fields=[TextField(name="Title", value="I'm a Test Title")], ) library.add_entries([entry, entry2]) diff --git a/tests/test_db_migrations.py b/tests/test_db_migrations.py index d616a6104..6052a44af 100644 --- a/tests/test_db_migrations.py +++ b/tests/test_db_migrations.py @@ -27,6 +27,10 @@ str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_8")), str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_9")), str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_100")), + str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_101")), + # str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_102")), + str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_103")), + str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_200")), ], ) def test_library_migrations(path: str): diff --git a/tests/test_library.py b/tests/test_library.py index 52526a540..f24473915 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -12,7 +12,7 @@ from tagstudio.core.library.alchemy.enums import BrowsingState from tagstudio.core.library.alchemy.fields import ( - FieldID, # pyright: ignore[reportPrivateUsage] + DatetimeField, TextField, ) from tagstudio.core.library.alchemy.library import Library @@ -81,12 +81,12 @@ def test_library_add_file(library: Library): entry = Entry( path=Path("bar.txt"), folder=unwrap(library.folder), - fields=library.default_fields, + fields=[TextField(name="Title", value="I'm a Test Title")], ) - assert not library.has_path_entry(entry.path) + assert not library.has_entry_with_path(entry.path) assert library.add_entries([entry]) - assert library.has_path_entry(entry.path) + assert library.has_entry_with_path(entry.path) def test_create_tag(library: Library, generate_tag: Callable[..., Tag]): @@ -207,13 +207,18 @@ def test_remove_entry_field(library: Library, entry_full: Entry): assert not entry.text_fields -def test_remove_field_entry_with_multiple_field(library: Library, entry_full: Entry): +def test_remove_text_field_entry_with_multiple_field(library: Library, entry_full: Entry): # Given title_field = entry_full.text_fields[0] # When # add identical field - assert library.add_field_to_entry(entry_full.id, field_id=title_field.type_key) + assert library.add_text_field_to_entry( + entry_full.id, + name=title_field.name, + value=title_field.value, + is_multiline=title_field.is_multiline, + ) # remove entry field library.remove_entry_field(title_field, [entry_full.id]) @@ -226,30 +231,22 @@ def test_remove_field_entry_with_multiple_field(library: Library, entry_full: En def test_update_entry_field(library: Library, entry_full: Entry): title_field = entry_full.text_fields[0] - library.update_entry_field( - entry_full.id, - title_field, - "new value", - ) + library.update_text_field(entry_full.id, title_field, "new value", title_field.is_multiline) entry = next(library.all_entries(with_joins=True)) assert entry.text_fields[0].value == "new value" -def test_update_entry_with_multiple_identical_fields(library: Library, entry_full: Entry): +def test_update_entry_with_multiple_identical_text_fields(library: Library, entry_full: Entry): # Given title_field = entry_full.text_fields[0] # When # add identical field - library.add_field_to_entry(entry_full.id, field_id=title_field.type_key) + library.add_text_field_to_entry(entry_full.id, name="Title", value="") # update one of the fields - library.update_entry_field( - entry_full.id, - title_field, - "new value", - ) + library.update_text_field(entry_full.id, title_field, "new value", title_field.is_multiline) # Then only one should be updated entry = next(library.all_entries(with_joins=True)) @@ -257,37 +254,64 @@ def test_update_entry_with_multiple_identical_fields(library: Library, entry_ful assert entry.text_fields[1].value == "new value" -def test_mirror_entry_fields(library: Library, entry_full: Entry): - # new entry - target_entry = Entry( +def test_mirror_entry_fields(library: Library): + # Create and add entries with fields + entry_a = Entry( folder=unwrap(library.folder), - path=Path("xxx"), + path=Path("title_and_date.txt"), fields=[ - TextField( - type_key=FieldID.NOTES.name, - value="notes", - position=0, - ) + TextField(name="Title", value="I'm a Test Title"), + DatetimeField(name="Date", value="2026-05-07 12:59:24"), ], ) - - # insert new entry and get id - entry_id = library.add_entries([target_entry])[0] - - # get new entry from library - new_entry = unwrap(library.get_entry_full(entry_id)) - - # mirror fields onto new entry - library.mirror_entry_fields(new_entry, entry_full) - - # get new entry from library again - entry = unwrap(library.get_entry_full(entry_id)) - - # make sure fields are there after getting it from the library again - assert len(entry.fields) == 2 - assert {x.type_key for x in entry.fields} == { - FieldID.TITLE.name, - FieldID.NOTES.name, + entry_b = Entry( + folder=unwrap(library.folder), + path=Path("notes.txt"), + fields=[ + TextField(name="Notes", value="These are my notes.\nNo peeking!", is_multiline=True) + ], + ) + entry_c = Entry( + folder=unwrap(library.folder), + path=Path("date_published.txt"), + fields=[ + DatetimeField(name="Date Published", value="2000-01-01 12:00:00"), + ], + ) + entry_a_id, entry_b_id, entry_c_id = library.add_entries([entry_a, entry_b, entry_c]) + + # Retrieve from library + entry_a_ = unwrap(library.get_entry_full(entry_a_id)) + entry_b_ = unwrap(library.get_entry_full(entry_b_id)) + entry_c_ = unwrap(library.get_entry_full(entry_c_id)) + + # Sanity check for initial fields + assert entry_a_.fields[0].name == "Title" + assert entry_a_.fields[1].name == "Date" + assert entry_b_.fields[0].name == "Notes" + assert entry_c_.fields[0].name == "Date Published" + assert len(entry_a_.fields) == 2 + assert len(entry_b_.fields) == 1 + assert len(entry_c_.fields) == 1 + + # Mirror fields between entries + library.mirror_entry_fields([entry_b_, entry_a_, entry_c_]) + + # Retrieve from library, again + entry_a_mirrored = unwrap(library.get_entry_full(entry_a_id)) + entry_b_mirrored = unwrap(library.get_entry_full(entry_b_id)) + entry_c_mirrored = unwrap(library.get_entry_full(entry_c_id)) + + # Assert presence of all fields on all entries + assert len(entry_a_mirrored.fields) == 4 + assert len(entry_b_mirrored.fields) == 4 + assert len(entry_c_mirrored.fields) == 4 + + assert {(type(x), x.name) for x in entry_a_mirrored.fields} == { + (TextField, "Title"), + (TextField, "Notes"), + (DatetimeField, "Date"), + (DatetimeField, "Date Published"), } @@ -298,32 +322,32 @@ def test_merge_entries(library: Library): tag_1: Tag = unwrap(library.add_tag(Tag(id=1011, name="tag_1"))) tag_2: Tag = unwrap(library.add_tag(Tag(id=1012, name="tag_2"))) - a = Entry( + entry_a = Entry( folder=folder, path=Path("a"), fields=[ - TextField(type_key=FieldID.AUTHOR.name, value="Author McAuthorson", position=0), - TextField(type_key=FieldID.DESCRIPTION.name, value="test description", position=2), + TextField(name="Author", value="Author McAuthorson"), + TextField(name="Description", value="test description", is_multiline=True), ], ) - b = Entry( + entry_b = Entry( folder=folder, path=Path("b"), - fields=[TextField(type_key=FieldID.NOTES.name, value="test note", position=1)], + fields=[TextField(name="Notes", value="test note", is_multiline=True)], ) - ids = library.add_entries([a, b]) + entry_a_id, entry_b_id = library.add_entries([entry_a, entry_b]) - library.add_tags_to_entries(ids[0], [tag_0.id, tag_2.id]) - library.add_tags_to_entries(ids[1], [tag_1.id]) + library.add_tags_to_entries(entry_a_id, [tag_0.id, tag_2.id]) + library.add_tags_to_entries(entry_b_id, [tag_1.id]) - entry_a: Entry = unwrap(library.get_entry_full(ids[0])) - entry_b: Entry = unwrap(library.get_entry_full(ids[1])) + entry_a_: Entry = unwrap(library.get_entry_full(entry_a_id)) + entry_b_: Entry = unwrap(library.get_entry_full(entry_b_id)) - assert library.merge_entries(entry_a, entry_b) - assert not library.has_path_entry(Path("a")) - assert library.has_path_entry(Path("b")) + assert library.merge_entries(entry_a_, entry_b_) + assert not library.has_entry_with_path(Path("a")) + assert library.has_entry_with_path(Path("b")) - entry_b_merged = unwrap(library.get_entry_full(ids[1])) + entry_b_merged = unwrap(library.get_entry_full(entry_b_id)) fields = [field.value for field in entry_b_merged.fields] assert "Author McAuthorson" in fields @@ -360,33 +384,6 @@ def test_search_entry_id(library: Library, query_name: int, has_result: bool): assert (result is not None) == has_result -def test_update_field_order(library: Library, entry_full: Entry): - # Given - title_field = entry_full.text_fields[0] - - # When add two more fields - library.add_field_to_entry(entry_full.id, field_id=title_field.type_key, value="first") - library.add_field_to_entry(entry_full.id, field_id=title_field.type_key, value="second") - - # remove the one on first position - assert title_field.position == 0 - library.remove_entry_field(title_field, [entry_full.id]) - - # recalculate the positions - library.update_field_position( - type(title_field), - title_field.type_key, - entry_full.id, - ) - - # Then - entry = next(library.all_entries(with_joins=True)) - assert entry.text_fields[0].position == 0 - assert entry.text_fields[0].value == "first" - assert entry.text_fields[1].position == 1 - assert entry.text_fields[1].value == "second" - - def test_path_search_ilike(library: Library): results = library.search_library(BrowsingState.from_path("bar.md"), page_size=500) assert results.total_count == 1 From a5f27024148b16138f8bb605f34aa550bdf41d44 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Fri, 8 May 2026 12:54:08 -0700 Subject: [PATCH 2/6] fix(tests): add missing db version fixtures --- .../.TagStudio/ts_library.sqlite | Bin 0 -> 114688 bytes .../.TagStudio/ts_library.sqlite | Bin 0 -> 114688 bytes .../.TagStudio/ts_library.sqlite | Bin 0 -> 102400 bytes 3 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/fixtures/empty_libraries/DB_VERSION_101/.TagStudio/ts_library.sqlite create mode 100644 tests/fixtures/empty_libraries/DB_VERSION_103/.TagStudio/ts_library.sqlite create mode 100644 tests/fixtures/empty_libraries/DB_VERSION_200/.TagStudio/ts_library.sqlite diff --git a/tests/fixtures/empty_libraries/DB_VERSION_101/.TagStudio/ts_library.sqlite b/tests/fixtures/empty_libraries/DB_VERSION_101/.TagStudio/ts_library.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..86d70113849bc00b532902260410728eda880583 GIT binary patch literal 114688 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 literal 0 HcmV?d00001 diff --git a/tests/fixtures/empty_libraries/DB_VERSION_103/.TagStudio/ts_library.sqlite b/tests/fixtures/empty_libraries/DB_VERSION_103/.TagStudio/ts_library.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..b4c2833bf1f1a66f98a3635187dc76f990e32aec GIT binary patch literal 114688 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 Date: Fri, 8 May 2026 13:14:21 -0700 Subject: [PATCH 3/6] fix: correctly add datetime field as datetime in json migration --- src/tagstudio/core/library/alchemy/library.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tagstudio/core/library/alchemy/library.py b/src/tagstudio/core/library/alchemy/library.py index 6cb5222b0..a87a1d789 100644 --- a/src/tagstudio/core/library/alchemy/library.py +++ b/src/tagstudio/core/library/alchemy/library.py @@ -339,7 +339,7 @@ def migrate_json_to_sqlite(self, json_lib: JsonLibrary): ), ) elif LEGACY_FIELD_MAP[legacy_field_id]["type"] == DatetimeField: - self.add_text_field_to_entry( + self.add_datetime_field_to_entry( entry_id=( entry.id + 1 ), # NOTE: JSON IDs start at 0 instead of 1 From 8c5495dc57fb6d49878f494b3fb09489bba2981f Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Fri, 8 May 2026 20:42:50 -0700 Subject: [PATCH 4/6] tests: update search library file --- src/tagstudio/core/library/alchemy/models.py | 2 +- .../.TagStudio/ts_library.sqlite | Bin 114688 -> 114688 bytes 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tagstudio/core/library/alchemy/models.py b/src/tagstudio/core/library/alchemy/models.py index dc65e56ae..be4e1647d 100644 --- a/src/tagstudio/core/library/alchemy/models.py +++ b/src/tagstudio/core/library/alchemy/models.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import override -from sqlalchemy import ForeignKey, ForeignKeyConstraint, Integer, event, JSON +from sqlalchemy import ForeignKey, ForeignKeyConstraint, Integer from sqlalchemy.orm import Mapped, mapped_column, relationship from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE diff --git a/tests/fixtures/search_library/.TagStudio/ts_library.sqlite b/tests/fixtures/search_library/.TagStudio/ts_library.sqlite index 6d89b18aabbb2dae650206a1c45a770dc25229da..4d520f076a574893f9bbac9063c4c37b4426d91d 100644 GIT binary patch delta 799 zcmZ`%Ur19?7(Zvb_nzJBz4zNHz0N5vXP_t{l`p}-HCISeWL_Ub%-yjIcTU_@dTWk+ z3d%Tv6#Wsws6m1(xJnp>FM&PwhaL=jFz6vGB%&ufTSSBEaDMQe@89otzH{<Y?l=4{DTv8U{Gc2dO5 z)^yXh423ab_>R~X;S;o2Q zbRYL|I8AjqDHYN{v``4oYV?(Glctqgf3P=pIx-yBgDowcK{Gd!nX+utGRKYi$E&!R zE~@GA^*JEPVba#yiarF!K^!JPR@eB@$R>~^Q&r{M-P^!o+08xpFsUWJPqC_c{ zkA+#0(Zwk~%ryw4A)SwzS)M7t(cLvZ*fYwN@e9ye9eZ^1^k4?+K~2;H85t|a$gHX@ z>KPpG8sY2{>f#Evy`-o#6=(Ly`(`!{FBTU59}N39hwv<6p8jJ!BaZ|Z`zi*$o$RZ4xq0}wuX8=%TFH5jGnP|l kv!XyB`}S4a88@@C06i2lJ#jDNgz0uW7)v%Au+C-#0A3q+hX4Qo From be08cabc4f569ae5ee0ae512fb06516e3925e8c5 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sat, 9 May 2026 10:51:06 -0700 Subject: [PATCH 5/6] fix: implement review feedback and misc fixes --- src/tagstudio/core/library/alchemy/fields.py | 28 +- src/tagstudio/core/library/alchemy/library.py | 260 +++++++----------- src/tagstudio/qt/mixed/field_containers.py | 23 +- tests/macros/test_dupe_files.py | 8 +- tests/test_library.py | 22 +- 5 files changed, 153 insertions(+), 188 deletions(-) diff --git a/src/tagstudio/core/library/alchemy/fields.py b/src/tagstudio/core/library/alchemy/fields.py index e674b955a..4331f578c 100644 --- a/src/tagstudio/core/library/alchemy/fields.py +++ b/src/tagstudio/core/library/alchemy/fields.py @@ -5,7 +5,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, override from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, declared_attr, mapped_column, relationship @@ -44,12 +44,38 @@ class TextField(BaseField): value: Mapped[str | None] is_multiline: Mapped[bool] = mapped_column(nullable=False, default=False) + @override + def __eq__(self, other: object) -> bool: + if not isinstance(other, TextField): + return False + + return (self.name, self.value, self.is_multiline) == ( + other.name, + other.value, + other.is_multiline, + ) + + @override + def __hash__(self) -> int: + return hash((self.name, self.value, self.is_multiline)) + class DatetimeField(BaseField): __tablename__ = "datetime_fields" value: Mapped[str | None] + @override + def __eq__(self, other: object) -> bool: + if not isinstance(other, DatetimeField): + return False + + return (self.name, self.value) == (other.name, other.value) + + @override + def __hash__(self) -> int: + return hash((self.name, self.value)) + class BaseFieldTemplate(Base): __abstract__ = True diff --git a/src/tagstudio/core/library/alchemy/library.py b/src/tagstudio/core/library/alchemy/library.py index a87a1d789..779a9c40f 100644 --- a/src/tagstudio/core/library/alchemy/library.py +++ b/src/tagstudio/core/library/alchemy/library.py @@ -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 @@ -327,24 +327,21 @@ def migrate_json_to_sqlite(self, json_lib: JsonLibrary): self.add_tags_to_entries(entry_ids=entry.id + 1, tag_ids=value) else: try: - if LEGACY_FIELD_MAP[legacy_field_id]["type"] == TextField: - self.add_text_field_to_entry( - entry_id=( - entry.id + 1 - ), # NOTE: JSON IDs start at 0 instead of 1 - name=str(LEGACY_FIELD_MAP[legacy_field_id]["name"]), + # NOTE: JSON IDs start at 0 instead of 1 + field_info = LEGACY_FIELD_MAP[legacy_field_id] + if field_info["type"] == TextField: + text_field = TextField( + name=str(field_info["name"]), value=value, - is_multiline=bool( - LEGACY_FIELD_MAP[legacy_field_id]["is_multiline"] - ), + is_multiline=bool(field_info["is_multiline"]), ) - elif LEGACY_FIELD_MAP[legacy_field_id]["type"] == DatetimeField: - self.add_datetime_field_to_entry( - entry_id=( - entry.id + 1 - ), # NOTE: JSON IDs start at 0 instead of 1 - name=str(LEGACY_FIELD_MAP[legacy_field_id]["name"]), - value=value, + self.add_field_to_entry(entry_id=(entry.id + 1), field=text_field) + elif field_info["type"] == DatetimeField: + datetime_field = DatetimeField( + name=str(field_info["name"]), value=value + ) + self.add_field_to_entry( + entry_id=(entry.id + 1), field=datetime_field ) except Exception as e: logger.error( @@ -490,18 +487,14 @@ def open_sqlite_library( # Add default field templates if is_new: - for ft in get_default_field_templates(): + for template in get_default_field_templates(): try: - if type(ft) is TextFieldTemplate: - session.add( - TextFieldTemplate(name=ft.name, is_multiline=ft.is_multiline) - ) - elif type(ft) is DatetimeFieldTemplate: - session.add(DatetimeFieldTemplate(name=ft.name)) - + session.add(template) session.commit() except IntegrityError: - logger.info("[Library] FieldTemplate already exists", field_template=ft) + logger.info( + "[Library] FieldTemplate already exists", field_template=template + ) session.rollback() # Ensure version rows are present @@ -575,7 +568,6 @@ def open_sqlite_library( self.__apply_db104_migrations(session, library_dir) if loaded_db_version < 200: self.__apply_db200_migrations(session) - self.__apply_db200_data_repairs(session) # Update DB_VERSION if loaded_db_version < DB_VERSION: @@ -767,55 +759,54 @@ def __apply_db200_migrations(self, session: Session): """Migrate DB to DB_VERSION 200.""" with session: # 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")) - session.commit() - logger.info("[Library][Migration][200] Dropped boolean_fields and value_type tables") # Add 'name' column to text_fields and datetime_fields tables - stmt = text('ALTER TABLE text_fields ADD COLUMN name VARCHAR NOT NULL DEFAULT ""') + 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 NOT NULL DEFAULT ""') + stmt = text('ALTER TABLE datetime_fields ADD COLUMN name VARCHAR DEFAULT ""') session.execute(stmt) - session.commit() - logger.info("[Library][Migration][200] Added name columns to field tables") # 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")) - session.commit() - logger.info("[Library][Migration][200] Dropped position columns to field tables") # 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.commit() - logger.info("[Library][Migration][200] Added is_multiline column to text_fields table") + 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.commit() - logger.info("[Library][Migration][200] Moved values from type_key columns to name") + session.flush() # TODO: Remove `type_key` columns from text_fields and datetime_fields tables. # See issue with dropping columns foreign keys in SQLite: # https://www.sqlite.org/lang_altertable.html#making_other_kinds_of_table_schema_changes # 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] Normalized TextField names") - session.commit() + 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("_", " ") - logger.info("[Library][Migration][200] Normalized DatetimeField names") - session.commit() + 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 ] @@ -823,17 +814,10 @@ def __apply_db200_migrations(self, session: Session): update(TextField).where(TextField.name.in_(text_boxes)).values(is_multiline=True) ) session.execute(update_stmt) - logger.info( - "[Library][Migration][200] Updated is_multiline columns for legacy TEXT_BOX fields" - ) - session.commit() - - pass + session.flush() - def __apply_db200_data_repairs(self, session: Session): - logger.info("[Library][Migration] Repairing data for library below version 200...") - with session: # 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 @@ -842,27 +826,26 @@ def __apply_db200_data_repairs(self, session: Session): 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) - session.commit() # Add default field templates - for ft in get_default_field_templates(): + logger.info("[Library][Migration][200] Adding default field templates...") + for template in get_default_field_templates(): try: - if type(ft) is TextFieldTemplate: - session.add(TextFieldTemplate(name=ft.name, is_multiline=ft.is_multiline)) - elif type(ft) is DatetimeFieldTemplate: - session.add(DatetimeFieldTemplate(name=ft.name)) - - session.commit() + session.add(template) + session.flush() except IntegrityError: - logger.info("[Library] FieldTemplate already exists", field_template=ft) + logger.error("[Library] FieldTemplate already exists", field_template=template) session.rollback() + session.commit() + @property def field_templates(self) -> Sequence[BaseFieldTemplate]: with Session(self.engine) as session: @@ -1297,20 +1280,20 @@ def remove_entry_field( field: BaseField, entry_ids: list[int], ) -> None: - field_ = type(field) + field_type = type(field) logger.info( "remove_entry_field", field=field, - type=field_, + type=field_type, entry_ids=entry_ids, ) with Session(self.engine) as session: # remove all fields matching entry and field_type - delete_stmt = delete(field_).where( + delete_stmt = delete(field_type).where( and_( - field_.id == field.id, + field_type.id == field.id, ) ) @@ -1324,14 +1307,14 @@ def update_text_field( if isinstance(entry_ids, int): entry_ids = [entry_ids] - field_ = type(field) + field_type = type(field) with Session(self.engine) as session: update_stmt = ( - update(field_) + update(field_type) .where( and_( - field_.id == field.id, + field_type.id == field.id, ) ) .values(value=value, is_multiline=is_multiline) @@ -1350,14 +1333,14 @@ def update_datetime_field( if isinstance(entry_ids, int): entry_ids = [entry_ids] - field_ = type(field) + field_type = type(field) with Session(self.engine) as session: update_stmt = ( - update(field_) + update(field_type) .where( and_( - field_.id == field.id, + field_type.id == field.id, ) ) .values(value=value) @@ -1366,61 +1349,51 @@ def update_datetime_field( session.execute(update_stmt) session.commit() - def add_text_field_to_entry( - self, entry_id: int, name: str, value: str | None = None, is_multiline: bool = False - ) -> bool: - """Add a TextField field to an Entry.""" - logger.info( - "[Library] Adding text field to entry", - entry_id=entry_id, - name=name, - value=value, - is_multiline=is_multiline, - ) - - field = TextField(entry_id=entry_id, name=name, value=value, is_multiline=is_multiline) + def add_field_to_entry(self, entry_id: int, field: BaseField) -> bool: + """Add a field object to an Entry.""" + if type(field) is TextField: + logger.info( + "[Library] Adding TextField to entry", + entry_id=entry_id, + name=field.name, + value=field.value, + is_multiline=field.is_multiline, + ) - with Session(self.engine) as session: - try: - session.add(field) - session.flush() - session.commit() - except IntegrityError as e: - logger.error(e) - session.rollback() - return False + field = TextField( + entry_id=entry_id, + name=field.name, + value=field.value, + is_multiline=field.is_multiline, + ) - return True + with Session(self.engine) as session: + try: + session.add(field) + session.commit() + except IntegrityError as e: + logger.error(e) + session.rollback() + return False - def add_datetime_field_to_entry( - self, - entry_id: int, - name: str, - value: str | None = None, - ) -> bool: - """Add a DatetimeField field to an Entry.""" - logger.info( - "[Library] Adding datetime field to entry", - entry_id=entry_id, - name=name, - value=value, - ) + elif type(field) is DatetimeField: + logger.info( + "[Library] Adding DatetimeField to entry", + entry_id=entry_id, + name=field.name, + value=field.value, + ) - field = DatetimeField( - entry_id=entry_id, - name=name, - value=value, - ) + field = DatetimeField(entry_id=entry_id, name=field.name, value=field.value) - with Session(self.engine) as session: - try: - session.add(field) - session.flush() - session.commit() - except IntegrityError as e: - logger.error(e) - session.rollback() - return False + with Session(self.engine) as session: + try: + session.add(field) + session.commit() + except IntegrityError as e: + logger.error(e) + session.rollback() + return False return True @@ -1963,55 +1936,22 @@ def set_version(self, key: str, value: int) -> None: def mirror_entry_fields(self, entries: list[Entry]) -> None: """Mirror fields among multiple Entry items.""" - all_tuples_to_fields_map = {} + all_fields: set[BaseField] = set() + logger.info("[Library][mirror_fields]", all_fields=all_fields) # Track all fields across all entries for entry in entries: for field in entry.fields: - field_tuple: tuple | None = None - if type(field) is TextField: - field_tuple = (type(field), field.name, field.value, field.is_multiline) - elif type(field) is DatetimeField: - field_tuple = (type(field), field.name, field.value) - all_tuples_to_fields_map[field_tuple] = field + all_fields.add(field) logger.info( "[Library][mirror_fields]", entry_id=entry.id, field_count_before=len(entry.fields) ) # Apply all (remaining) fields to all entries, avoiding duplicates for entry in entries: - for field_tuple, field in all_tuples_to_fields_map.items(): # pyright: ignore[reportUnknownVariableType] - entry_field_tuples: set[tuple[Any, ...]] = set() # pyright: ignore[reportExplicitAny] - # Locally process the entry's fields into parsable tuples - for entry_field in entry.fields: - entry_field_tuple: tuple | None = None - if type(entry_field) is TextField: - entry_field_tuple = ( - type(entry_field), - entry_field.name, - entry_field.value, - entry_field.is_multiline, - ) - entry_field_tuples.add(entry_field_tuple) - elif type(entry_field) is DatetimeField: - entry_field_tuple = (type(entry_field), entry_field.name, entry_field.value) - entry_field_tuples.add(entry_field_tuple) - - if field_tuple not in entry_field_tuples: - if type(field) is TextField: - self.add_text_field_to_entry( - entry_id=entry.id, - name=field.name, - value=field.value, - is_multiline=field.is_multiline, - ) - elif type(field) is DatetimeField: - self.add_datetime_field_to_entry( - entry_id=entry.id, name=field.name, value=field.value - ) - logger.info( - "[Library][mirror_fields]", entry_id=entry.id, field_count_after=len(entry.fields) - ) + for field in all_fields: + if field not in entry.fields: + self.add_field_to_entry(entry_id=entry.id, field=field) def merge_entries(self, from_entry: Entry, into_entry: Entry) -> bool: """Add fields and tags from the first entry to the second, and then delete the first.""" diff --git a/src/tagstudio/qt/mixed/field_containers.py b/src/tagstudio/qt/mixed/field_containers.py index 594738f71..2a7da7075 100644 --- a/src/tagstudio/qt/mixed/field_containers.py +++ b/src/tagstudio/qt/mixed/field_containers.py @@ -220,23 +220,18 @@ def add_field_to_selected(self, field_list: list[QListWidgetItem]): ) for entry_id in self.driver.selected: for field in field_list: - field_: BaseFieldTemplate = field.data(Qt.ItemDataRole.UserRole) + template: BaseFieldTemplate = field.data(Qt.ItemDataRole.UserRole) logger.info( "[FieldContainers][add_field_to_selected] Adding field", - name=field_.name, - type=field_.__class__.__name__, + name=template.name, + type=template.__class__.__name__, ) - if type(field_) is TextFieldTemplate: - self.lib.add_text_field_to_entry( - entry_id=entry_id, - name=field_.name, - is_multiline=field_.is_multiline, - ) - elif type(field_) is DatetimeFieldTemplate: - self.lib.add_datetime_field_to_entry( - entry_id=entry_id, - name=field_.name, - ) + if type(template) is TextFieldTemplate: + text_field = TextField(name=template.name, is_multiline=template.is_multiline) + self.lib.add_field_to_entry(entry_id, text_field) + elif type(template) is DatetimeFieldTemplate: + datetime_field = DatetimeField(name=template.name) + self.lib.add_field_to_entry(entry_id, datetime_field) def add_tags_to_selected(self, tags: int | list[int]): """Add list of tags to one or more selected items. diff --git a/tests/macros/test_dupe_files.py b/tests/macros/test_dupe_files.py index dac71127b..05053555a 100644 --- a/tests/macros/test_dupe_files.py +++ b/tests/macros/test_dupe_files.py @@ -4,7 +4,7 @@ from pathlib import Path -from tagstudio.core.library.alchemy.fields import TextField +from tagstudio.core.library.alchemy.fields import BaseField, TextField from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Entry from tagstudio.core.library.alchemy.registries.dupe_files_registry import DupeFilesRegistry @@ -17,16 +17,18 @@ def test_refresh_dupe_files(library: Library): library.library_dir = Path("/tmp/") folder = unwrap(library.folder) + fields: list[BaseField] = [TextField(name="Title", value="I'm a Test Title")] + entry = Entry( folder=folder, path=Path("bar/foo.txt"), - fields=[TextField(name="Title", value="I'm a Test Title")], + fields=fields, ) entry2 = Entry( folder=folder, path=Path("foo/foo.txt"), - fields=[TextField(name="Title", value="I'm a Test Title")], + fields=fields, ) library.add_entries([entry, entry2]) diff --git a/tests/test_library.py b/tests/test_library.py index f24473915..c8e7524c7 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -207,18 +207,13 @@ def test_remove_entry_field(library: Library, entry_full: Entry): assert not entry.text_fields -def test_remove_text_field_entry_with_multiple_field(library: Library, entry_full: Entry): +def test_remove_text_field_entry_with_multiple_fields(library: Library, entry_full: Entry): # Given title_field = entry_full.text_fields[0] # When # add identical field - assert library.add_text_field_to_entry( - entry_full.id, - name=title_field.name, - value=title_field.value, - is_multiline=title_field.is_multiline, - ) + assert library.add_field_to_entry(entry_full.id, field=title_field) # remove entry field library.remove_entry_field(title_field, [entry_full.id]) @@ -243,7 +238,8 @@ def test_update_entry_with_multiple_identical_text_fields(library: Library, entr # When # add identical field - library.add_text_field_to_entry(entry_full.id, name="Title", value="") + empty_title = TextField(name="Title", value="") + library.add_field_to_entry(entry_full.id, field=empty_title) # update one of the fields library.update_text_field(entry_full.id, title_field, "new value", title_field.is_multiline) @@ -268,7 +264,8 @@ def test_mirror_entry_fields(library: Library): folder=unwrap(library.folder), path=Path("notes.txt"), fields=[ - TextField(name="Notes", value="These are my notes.\nNo peeking!", is_multiline=True) + TextField(name="Notes", value="These are my notes.\nNo peeking!", is_multiline=True), + TextField(name="Title", value="I'm a Test Title"), ], ) entry_c = Entry( @@ -291,7 +288,7 @@ def test_mirror_entry_fields(library: Library): assert entry_b_.fields[0].name == "Notes" assert entry_c_.fields[0].name == "Date Published" assert len(entry_a_.fields) == 2 - assert len(entry_b_.fields) == 1 + assert len(entry_b_.fields) == 2 assert len(entry_c_.fields) == 1 # Mirror fields between entries @@ -302,6 +299,11 @@ def test_mirror_entry_fields(library: Library): entry_b_mirrored = unwrap(library.get_entry_full(entry_b_id)) entry_c_mirrored = unwrap(library.get_entry_full(entry_c_id)) + for entry in [entry_a_mirrored, entry_b_mirrored, entry_c_mirrored]: + logger.info( + "[Library][mirror_fields]", entry_id=entry.id, field_count_after=len(entry.fields) + ) + # Assert presence of all fields on all entries assert len(entry_a_mirrored.fields) == 4 assert len(entry_b_mirrored.fields) == 4 From bf300d80bac6c3aa392f39f5cd8b13b1ab1b7be3 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sat, 9 May 2026 20:09:24 -0700 Subject: [PATCH 6/6] fix: implement additional feedback --- src/tagstudio/core/library/alchemy/fields.py | 32 +++++++++ src/tagstudio/core/library/alchemy/library.py | 71 ++++++------------- src/tagstudio/qt/mixed/add_field.py | 2 +- src/tagstudio/qt/mixed/field_containers.py | 15 ++-- src/tagstudio/qt/ts_qt.py | 2 +- tests/test_library.py | 4 +- 6 files changed, 61 insertions(+), 65 deletions(-) diff --git a/src/tagstudio/core/library/alchemy/fields.py b/src/tagstudio/core/library/alchemy/fields.py index 4331f578c..5d4d6c966 100644 --- a/src/tagstudio/core/library/alchemy/fields.py +++ b/src/tagstudio/core/library/alchemy/fields.py @@ -35,6 +35,13 @@ def entry_id(self) -> Mapped[int]: def entry(self) -> Mapped[Entry]: return relationship(foreign_keys=[self.entry_id]) # type: ignore # pyright: ignore[reportArgumentType] + @property + def class_name(self) -> str: + return self.__class__.__name__ + + def clone_with_entry_id(self, entry_id: int) -> BaseField: # pyright: ignore + raise NotImplementedError() + value: Any # pyright: ignore @@ -59,6 +66,12 @@ def __eq__(self, other: object) -> bool: def __hash__(self) -> int: return hash((self.name, self.value, self.is_multiline)) + @override + def clone_with_entry_id(self, entry_id: int) -> TextField: + return TextField( + name=self.name, entry_id=entry_id, value=self.value, is_multiline=self.is_multiline + ) + class DatetimeField(BaseField): __tablename__ = "datetime_fields" @@ -76,6 +89,10 @@ def __eq__(self, other: object) -> bool: def __hash__(self) -> int: return hash((self.name, self.value)) + @override + def clone_with_entry_id(self, entry_id: int) -> DatetimeField: + return DatetimeField(name=self.name, entry_id=entry_id, value=self.value) + class BaseFieldTemplate(Base): __abstract__ = True @@ -88,15 +105,30 @@ def id(self) -> Mapped[int]: def name(self) -> Mapped[str]: return mapped_column(nullable=False, default="") + @property + def class_name(self) -> str: + return self.__class__.__name__ + + def to_field(self, value: Any | None = None) -> BaseField: # pyright: ignore + raise NotImplementedError() + class TextFieldTemplate(BaseFieldTemplate): __tablename__ = "text_field_templates" is_multiline: Mapped[bool] = mapped_column(nullable=False, default=False) + @override + def to_field(self, value: str | None = None) -> TextField: + return TextField(name=self.name, value=value, is_multiline=self.is_multiline) + class DatetimeFieldTemplate(BaseFieldTemplate): __tablename__ = "datetime_field_templates" + @override + def to_field(self, value: str | None = None) -> DatetimeField: + return DatetimeField(name=self.name, value=value) + # Used for migrating legacy libraries. # Legacy JSON libraries ( bool: + def add_field_to_entries(self, entry_ids: list[int] | int, field: BaseField) -> bool: """Add a field object to an Entry.""" - if type(field) is TextField: - logger.info( - "[Library] Adding TextField to entry", - entry_id=entry_id, - name=field.name, - value=field.value, - is_multiline=field.is_multiline, - ) - - field = TextField( - entry_id=entry_id, - name=field.name, - value=field.value, - is_multiline=field.is_multiline, - ) - - with Session(self.engine) as session: - try: - session.add(field) - session.commit() - except IntegrityError as e: - logger.error(e) - session.rollback() - return False - - elif type(field) is DatetimeField: - logger.info( - "[Library] Adding DatetimeField to entry", - entry_id=entry_id, - name=field.name, - value=field.value, - ) + if isinstance(entry_ids, int): + entry_ids = [entry_ids] - field = DatetimeField(entry_id=entry_id, name=field.name, value=field.value) + logger.info( + "[Library] Adding field to entry", + type=field.class_name, + entry_ids=entry_ids, + name=field.name, + value=field.value, + ) - with Session(self.engine) as session: + with Session(self.engine) as session: + for entry_id in entry_ids: try: - session.add(field) + session.add(field.clone_with_entry_id(entry_id)) session.commit() except IntegrityError as e: logger.error(e) @@ -1951,7 +1922,7 @@ def mirror_entry_fields(self, entries: list[Entry]) -> None: for entry in entries: for field in all_fields: if field not in entry.fields: - self.add_field_to_entry(entry_id=entry.id, field=field) + self.add_field_to_entries(entry_ids=entry.id, field=field) def merge_entries(self, from_entry: Entry, into_entry: Entry) -> bool: """Add fields and tags from the first entry to the second, and then delete the first.""" diff --git a/src/tagstudio/qt/mixed/add_field.py b/src/tagstudio/qt/mixed/add_field.py index bfaac9310..ba83ab584 100644 --- a/src/tagstudio/qt/mixed/add_field.py +++ b/src/tagstudio/qt/mixed/add_field.py @@ -78,7 +78,7 @@ def show(self): self.list_widget.clear() for field_template in self.lib.field_templates: field_name_key: str = FIELD_TYPE_KEYS.get( - field_template.__class__.__name__, "field_type.unknown" + field_template.class_name, "field_type.unknown" ) item = QListWidgetItem(f"{field_template.name} ({Translations[field_name_key]})") item.setData(Qt.ItemDataRole.UserRole, field_template) diff --git a/src/tagstudio/qt/mixed/field_containers.py b/src/tagstudio/qt/mixed/field_containers.py index 2a7da7075..12f82577e 100644 --- a/src/tagstudio/qt/mixed/field_containers.py +++ b/src/tagstudio/qt/mixed/field_containers.py @@ -28,9 +28,7 @@ BaseField, BaseFieldTemplate, DatetimeField, - DatetimeFieldTemplate, TextField, - TextFieldTemplate, ) from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Entry, Tag @@ -224,14 +222,9 @@ def add_field_to_selected(self, field_list: list[QListWidgetItem]): logger.info( "[FieldContainers][add_field_to_selected] Adding field", name=template.name, - type=template.__class__.__name__, + type=template.class_name, ) - if type(template) is TextFieldTemplate: - text_field = TextField(name=template.name, is_multiline=template.is_multiline) - self.lib.add_field_to_entry(entry_id, text_field) - elif type(template) is DatetimeFieldTemplate: - datetime_field = DatetimeField(name=template.name) - self.lib.add_field_to_entry(entry_id, datetime_field) + self.lib.add_field_to_entries(entry_id, template.to_field()) def add_tags_to_selected(self, tags: int | list[int]): """Add list of tags to one or more selected items. @@ -265,7 +258,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): "[FieldContainers][write_container]", index=index, name=field.name, - type=field.__class__.__name__, + type=field.class_name, ) if len(self.containers) < (index + 1): container = FieldContainer() @@ -275,7 +268,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): container = self.containers[index] # Set field title - field_name_key: str = FIELD_TYPE_KEYS.get(field.__class__.__name__, "field_type.unknown") + field_name_key: str = FIELD_TYPE_KEYS.get(field.class_name, "field_type.unknown") title = f"{field.name} ({Translations[field_name_key]})" # Single-line Text diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index 4046e93f5..8ba3a8ad9 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -1228,7 +1228,7 @@ def paste_fields_action_callback(self): if field.type_key == e.type_key and field.value == e.value: exists = True if not exists: - self.lib.add_field_to_entry(id, field_id=field.type_key, value=field.value) + self.lib.add_field_to_entries(id, field_id=field.type_key, value=field.value) self.lib.add_tags_to_entries(id, self.copy_buffer["tags"]) if len(self.selected) > 1: if TAG_ARCHIVED in self.copy_buffer["tags"]: diff --git a/tests/test_library.py b/tests/test_library.py index c8e7524c7..01cfb84b5 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -213,7 +213,7 @@ def test_remove_text_field_entry_with_multiple_fields(library: Library, entry_fu # When # add identical field - assert library.add_field_to_entry(entry_full.id, field=title_field) + assert library.add_field_to_entries(entry_full.id, field=title_field) # remove entry field library.remove_entry_field(title_field, [entry_full.id]) @@ -239,7 +239,7 @@ def test_update_entry_with_multiple_identical_text_fields(library: Library, entr # When # add identical field empty_title = TextField(name="Title", value="") - library.add_field_to_entry(entry_full.id, field=empty_title) + library.add_field_to_entries(entry_full.id, field=empty_title) # update one of the fields library.update_text_field(entry_full.id, title_field, "new value", title_field.is_multiline)