Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
9a25eb6
feat: add per-tag display options.
Sola-ris Aug 8, 2026
5f23554
feat: bump DB_VERSION to 301, add migration.
Sola-ris Aug 8, 2026
d38f660
tests: add tests.
Sola-ris Aug 8, 2026
109b696
docs: document cateogry display options.
Sola-ris Aug 8, 2026
28a812f
docs: document library versions 300 and 301.
Sola-ris Aug 8, 2026
011113f
fix: resolve ruff and pyright issues.
Sola-ris Aug 8, 2026
a515091
Merge branch 'main' into feat/category-exclusions-v2
Sola-ris Aug 12, 2026
7f92d94
fix: redo migration in the new style.
Sola-ris Aug 12, 2026
a2ac6d7
fix: Remove unnecessary @staticmethod decorators.
Sola-ris Aug 14, 2026
960d950
fix: Bump library version to 400.
Sola-ris Aug 14, 2026
022a16c
fix: Fix color order in colored_radio_button_style.
Sola-ris Aug 15, 2026
9356cc4
fix: Commit the library produced by pytest.
Sola-ris Aug 15, 2026
b22a7a0
test: Add test for removing a category during tag creation.
Sola-ris Aug 15, 2026
f3d9cc3
fix: Fix removing categories during tag creation.
Sola-ris Aug 15, 2026
04a29f7
test: Add test for adding another tag after removing an existing cate…
Sola-ris Aug 15, 2026
9544369
fix: Fix adding another tag after removing an existing category.
Sola-ris Aug 15, 2026
9626aa1
feat: Add separators between widgets.
CyanVoxel Aug 15, 2026
49d1e71
docs: Fix version in library-changes.md.
Sola-ris Aug 15, 2026
c870503
test: Test removing a category inherited both directly and indirectly.
Sola-ris Aug 16, 2026
7f31bda
fix: Fix removing a category inherited both directly and indirectly.
Sola-ris Aug 16, 2026
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 400

| 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 = 400

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)
32 changes: 29 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,23 @@ def update_parent_tags(self, tag: Tag, parent_ids: list[int] | set[int], session
)
session.add(parent_tag)

def _update_category_exclusion(
self, 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
MigrationTo400, # 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 MigrationTo400(DBMigration):
version = 400

@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