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
17 changes: 17 additions & 0 deletions docs/library-changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,3 +201,20 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
| 95e2fe7b4449951c385e35a2e13f0c1925f1f98e | [v9.6.1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.6.1) | SQLite |

- Applies repairs to the `tag_parents` table, removing rows that reference child tags that have been deleted.

#### Version 300

| Added in Commit | Introduced in Release | Format |
| ---------------------------------------- |-------------------------------------------------------------------------| ------ |
| 51a9c16f50ca785d810911d2d0c83fa33eb1c0ae | [v9.6.2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.6.2) | SQLite |

- Drops `folder` columns from the `entries` table.
- Drops the unused `folders` table.

#### Version 301

| Added in Commit | Introduced in Release | Format |
|-----------------|-----------------------| ------ |
| TBD | TBD | SQLite |

- Adds the `category_exclusion` table.
2 changes: 2 additions & 0 deletions docs/tags.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ This means that duplicates of tags can appear on entries if the tag inherits fro

![Tag Category Example](assets/tag_categories_example.png)

If you don't want a tag to appear in one, more, or even all the applicable categories, simply uncheck the category in the "Edit Tag" panel.

### Built-In Tags and Categories

The built-in tags "Favorite" and "Archived" inherit from the built-in "Meta Tags" category which is marked as a category by default. This behavior of default tags can be fully customized by disabling the category option and/or by adding/removing the tags' Parent Tags.
Expand Down
4 changes: 2 additions & 2 deletions src/tagstudio/core/library/alchemy/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@

DB_VERSION_CURRENT_KEY: str = "CURRENT"
DB_VERSION_INITIAL_KEY: str = "INITIAL"
DB_VERSION: int = 300
DB_VERSION: int = 301

TAG_CHILDREN_QUERY = text("""
WITH RECURSIVE ChildTags AS (
SELECT :tag_id AS tag_id
UNION
SELECT tp.child_id AS tag_id
FROM tag_parents tp
FROM tag_parents tp
INNER JOIN ChildTags c ON tp.parent_id = c.tag_id
)
SELECT * FROM ChildTags;
Expand Down
7 changes: 7 additions & 0 deletions src/tagstudio/core/library/alchemy/joins.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,10 @@ class TagEntry(Base):

tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True)
entry_id: Mapped[int] = mapped_column(ForeignKey("entries.id"), primary_key=True)


class CategoryExclusion(Base):
__tablename__ = "category_exclusions"

tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True)
category_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True)
40 changes: 37 additions & 3 deletions src/tagstudio/core/library/alchemy/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@
TextField,
TextFieldTemplate,
)
from tagstudio.core.library.alchemy.joins import TagEntry, TagParent
from tagstudio.core.library.alchemy.joins import CategoryExclusion, TagEntry, TagParent
from tagstudio.core.library.alchemy.models import (
Entry,
Namespace,
Expand Down Expand Up @@ -570,6 +570,7 @@ def open_sqlite_library(
(self.__apply_db201_migration, 201, 200), # changes: field tables
(self.__apply_db202_migration, 202, None), # changes: tag_parents
(self.__apply_db300_migration, 300, None), # changes: deletes folders
(self.__apply_db301_migration, 301, None), # changes: add category_exclusions
]
for migration, v, iv in migrations:
if loaded_db_version < v and (iv is None or initial_db_version < iv):
Expand Down Expand Up @@ -914,6 +915,14 @@ def __apply_db300_migration(self, session: Session, library_dir: Path):
session.execute(text("DROP TABLE folders"))
session.flush()

def __apply_db301_migration(self, session: Session, library_dir: Path):
"""Migrate DB to DB_VERSION 301.

The category_exclusion table is generated by SQLAlchemy.
This only exists set the correct DB_VERSION.
"""
pass

@property
def field_templates(self) -> Sequence[BaseFieldTemplate]:
with Session(self.engine) as session:
Expand Down Expand Up @@ -1717,6 +1726,7 @@ def add_tag(
tag: Tag,
parent_ids: list[int] | set[int] | None = None,
aliases: Iterable[TagAlias] | None = None,
exclusion_ids: list[int] | set[int] | None = None,
) -> Tag | None:
with Session(self.engine, expire_on_commit=False) as session:
try:
Expand All @@ -1733,6 +1743,9 @@ def add_tag(
self.update_aliases(tag, aliases, session)
session.flush()

if exclusion_ids is not None:
self.update_category_exclusion(tag, exclusion_ids, session)

session.commit()
session.expunge(tag)
return tag
Expand Down Expand Up @@ -1861,6 +1874,7 @@ def get_tag(self, tag_id: int) -> Tag | None:
selectinload(Tag.parent_tags),
selectinload(Tag.aliases),
joinedload(Tag.color),
selectinload(Tag.category_exclusions),
)
tag = session.scalar(tags_query.where(Tag.id == tag_id))

Expand Down Expand Up @@ -1931,7 +1945,10 @@ def get_tag_hierarchy(self, tag_ids: Iterable[int]) -> dict[int, Tag]:

statement = select(Tag).where(Tag.id.in_(all_tag_ids))
statement = statement.options(
noload(Tag.parent_tags), selectinload(Tag.aliases), joinedload(Tag.color)
noload(Tag.parent_tags),
selectinload(Tag.aliases),
selectinload(Tag.category_exclusions),
joinedload(Tag.color),
)
tags = session.scalars(statement).fetchall()
for tag in tags:
Expand Down Expand Up @@ -2010,9 +2027,10 @@ def update_tag(
tag: Tag,
parent_ids: list[int] | set[int] | None = None,
aliases: Iterable[TagAlias] | None = None,
exclusion_ids: list[int] | set[int] | None = None,
) -> None:
"""Edit a Tag in the Library."""
self.add_tag(tag, parent_ids, aliases)
self.add_tag(tag, parent_ids, aliases, exclusion_ids)

def update_color(self, old_color_group: TagColorGroup, new_color_group: TagColorGroup) -> None:
"""Update a TagColorGroup in the Library. If it doesn't already exist, create it."""
Expand Down Expand Up @@ -2133,6 +2151,22 @@ def update_parent_tags(self, tag: Tag, parent_ids: list[int] | set[int], session
)
session.add(parent_tag)

@staticmethod
def update_category_exclusion(tag: Tag, exclusion_ids: list[int] | set[int], session: Session):
prev_exclusions = session.scalars(
select(CategoryExclusion).where(CategoryExclusion.tag_id == tag.id)
).all()

for exclusion in prev_exclusions:
if exclusion.category_id not in exclusion_ids:
session.delete(exclusion)
else:
exclusion_ids.remove(exclusion.category_id)

for exclusion_id in exclusion_ids:
exclusion = CategoryExclusion(tag_id=tag.id, category_id=exclusion_id)
session.add(exclusion)

def get_version(self, key: str) -> int:
"""Get a version value from the DB.

Expand Down
14 changes: 13 additions & 1 deletion src/tagstudio/core/library/alchemy/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
DatetimeField,
TextField,
)
from tagstudio.core.library.alchemy.joins import TagParent
from tagstudio.core.library.alchemy.joins import CategoryExclusion, TagParent


class Namespace(Base):
Expand Down Expand Up @@ -104,6 +104,12 @@ class Tag(Base):
back_populates="parent_tags",
)
disambiguation_id: Mapped[int | None]
category_exclusions: Mapped[set["Tag"]] = relationship(
secondary=CategoryExclusion.__tablename__,
primaryjoin="Tag.id == CategoryExclusion.tag_id",
secondaryjoin="Tag.id == CategoryExclusion.category_id",
back_populates="category_exclusions",
)

__table_args__ = (
ForeignKeyConstraint(
Expand All @@ -124,6 +130,10 @@ def alias_strings(self) -> list[str]:
def alias_ids(self) -> list[int]:
return [tag.id for tag in self.aliases]

@property
def exclusion_ids(self) -> list[int]:
return [tag.id for tag in self.category_exclusions]

def __init__(
self,
name: str,
Expand All @@ -137,6 +147,7 @@ def __init__(
disambiguation_id: int | None = None,
is_category: bool = False,
is_hidden: bool = False,
category_exclusions: set["Tag"] | None = None,
):
self.name = name
self.aliases = aliases or set()
Expand All @@ -149,6 +160,7 @@ def __init__(
self.is_category = is_category
self.is_hidden = is_hidden
self.id = id # pyright: ignore[reportAttributeAccessIssue]
self.category_exclusions = category_exclusions or set()
super().__init__()

@override
Expand Down
1 change: 1 addition & 0 deletions src/tagstudio/qt/controllers/tag_box_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ def _update_tag_callback(self, build_tag_panel: BuildTagPanel):
build_tag_panel.build_tag(),
parent_ids=set(build_tag_panel.parent_ids),
aliases=set(build_tag_panel.aliases),
exclusion_ids=set(build_tag_panel.exclusion_ids),
)
self.on_update.emit()

Expand Down
6 changes: 5 additions & 1 deletion src/tagstudio/qt/controllers/tag_search_panel_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,10 @@ def create_item(self, edit_item_panel: ModalContent, choose_item: bool = False)
if isinstance(edit_item_panel, BuildTagPanel):
tag: Tag = edit_item_panel.build_tag()
self._lib.add_tag(
tag, parent_ids=edit_item_panel.parent_ids, aliases=edit_item_panel.aliases
tag,
parent_ids=edit_item_panel.parent_ids,
aliases=edit_item_panel.aliases,
exclusion_ids=edit_item_panel.exclusion_ids,
)

if choose_item:
Expand All @@ -188,6 +191,7 @@ def edit_item(self, edit_item_panel: ModalContent) -> None:
tag=edit_item_panel.build_tag(),
parent_ids=edit_item_panel.parent_ids,
aliases=edit_item_panel.aliases,
exclusion_ids=edit_item_panel.exclusion_ids,
)
self.update_items(self.layout().search_field.text())

Expand Down
6 changes: 5 additions & 1 deletion src/tagstudio/qt/controllers/tag_suggest_box.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,10 @@ def _create_item_from_modal(self, edit_item_panel: ModalContent) -> None:
if isinstance(edit_item_panel, BuildTagPanel):
tag: Tag = edit_item_panel.build_tag()
self._lib.add_tag(
tag, parent_ids=edit_item_panel.parent_ids, aliases=edit_item_panel.aliases
tag,
parent_ids=edit_item_panel.parent_ids,
aliases=edit_item_panel.aliases,
exclusion_ids=edit_item_panel.exclusion_ids,
)
self._on_item_chosen(tag)
self._clear_search_query()
Expand All @@ -158,6 +161,7 @@ def _edit_item(self, edit_item_panel: ModalContent) -> None:
tag=edit_item_panel.build_tag(),
parent_ids=edit_item_panel.parent_ids,
aliases=edit_item_panel.aliases,
exclusion_ids=edit_item_panel.exclusion_ids,
)
self._update_items(self.layout().search_field.text())

Expand Down
Loading
Loading