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 @@ -14,14 +14,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)
31 changes: 28 additions & 3 deletions src/tagstudio/core/library/alchemy/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,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.migrations import DBMigrations, MigrationError
from tagstudio.core.library.alchemy.models import (
Entry,
Expand Down Expand Up @@ -1326,6 +1326,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 @@ -1342,6 +1343,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 @@ -1471,6 +1475,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 @@ -1541,7 +1546,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 @@ -1620,9 +1628,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 @@ -1743,6 +1752,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
21 changes: 21 additions & 0 deletions src/tagstudio/core/library/alchemy/migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ def run(self):
MigrationTo201, # changes: field tables
MigrationTo202, # changes: tag_parents
MigrationTo300, # changes: deletes folders
MigrationTo301, # changes: add category_exclusions
]
with Session(self.engine) as session:
for migration in migrations:
Expand Down Expand Up @@ -569,3 +570,23 @@ def run(cls, session: Session, library_dir: Path, fmt_log):
## drop table "folders"
session.execute(text("DROP TABLE folders"))
session.flush()


class MigrationTo301(DBMigration):
version = 301

@override
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log):
logger.info(fmt_log("Creating category_exclusions table..."))
session.execute(
text("""
CREATE TABLE category_exclusions (
tag_id INTEGER NOT NULL REFERENCES tags(id),
category_id INTEGER NOT NULL REFERENCES tags(id),

PRIMARY KEY (tag_id, category_id)
)
""")
)
session.flush()
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