Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/library-changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,13 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
| [v9.5.4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.4) | 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.

#### Version 104

| Used From | Format | Location |
| ----------------------------------------------------------------------- | ------ | ----------------------------------------------- |
| [v9.5.7](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.7) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |

- Adds a new `url_fields` table.
- Changes the type key of the `URL` and `SOURCE` fields to be `URL`.
- Migrates any records in the `text_fields` whose type key has the type `URL` (so, if their type is either `URL` or `SOURCE`) to the new `url_fields` table.
2 changes: 1 addition & 1 deletion src/tagstudio/core/library/alchemy/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
DB_VERSION_LEGACY_KEY: str = "DB_VERSION"
DB_VERSION_CURRENT_KEY: str = "CURRENT"
DB_VERSION_INITIAL_KEY: str = "INITIAL"
DB_VERSION: int = 102
DB_VERSION: int = 104

TAG_CHILDREN_QUERY = text("""
WITH RECURSIVE ChildTags AS (
Expand Down
1 change: 1 addition & 0 deletions src/tagstudio/core/library/alchemy/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ def with_search_query(self, search_query: str) -> "BrowsingState":
class FieldTypeEnum(enum.Enum):
TEXT_LINE = "Text Line"
TEXT_BOX = "Text Box"
URL = "URL"
TAGS = "Tags"
DATETIME = "Datetime"
BOOLEAN = "Checkbox"
22 changes: 19 additions & 3 deletions src/tagstudio/core/library/alchemy/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,29 @@ def __eq__(self, value: object) -> bool:
raise NotImplementedError


class UrlField(BaseField):
__tablename__ = "url_fields"

title: Mapped[str | None]
value: Mapped[str | None]

def __key(self) -> tuple[ValueType, str | None, str | None]:
return self.type, self.title, self.value

@override
def __eq__(self, value: object) -> bool:
if isinstance(value, UrlField):
return self.__key() == value.__key()
raise NotImplementedError


class DatetimeField(BaseField):
__tablename__ = "datetime_fields"

value: Mapped[str | None]

def __key(self):
return (self.type, self.value)
return self.type, self.value

@override
def __eq__(self, value: object) -> bool:
Expand All @@ -117,7 +133,7 @@ class FieldID(Enum):
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)
URL = DefaultField(id=3, name="URL", type=FieldTypeEnum.URL)
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)
Expand All @@ -132,7 +148,7 @@ class FieldID(Enum):
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)
SOURCE = DefaultField(id=21, name="Source", type=FieldTypeEnum.URL)
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)
Expand Down
114 changes: 90 additions & 24 deletions src/tagstudio/core/library/alchemy/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
DatetimeField,
FieldID,
TextField,
UrlField,
)
from tagstudio.core.library.alchemy.joins import TagEntry, TagParent
from tagstudio.core.library.alchemy.models import (
Expand Down Expand Up @@ -551,6 +552,9 @@ def open_sqlite_library(self, library_dir: Path, is_new: bool) -> LibraryStatus:
self.__apply_db100_parent_repairs(session)
if loaded_db_version < 102:
self.__apply_db102_repairs(session)
if loaded_db_version < 104:
self.__apply_db104_value_type_migration(session)
self.__apply_db104_url_migration(session)

# Convert file extension list to ts_ignore file, if a .ts_ignore file does not exist
self.migrate_sql_to_ts_ignore(library_dir)
Expand Down Expand Up @@ -698,6 +702,66 @@ def __apply_db102_repairs(self, session: Session):
session.commit()
logger.info("[Library][Migration] Verified TagParent table data")

def __apply_db104_value_type_migration(self, session: Session):
"""Changes the type of the URL field types to URL."""
try:
with session:
stmt = (
update(ValueType)
.filter(ValueType.key.in_([FieldID.URL.name, FieldID.SOURCE.name]))
.values(
type=FieldTypeEnum.URL.name,
)
)

session.execute(stmt)
session.commit()
logger.info("[Library][Migration] Changed the type of the URL field types to URL!")
except Exception as e:
logger.error(
"[Library][Migration] Could not change the type of the URL field types to URL!",
error=e,
)
session.rollback()

def __apply_db104_url_migration(self, session: Session):
"""Moves all URL text fields to the new URL field table."""
try:
with session:
# Get all URL fields from the text fields table
source_records = (
session.query(TextField)
.join(ValueType)
.filter(ValueType.type == FieldTypeEnum.URL.name)
.with_for_update()
.all()
)

destination_records = []
for source_record in source_records:
destination_record = UrlField(
title=None,
value=source_record.value,
type_key=source_record.type_key,
entry_id=source_record.entry_id,
position=source_record.position,
)
destination_records.append(destination_record)

for record in source_records:
session.delete(record)

session.add_all(destination_records)
session.commit()

logger.info("[Library][Migration] Migrated URL fields to the url_fields table")
except Exception as e:
logger.error(
"[Library][Migration] Could not migrate URL fields to the url_fields table!",
error=e,
)
session.rollback()

def migrate_sql_to_ts_ignore(self, library_dir: Path):
# Do not continue if existing '.ts_ignore' file is found
if Path(library_dir / TS_FOLDER_NAME / IGNORE_NAME).exists():
Expand Down Expand Up @@ -762,9 +826,11 @@ def get_entry_full(
if with_fields:
entry_stmt = (
entry_stmt.outerjoin(Entry.text_fields)
.outerjoin(Entry.url_fields)
.outerjoin(Entry.datetime_fields)
.options(
selectinload(Entry.text_fields),
selectinload(Entry.url_fields),
selectinload(Entry.datetime_fields),
)
)
Expand Down Expand Up @@ -811,11 +877,13 @@ def get_entries_full(self, entry_ids: list[int] | set[int]) -> Iterator[Entry]:
statement = select(Entry).where(Entry.id.in_(set(entry_ids)))
statement = (
statement.outerjoin(Entry.text_fields)
.outerjoin(Entry.url_fields)
.outerjoin(Entry.datetime_fields)
.outerjoin(Entry.tags)
)
statement = statement.options(
selectinload(Entry.text_fields),
selectinload(Entry.url_fields),
selectinload(Entry.datetime_fields),
selectinload(Entry.tags).options(
selectinload(Tag.aliases),
Expand All @@ -839,8 +907,13 @@ def get_entry_full_by_path(self, path: Path) -> Entry | None:
stmt = select(Entry).where(Entry.path == path)
stmt = (
stmt.outerjoin(Entry.text_fields)
.outerjoin(Entry.url_fields)
.outerjoin(Entry.datetime_fields)
.options(selectinload(Entry.text_fields), selectinload(Entry.datetime_fields))
.options(
selectinload(Entry.text_fields),
selectinload(Entry.url_fields),
selectinload(Entry.datetime_fields),
)
)
stmt = (
stmt.outerjoin(Entry.tags)
Expand Down Expand Up @@ -885,11 +958,13 @@ def all_entries(self, with_joins: bool = False) -> Iterator[Entry]:
# load Entry with all joins and all tags
stmt = (
stmt.outerjoin(Entry.text_fields)
.outerjoin(Entry.url_fields)
.outerjoin(Entry.datetime_fields)
.outerjoin(Entry.tags)
)
stmt = stmt.options(
contains_eager(Entry.text_fields),
contains_eager(Entry.url_fields),
contains_eager(Entry.datetime_fields),
contains_eager(Entry.tags),
)
Expand Down Expand Up @@ -1224,12 +1299,7 @@ def remove_entry_field(
# 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_entry_field(self, entry_ids: list[int] | int, field: BaseField, **kwargs):
if isinstance(entry_ids, int):
entry_ids = [entry_ids]

Expand All @@ -1245,7 +1315,7 @@ def update_entry_field(
FieldClass.entry_id.in_(entry_ids),
)
)
.values(value=content)
.values(**kwargs)
)

session.execute(update_stmt)
Expand All @@ -1268,14 +1338,14 @@ def add_field_to_entry(
*,
field: ValueType | None = None,
field_id: FieldID | str | None = None,
value: str | datetime | None = None,
**kwargs,
) -> bool:
logger.info(
"[Library][add_field_to_entry]",
entry_id=entry_id,
field_type=field,
field_id=field_id,
value=value,
**kwargs,
)
# supply only instance or ID, not both
assert bool(field) != (field_id is not None)
Expand All @@ -1285,20 +1355,16 @@ def add_field_to_entry(
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_model: TextField | UrlField | DatetimeField
match field.type:
case FieldTypeEnum.TEXT_LINE | FieldTypeEnum.TEXT_BOX:
field_model = TextField(type_key=field.key, **kwargs)
case FieldTypeEnum.URL:
field_model = UrlField(type_key=field.key, **kwargs)
case FieldTypeEnum.DATETIME:
field_model = DatetimeField(type_key=field.key, **kwargs)
case _:
raise NotImplementedError(f"field type not implemented: {field.type}")

with Session(self.engine) as session:
try:
Expand Down
10 changes: 10 additions & 0 deletions src/tagstudio/core/library/alchemy/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
BooleanField,
DatetimeField,
TextField,
UrlField,
)
from tagstudio.core.library.alchemy.joins import TagParent

Expand Down Expand Up @@ -211,6 +212,10 @@ class Entry(Base):
back_populates="entry",
cascade="all, delete",
)
url_fields: Mapped[list[UrlField]] = relationship(
back_populates="entry",
cascade="all, delete",
)
datetime_fields: Mapped[list[DatetimeField]] = relationship(
back_populates="entry",
cascade="all, delete",
Expand All @@ -220,6 +225,7 @@ class Entry(Base):
def fields(self) -> list[BaseField]:
fields: list[BaseField] = []
fields.extend(self.text_fields)
fields.extend(self.url_fields)
fields.extend(self.datetime_fields)
fields = sorted(fields, key=lambda field: field.type.position)
return fields
Expand Down Expand Up @@ -260,6 +266,8 @@ def __init__(
for field in fields:
if isinstance(field, TextField):
self.text_fields.append(field)
elif isinstance(field, UrlField):
self.url_fields.append(field)
elif isinstance(field, DatetimeField):
self.datetime_fields.append(field)
else:
Expand Down Expand Up @@ -295,6 +303,7 @@ class ValueType(Base):

# add relations to other tables
text_fields: Mapped[list[TextField]] = relationship("TextField", back_populates="type")
url_fields: Mapped[list[UrlField]] = relationship("UrlField", back_populates="type")
datetime_fields: Mapped[list[DatetimeField]] = relationship(
"DatetimeField", back_populates="type"
)
Expand All @@ -305,6 +314,7 @@ def as_field(self) -> BaseField:
FieldClass = { # noqa: N806
FieldTypeEnum.TEXT_LINE: TextField,
FieldTypeEnum.TEXT_BOX: TextField,
FieldTypeEnum.URL: UrlField,
FieldTypeEnum.DATETIME: DatetimeField,
FieldTypeEnum.BOOLEAN: BooleanField,
}
Expand Down
Loading