mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2026-05-21 08:15:09 +00:00
refactor!: uncouple fields from hard-coded values (#1354)
* refactor!: uncouple fields from hardcoded values * fix(tests): add missing db version fixtures * fix: correctly add datetime field as datetime in json migration * tests: update search library file * fix: implement review feedback and misc fixes * fix: implement additional feedback
This commit is contained in:
committed by
GitHub
parent
de7face06b
commit
c15e2b56ee
@@ -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 | `<Library Folder>`/.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 | `<Library Folder>`/.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 | `<Library Folder>`/.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 | `<Library Folder>`/.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 | `<Library Folder>`/.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 | `<Library Folder>`/.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 | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
| Used From | Format | Location |
|
||||
| ---------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| [71d0425](https://github.com/TagStudioDev/TagStudio/commit/71d04254cf87f4200bb7ffc81656e50dfb122e4d) | SQLite | `<Library Folder>`/.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.
|
||||
- Applies repairs to the `tag_parents` table created in [version 100](#version-100), removing rows that reference tags that have been deleted.
|
||||
|
||||
#### Version 103
|
||||
| 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 | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
|
||||
| Used From | Format | Location |
|
||||
| ----------------------------------------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| [#1139](https://github.com/TagStudioDev/TagStudio/pull/1139) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
|
||||
- Adds the `is_hidden` column to the `tags` table (default `0`). Used for excluding entries tagged with hidden tags from library searches.
|
||||
- Sets the `is_hidden` field on the built-in Archived tag to `1`, to match the Archived tag now being hidden by default.
|
||||
- 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 | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
| Used From | Format | Location |
|
||||
| ---------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| [ad2cbbc](https://github.com/TagStudioDev/TagStudio/commit/ad2cbbca483018d245b44348e2c4f5a0e0bb28f1) | SQLite | `<Library Folder>`/.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 | `<Library Folder>`/.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`.
|
||||
|
||||
@@ -10,7 +10,7 @@ JSON_FILENAME: str = "ts_library.json"
|
||||
|
||||
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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -152,11 +152,3 @@ class 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"
|
||||
|
||||
@@ -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 @@ class BaseField(Base):
|
||||
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,42 @@ class BaseField(Base):
|
||||
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)
|
||||
@property
|
||||
def class_name(self) -> str:
|
||||
return self.__class__.__name__
|
||||
|
||||
@override
|
||||
def __hash__(self):
|
||||
return hash(self.__key())
|
||||
|
||||
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"
|
||||
|
||||
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
|
||||
is_multiline: Mapped[bool] = mapped_column(nullable=False, default=False)
|
||||
|
||||
@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, TextField):
|
||||
return False
|
||||
raise NotImplementedError
|
||||
|
||||
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))
|
||||
|
||||
@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):
|
||||
@@ -93,52 +78,86 @@ class DatetimeField(BaseField):
|
||||
|
||||
value: Mapped[str | None]
|
||||
|
||||
def __key(self):
|
||||
return (self.type, self.value)
|
||||
@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 __eq__(self, value: object) -> bool:
|
||||
if isinstance(value, DatetimeField):
|
||||
return self.__key() == value.__key()
|
||||
raise NotImplementedError
|
||||
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)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DefaultField:
|
||||
id: int
|
||||
name: str
|
||||
type: FieldTypeEnum
|
||||
is_default: bool = field(default=False)
|
||||
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 FieldID(Enum):
|
||||
"""Only for bootstrapping content of DB table."""
|
||||
class TextFieldTemplate(BaseFieldTemplate):
|
||||
__tablename__ = "text_field_templates"
|
||||
is_multiline: Mapped[bool] = mapped_column(nullable=False, default=False)
|
||||
|
||||
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)
|
||||
@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 (<v9.4) use an integer ID.
|
||||
# SQLite libraries 6 until 200 use a slugfield name (e.g. "DATE_CREATED").
|
||||
LEGACY_FIELD_MAP = {
|
||||
0: {"type": TextField, "name": "Title", "is_multiline": False},
|
||||
1: {"type": TextField, "name": "Author", "is_multiline": False},
|
||||
2: {"type": TextField, "name": "Artist", "is_multiline": False},
|
||||
3: {"type": TextField, "name": "URL", "is_multiline": False},
|
||||
4: {"type": TextField, "name": "Description", "is_multiline": True},
|
||||
5: {"type": TextField, "name": "Notes", "is_multiline": True},
|
||||
9: {"type": TextField, "name": "Collation", "is_multiline": False},
|
||||
10: {"type": DatetimeField, "name": "Date", "is_multiline": False},
|
||||
11: {"type": DatetimeField, "name": "Date Created"},
|
||||
12: {"type": DatetimeField, "name": "Date Modified"},
|
||||
13: {"type": DatetimeField, "name": "Date Taken"},
|
||||
14: {"type": DatetimeField, "name": "Date Published"},
|
||||
17: {"type": TextField, "name": "Book", "is_multiline": False},
|
||||
18: {"type": TextField, "name": "Comic", "is_multiline": False},
|
||||
19: {"type": TextField, "name": "Series", "is_multiline": False},
|
||||
20: {"type": TextField, "name": "Manga", "is_multiline": False},
|
||||
21: {"type": TextField, "name": "Source", "is_multiline": False},
|
||||
22: {"type": DatetimeField, "name": "Date Uploaded"},
|
||||
23: {"type": DatetimeField, "name": "Date Released"},
|
||||
24: {"type": TextField, "name": "Volume", "is_multiline": False},
|
||||
25: {"type": TextField, "name": "Anthology", "is_multiline": False},
|
||||
26: {"type": TextField, "name": "Magazine", "is_multiline": False},
|
||||
27: {"type": TextField, "name": "Publisher", "is_multiline": False},
|
||||
28: {"type": TextField, "name": "Guest Artist", "is_multiline": False},
|
||||
29: {"type": TextField, "name": "Composer", "is_multiline": False},
|
||||
30: {"type": TextField, "name": "Comments", "is_multiline": True},
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import re
|
||||
import shutil
|
||||
import time
|
||||
import unicodedata
|
||||
from collections.abc import Iterable, Iterator
|
||||
from collections.abc import Iterable, Iterator, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from os import makedirs
|
||||
@@ -79,14 +79,16 @@ from tagstudio.core.library.alchemy.db import make_tables
|
||||
from tagstudio.core.library.alchemy.enums import (
|
||||
MAX_SQL_VARIABLES,
|
||||
BrowsingState,
|
||||
FieldTypeEnum,
|
||||
SortingModeEnum,
|
||||
)
|
||||
from tagstudio.core.library.alchemy.fields import (
|
||||
LEGACY_FIELD_MAP,
|
||||
BaseField,
|
||||
BaseFieldTemplate,
|
||||
DatetimeField,
|
||||
FieldID,
|
||||
DatetimeFieldTemplate,
|
||||
TextField,
|
||||
TextFieldTemplate,
|
||||
)
|
||||
from tagstudio.core.library.alchemy.joins import TagEntry, TagParent
|
||||
from tagstudio.core.library.alchemy.models import (
|
||||
@@ -96,7 +98,6 @@ from tagstudio.core.library.alchemy.models import (
|
||||
Tag,
|
||||
TagAlias,
|
||||
TagColorGroup,
|
||||
ValueType,
|
||||
Version,
|
||||
)
|
||||
from tagstudio.core.library.alchemy.visitors import SQLBoolExpressionBuilder
|
||||
@@ -138,6 +139,7 @@ def slugify(input_string: str, allow_reserved: bool = False) -> 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 @@ class Library:
|
||||
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 @@ class Library:
|
||||
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 "<NO TAG>"
|
||||
@@ -454,6 +487,18 @@ class 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 @@ class 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 @@ class 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 @@ class Library:
|
||||
"""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 @@ class Library:
|
||||
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 @@ class Library:
|
||||
with open(ts_ignore, "w") as f:
|
||||
f.write(migrate_ext_list(extensions, is_exclude_list))
|
||||
|
||||
@property
|
||||
def default_fields(self) -> list[BaseField]:
|
||||
with Session(self.engine) as session:
|
||||
types = session.scalars(
|
||||
select(ValueType).where(
|
||||
# check if field is default
|
||||
ValueType.is_default.is_(True)
|
||||
)
|
||||
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..."
|
||||
)
|
||||
return [x.as_field for x in types]
|
||||
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 field_templates(self) -> Sequence[BaseFieldTemplate]:
|
||||
with Session(self.engine) as session:
|
||||
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 @@ class Library:
|
||||
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 @@ class Library:
|
||||
|
||||
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 @@ class Library:
|
||||
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_entry_field(
|
||||
self,
|
||||
entry_ids: list[int] | int,
|
||||
field: BaseField,
|
||||
content: str | datetime,
|
||||
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]
|
||||
|
||||
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, is_multiline=is_multiline)
|
||||
)
|
||||
|
||||
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_entry(
|
||||
def update_datetime_field(
|
||||
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,
|
||||
)
|
||||
# supply only instance or ID, not both
|
||||
assert bool(field) != (field_id is not None)
|
||||
entry_ids: list[int] | int,
|
||||
field: DatetimeField,
|
||||
value: datetime,
|
||||
):
|
||||
"""Update a DatetimeField field on one or more Entries."""
|
||||
if isinstance(entry_ids, int):
|
||||
entry_ids = [entry_ids]
|
||||
|
||||
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_type = type(field)
|
||||
|
||||
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
|
||||
update_stmt = (
|
||||
update(field_type)
|
||||
.where(and_(field_type.id == field.id, field_type.entry_id.in_(entry_ids)))
|
||||
.values(value=value)
|
||||
)
|
||||
|
||||
# recalculate the positions of fields
|
||||
self.update_field_position(
|
||||
field_class=type(field_model),
|
||||
field_type=field.key,
|
||||
entry_ids=entry_id,
|
||||
session.execute(update_stmt)
|
||||
session.commit()
|
||||
|
||||
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]
|
||||
|
||||
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:
|
||||
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
|
||||
|
||||
return True
|
||||
|
||||
def tag_from_strings(self, strings: list[str] | str) -> list[int]:
|
||||
@@ -1867,39 +1905,42 @@ class Library:
|
||||
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}
|
||||
for entry in entries:
|
||||
for entry_field in entry.fields:
|
||||
fields[entry_field.type_key] = entry_field
|
||||
all_fields: set[BaseField] = set()
|
||||
logger.info("[Library][mirror_fields]", all_fields=all_fields)
|
||||
|
||||
# assign the field to all entries
|
||||
# Track all fields across all entries
|
||||
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 entry.fields:
|
||||
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 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
|
||||
|
||||
|
||||
@@ -6,15 +6,13 @@ from datetime import datetime as dt
|
||||
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 @@ class Entry(Base):
|
||||
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 @@ class Entry(Base):
|
||||
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"
|
||||
|
||||
|
||||
@@ -145,7 +145,7 @@ class RefreshTracker:
|
||||
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 @@ class RefreshTracker:
|
||||
|
||||
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!")
|
||||
|
||||
@@ -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 | None:
|
||||
|
||||
@@ -65,7 +65,7 @@ class LibraryInfoWindow(LibraryInfoWindowView):
|
||||
def update_stats(self):
|
||||
self.entry_count_label.setText(f"<b>{self.lib.entries_count}</b>")
|
||||
self.tag_count_label.setText(f"<b>{len(self.lib.tags)}</b>")
|
||||
self.field_count_label.setText(f"<b>{len(self.lib.field_types)}</b>")
|
||||
self.field_count_label.setText(f"<b>{len(self.lib.field_templates)}</b>")
|
||||
self.namespaces_count_label.setText(f"<b>{len(self.lib.namespaces)}</b>")
|
||||
colors_total = 0
|
||||
for c in self.lib.tag_color_groups.values():
|
||||
|
||||
@@ -22,12 +22,15 @@ class PreviewPanel(PreviewPanelView):
|
||||
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()
|
||||
|
||||
@@ -19,7 +19,7 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
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 @@ class AddFieldModal(QWidget):
|
||||
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()
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from PySide6.QtGui import QGuiApplication
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QListWidgetItem,
|
||||
QMessageBox,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
@@ -23,9 +24,9 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
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.controllers.tag_box_controller import TagBoxWidget
|
||||
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 @@ class FieldContainers(QWidget):
|
||||
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 @@ class FieldContainers(QWidget):
|
||||
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 @@ class FieldContainers(QWidget):
|
||||
|
||||
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 @@ class FieldContainers(QWidget):
|
||||
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 @@ class FieldContainers(QWidget):
|
||||
assert isinstance(field.value, str | type(None))
|
||||
text = field.value or ""
|
||||
else:
|
||||
text = "<i>Mixed Data</i>"
|
||||
text = "<i>Mixed Data</i>" # 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 @@ class FieldContainers(QWidget):
|
||||
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 @@ class FieldContainers(QWidget):
|
||||
)
|
||||
)
|
||||
|
||||
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 = "<i>Mixed Data</i>"
|
||||
title = f"{field.type.name} (Text Box)"
|
||||
text = "<i>Mixed Data</i>" # 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 @@ class FieldContainers(QWidget):
|
||||
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 @@ class FieldContainers(QWidget):
|
||||
)
|
||||
)
|
||||
|
||||
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 @@ class FieldContainers(QWidget):
|
||||
|
||||
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 @@ class FieldContainers(QWidget):
|
||||
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 @@ class FieldContainers(QWidget):
|
||||
)
|
||||
)
|
||||
else:
|
||||
text = "<i>Mixed Data</i>"
|
||||
title = f"{field.type.name} (Wacky Date)"
|
||||
text = "<i>Mixed Data</i>" # 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 @@ class FieldContainers(QWidget):
|
||||
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 @@ class FieldContainers(QWidget):
|
||||
|
||||
else:
|
||||
inner_widget = TagBoxWidget(
|
||||
"Tags",
|
||||
"Tags", # TODO: Localize this
|
||||
self.driver,
|
||||
)
|
||||
container.set_inner_widget(inner_widget)
|
||||
@@ -460,26 +473,24 @@ class FieldContainers(QWidget):
|
||||
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_entry_field(
|
||||
entry_ids,
|
||||
field,
|
||||
content,
|
||||
)
|
||||
|
||||
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_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
|
||||
|
||||
@@ -34,6 +34,7 @@ from tagstudio.core.constants import (
|
||||
)
|
||||
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 @@ class JsonMigrationModal(QObject):
|
||||
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 @@ class JsonMigrationModal(QObject):
|
||||
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 @@ class JsonMigrationModal(QObject):
|
||||
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 @@ class JsonMigrationModal(QObject):
|
||||
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()
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ class MirrorEntriesModal(QWidget):
|
||||
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
|
||||
|
||||
|
||||
@@ -39,6 +39,14 @@ LANGUAGES = {
|
||||
"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]
|
||||
|
||||
@@ -52,10 +52,8 @@ from tagstudio.core.driver import DriverMixin
|
||||
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.library.refresh import RefreshTracker
|
||||
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
|
||||
@@ -1124,7 +1122,6 @@ class QtDriver(DriverMixin, QObject):
|
||||
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(
|
||||
@@ -1141,32 +1138,6 @@ class QtDriver(DriverMixin, QObject):
|
||||
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(
|
||||
@@ -1258,7 +1229,7 @@ class QtDriver(DriverMixin, QObject):
|
||||
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"]:
|
||||
|
||||
@@ -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}<br>{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",
|
||||
|
||||
@@ -12,6 +12,8 @@ from unittest.mock import Mock, patch
|
||||
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)
|
||||
|
||||
|
||||
BIN
tests/fixtures/empty_libraries/DB_VERSION_101/.TagStudio/ts_library.sqlite
vendored
Normal file
BIN
tests/fixtures/empty_libraries/DB_VERSION_101/.TagStudio/ts_library.sqlite
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/empty_libraries/DB_VERSION_103/.TagStudio/ts_library.sqlite
vendored
Normal file
BIN
tests/fixtures/empty_libraries/DB_VERSION_103/.TagStudio/ts_library.sqlite
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/empty_libraries/DB_VERSION_200/.TagStudio/ts_library.sqlite
vendored
Normal file
BIN
tests/fixtures/empty_libraries/DB_VERSION_200/.TagStudio/ts_library.sqlite
vendored
Normal file
Binary file not shown.
Binary file not shown.
@@ -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])
|
||||
|
||||
@@ -27,6 +27,10 @@ EMPTY_LIBRARIES = "empty_libraries"
|
||||
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):
|
||||
|
||||
@@ -12,7 +12,7 @@ import structlog
|
||||
|
||||
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"),
|
||||
],
|
||||
)
|
||||
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])
|
||||
|
||||
# insert new entry and get id
|
||||
entry_id = library.add_entries([target_entry])[0]
|
||||
# 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))
|
||||
|
||||
# get new entry from library
|
||||
new_entry = unwrap(library.get_entry_full(entry_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 onto new entry
|
||||
library.mirror_entry_fields(new_entry, entry_full)
|
||||
# Mirror fields between entries
|
||||
library.mirror_entry_fields([entry_b_, entry_a_, entry_c_])
|
||||
|
||||
# get new entry from library again
|
||||
entry = unwrap(library.get_entry_full(entry_id))
|
||||
# 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))
|
||||
|
||||
# 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,
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user