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..5d4d6c966 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 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,103 +35,129 @@ 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()) + @property + def class_name(self) -> str: + return self.__class__.__name__ - def __key(self): # pyright: ignore[reportUnknownParameterType] - raise NotImplementedError + def clone_with_entry_id(self, entry_id: int) -> BaseField: # pyright: ignore + raise NotImplementedError() value: Any # pyright: ignore -class BooleanField(BaseField): - __tablename__ = "boolean_fields" +class TextField(BaseField): + __tablename__ = "text_fields" + + 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 - value: Mapped[bool] + return (self.name, self.value, self.is_multiline) == ( + other.name, + other.value, + other.is_multiline, + ) - def __key(self): - return (self.type, self.value) + @override + def __hash__(self) -> int: + return hash((self.name, self.value, self.is_multiline)) @override - def __eq__(self, value: object) -> bool: - if isinstance(value, BooleanField): - return self.__key() == value.__key() - raise NotImplementedError + 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 TextField(BaseField): - __tablename__ = "text_fields" +class DatetimeField(BaseField): + __tablename__ = "datetime_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): + def __eq__(self, other: object) -> bool: + if not isinstance(other, DatetimeField): return False - raise NotImplementedError + return (self.name, self.value) == (other.name, other.value) -class DatetimeField(BaseField): - __tablename__ = "datetime_fields" + @override + 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 + + @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="") + + @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) - value: Mapped[str | None] - def __key(self): - return (self.type, self.value) +class DatetimeFieldTemplate(BaseFieldTemplate): + __tablename__ = "datetime_field_templates" @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) + def to_field(self, value: str | None = None) -> DatetimeField: + return DatetimeField(name=self.name, value=value) + + +# 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,47 @@ 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: + # 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(field_info["is_multiline"]), + ) + self.add_field_to_entries( + entry_ids=(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_entries( + entry_ids=(entry.id + 1), field=datetime_field + ) + 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 +362,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 +487,18 @@ def open_sqlite_library( except IntegrityError: session.rollback() + # Add default field templates + if is_new: + for template in get_default_field_templates(): + try: + session.add(template) + session.commit() + except IntegrityError: + logger.info( + "[Library] FieldTemplate already exists", field_template=template + ) + session.rollback() + # Ensure version rows are present with catch_warnings(record=True): try: @@ -469,22 +514,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 +568,8 @@ 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) # Update DB_VERSION if loaded_db_version < DB_VERSION: @@ -552,15 +583,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 +725,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 +757,103 @@ 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 + logger.info( + "[Library][Migration][200] Dropping boolean_fields and value_type tables..." + ) + session.execute(text("DROP TABLE boolean_fields")) + session.execute(text("DROP TABLE value_type")) + + # Add 'name' column to text_fields and datetime_fields tables + logger.info("[Library][Migration][200] Adding name columns to field tables...") + stmt = text('ALTER TABLE text_fields ADD COLUMN name VARCHAR DEFAULT ""') + session.execute(stmt) + stmt = text('ALTER TABLE datetime_fields ADD COLUMN name VARCHAR DEFAULT ""') + session.execute(stmt) + + # Drop unnecessary 'position' columns + logger.info("[Library][Migration][200] Dropping position columns to field tables...") + session.execute(text("ALTER TABLE datetime_fields DROP COLUMN position")) + session.execute(text("ALTER TABLE text_fields DROP COLUMN position")) + + # Add 'is_multiline' column to text_fields table + logger.info("[Library][Migration][200] Adding is_multiline column to text_fields...") + stmt = text( + "ALTER TABLE text_fields ADD COLUMN is_multiline BOOLEAN NOT NULL DEFAULT 0" + ) + session.execute(stmt) + session.flush() + + # Move values from old `type_key` columns into new `name` columns + logger.info("[Library][Migration][200] Moving values from type_key columns to name...") + session.execute(text("UPDATE text_fields SET name = type_key")) + session.execute(text("UPDATE datetime_fields SET name = type_key")) + session.flush() + + # 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] Normalizing DatetimeField names...") + for datetime_field in session.execute(select(DatetimeField)).scalars(): + datetime_field.name = datetime_field.name.title().replace("_", " ") + session.flush() + + # Add correct `is_multiline` values to text_fields table + logger.info("[Library][Migration][200] Updating is_multiline for legacy TEXT_BOXes...") + text_boxes = [ + x.get("name") for x in LEGACY_FIELD_MAP.values() if x.get("is_multiline") is True + ] + update_stmt = ( + update(TextField).where(TextField.name.in_(text_boxes)).values(is_multiline=True) + ) + session.execute(update_stmt) + session.flush() + + # Repair legacy "Description" fields to use is_multiline = True + logger.info("[Library][Migration][200] Repairing legacy Description fields...") + desc_stmt = ( + update(TextField) + .where(TextField.name == "Description" and TextField.is_multiline == False) # noqa: E712 + .values(is_multiline=True) + ) + session.execute(desc_stmt) + + # Repair legacy "Comments" fields to use is_multiline = True + logger.info("[Library][Migration][200] Repairing legacy Comment fields...") + comm_stmt = ( + update(TextField) + .where(TextField.name == "Comments" and TextField.is_multiline == False) # noqa: E712 + .values(is_multiline=True) + ) + session.execute(comm_stmt) + + # Add default field templates + logger.info("[Library][Migration][200] Adding default field templates...") + for template in get_default_field_templates(): + try: + session.add(template) + session.flush() + except IntegrityError: + logger.error("[Library] FieldTemplate already exists", field_template=template) + session.rollback() + + session.commit() + @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 +1084,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 +1233,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 +1277,95 @@ 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 = type(field) logger.info( "remove_entry_field", field=field, + type=field_type, 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_type).where( and_( - FieldClass.position == field.position, - FieldClass.type_key == field.type_key, - FieldClass.entry_id.in_(entry_ids), + field_type.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 = type(field) + + with Session(self.engine) as session: + update_stmt = ( + update(field_type) + .where(and_(field_type.id == field.id, field_type.entry_id.in_(entry_ids))) + .values(value=value, is_multiline=is_multiline) + ) + + session.execute(update_stmt) + session.commit() - def update_entry_field( + 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 = type(field) with Session(self.engine) as session: update_stmt = ( - update(FieldClass) - .where( - and_( - FieldClass.position == field.position, - FieldClass.type == field.type, - FieldClass.entry_id.in_(entry_ids), - ) - ) - .values(value=content) + update(field_type) + .where(and_(field_type.id == field.id, field_type.entry_id.in_(entry_ids))) + .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 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 + def add_field_to_entries(self, entry_ids: list[int] | int, field: BaseField) -> bool: + """Add a field object to an Entry.""" + if isinstance(entry_ids, int): + entry_ids = [entry_ids] - def add_field_to_entry( - self, - entry_id: int, - *, - field: ValueType | None = None, - field_id: FieldID | str | None = None, - value: str | datetime | None = None, - ) -> bool: logger.info( - "[Library][add_field_to_entry]", - entry_id=entry_id, - field_type=field, - field_id=field_id, - value=value, + "[Library] Adding field to entry", + type=field.class_name, + entry_ids=entry_ids, + name=field.name, + value=field.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}") with Session(self.engine) as session: - try: - field_model.entry_id = entry_id - session.add(field_model) - session.flush() - session.commit() - except IntegrityError as e: - logger.error(e) - session.rollback() - return False - # TODO - trigger error signal + for entry_id in entry_ids: + try: + session.add(field.clone_with_entry_id(entry_id)) + session.commit() + except IntegrityError as e: + logger.error(e) + session.rollback() + return False - # 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 +1905,42 @@ 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_fields: set[BaseField] = set() + logger.info("[Library][mirror_fields]", all_fields=all_fields) + + # 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: + all_fields.add(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 in all_fields: + if field not in entry.fields: + 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.""" - 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..be4e1647d 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 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..ba83ab584 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..12f82577e 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,9 +24,9 @@ ) from tagstudio.core.enums import Theme -from tagstudio.core.library.alchemy.enums import FieldTypeEnum from tagstudio.core.library.alchemy.fields import ( BaseField, + BaseFieldTemplate, DatetimeField, TextField, ) @@ -36,7 +37,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 +206,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 +217,14 @@ 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: + template: BaseFieldTemplate = field.data(Qt.ItemDataRole.UserRole) + logger.info( + "[FieldContainers][add_field_to_selected] Adding field", + name=template.name, + type=template.class_name, ) + 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. @@ -250,7 +254,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 +267,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 +281,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 +304,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 +312,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 +339,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 +347,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 +366,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 +378,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 +386,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 +430,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 +444,7 @@ def write_tag_container( else: inner_widget = TagBoxWidget( - "Tags", + "Tags", # TODO: Localize this self.driver, ) container.set_inner_widget(inner_widget) @@ -460,26 +473,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..8ba3a8ad9 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( @@ -1257,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/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/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 000000000..86d701138 Binary files /dev/null and b/tests/fixtures/empty_libraries/DB_VERSION_101/.TagStudio/ts_library.sqlite differ 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 000000000..b4c2833bf Binary files /dev/null and b/tests/fixtures/empty_libraries/DB_VERSION_103/.TagStudio/ts_library.sqlite differ diff --git a/tests/fixtures/empty_libraries/DB_VERSION_200/.TagStudio/ts_library.sqlite b/tests/fixtures/empty_libraries/DB_VERSION_200/.TagStudio/ts_library.sqlite new file mode 100644 index 000000000..96fd0cf59 Binary files /dev/null and b/tests/fixtures/empty_libraries/DB_VERSION_200/.TagStudio/ts_library.sqlite differ diff --git a/tests/fixtures/search_library/.TagStudio/ts_library.sqlite b/tests/fixtures/search_library/.TagStudio/ts_library.sqlite index 6d89b18aa..4d520f076 100644 Binary files a/tests/fixtures/search_library/.TagStudio/ts_library.sqlite and b/tests/fixtures/search_library/.TagStudio/ts_library.sqlite differ diff --git a/tests/macros/test_dupe_files.py b/tests/macros/test_dupe_files.py index e449bd552..05053555a 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 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 @@ -16,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=library.default_fields, + fields=fields, ) entry2 = Entry( folder=folder, path=Path("foo/foo.txt"), - fields=library.default_fields, + fields=fields, ) 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..01cfb84b5 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,13 @@ 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_fields(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_field_to_entries(entry_full.id, field=title_field) # remove entry field library.remove_entry_field(title_field, [entry_full.id]) @@ -226,30 +226,23 @@ 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) + empty_title = TextField(name="Title", value="") + library.add_field_to_entries(entry_full.id, field=empty_title) # 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 +250,70 @@ 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), + TextField(name="Title", value="I'm a Test Title"), + ], + ) + 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) == 2 + 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)) + + 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 + 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 +324,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 +386,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